Merge branch 'main' into xcode-style-breakpoint-indicator

Piotr Osiewicz created

Change summary

.cargo/config.toml                                                                                    |    6 
.git-blame-ignore-revs                                                                                |    4 
.github/ISSUE_TEMPLATE/01_bug_ai.yml                                                                  |   21 
.github/ISSUE_TEMPLATE/02_bug_edit_predictions.yml                                                    |   36 
.github/ISSUE_TEMPLATE/04_bug_debugger.yml                                                            |   16 
.github/ISSUE_TEMPLATE/10_bug_report.yml                                                              |   10 
.github/ISSUE_TEMPLATE/11_crash_report.yml                                                            |    4 
.github/actions/build_docs/action.yml                                                                 |   32 
.github/actions/run_tests_windows/action.yml                                                          |    8 
.github/workflows/ci.yml                                                                              |  260 
.github/workflows/community_delete_comments.yml                                                       |   34 
.github/workflows/deploy_cloudflare.yml                                                               |   19 
.github/workflows/deploy_collab.yml                                                                   |    4 
.github/workflows/eval.yml                                                                            |   11 
.github/workflows/nix.yml                                                                             |   66 
.github/workflows/release_nightly.yml                                                                 |   57 
.github/workflows/unit_evals.yml                                                                      |   86 
.gitignore                                                                                            |    2 
.mailmap                                                                                              |   20 
.rules                                                                                                |   12 
.zed/debug.json                                                                                       |   23 
.zed/settings.json                                                                                    |    2 
Cargo.lock                                                                                            |  365 
Cargo.toml                                                                                            |   75 
Dockerfile-collab                                                                                     |    2 
README.md                                                                                             |    4 
assets/icons/ai_open_router.svg                                                                       |    8 
assets/icons/ai_v_zero.svg                                                                            |   16 
assets/icons/arrow_down10.svg                                                                         |    1 
assets/icons/arrow_up_alt.svg                                                                         |    3 
assets/icons/blocks.svg                                                                               |    2 
assets/icons/bolt.svg                                                                                 |    4 
assets/icons/bolt_filled.svg                                                                          |    3 
assets/icons/bolt_filled_alt.svg                                                                      |    3 
assets/icons/circle_help.svg                                                                          |    1 
assets/icons/cursor_i_beam.svg                                                                        |    7 
assets/icons/file_icons/cairo.svg                                                                     |    1 
assets/icons/list_todo.svg                                                                            |    1 
assets/icons/lsp_debug.svg                                                                            |   12 
assets/icons/lsp_restart.svg                                                                          |    4 
assets/icons/lsp_stop.svg                                                                             |    4 
assets/icons/play_alt.svg                                                                             |    3 
assets/icons/play_bug.svg                                                                             |    8 
assets/icons/scroll_text.svg                                                                          |    1 
assets/icons/sliders.svg                                                                              |    1 
assets/icons/split_alt.svg                                                                            |    1 
assets/icons/star.svg                                                                                 |    0 
assets/icons/star_filled.svg                                                                          |    2 
assets/icons/zed_assistant.svg                                                                        |    8 
assets/icons/zed_burn_mode.svg                                                                        |    3 
assets/icons/zed_burn_mode_on.svg                                                                     |   13 
assets/icons/zed_max_mode.svg                                                                         |   14 
assets/icons/zed_mcp_custom.svg                                                                       |    2 
assets/icons/zed_mcp_extension.svg                                                                    |    2 
assets/images/debugger_grid.svg                                                                       |  890 
assets/keymaps/default-linux.json                                                                     |  165 
assets/keymaps/default-macos.json                                                                     |  165 
assets/keymaps/initial.json                                                                           |    4 
assets/keymaps/linux/atom.json                                                                        |   18 
assets/keymaps/linux/cursor.json                                                                      |   83 
assets/keymaps/linux/emacs.json                                                                       |   12 
assets/keymaps/linux/sublime_text.json                                                                |    8 
assets/keymaps/macos/atom.json                                                                        |   20 
assets/keymaps/macos/cursor.json                                                                      |   84 
assets/keymaps/macos/emacs.json                                                                       |   12 
assets/keymaps/macos/sublime_text.json                                                                |    6 
assets/keymaps/vim.json                                                                               |   53 
assets/prompts/assistant_system_prompt.hbs                                                            |    7 
assets/settings/default.json                                                                          |  226 
assets/settings/initial_debug_tasks.json                                                              |   16 
assets/settings/initial_local_debug_tasks.json                                                        |    5 
assets/settings/initial_tasks.json                                                                    |    2 
assets/sounds/agent_done.wav                                                                          |    0 
assets/themes/ayu/ayu.json                                                                            |   57 
assets/themes/gruvbox/gruvbox.json                                                                    |  126 
assets/themes/one/one.json                                                                            |   46 
crates/activity_indicator/Cargo.toml                                                                  |    4 
crates/activity_indicator/src/activity_indicator.rs                                                   |  400 
crates/agent/Cargo.toml                                                                               |   51 
crates/agent/src/agent.rs                                                                             |  272 
crates/agent/src/agent_configuration.rs                                                               |  623 
crates/agent/src/agent_configuration/add_context_server_modal.rs                                      |  197 
crates/agent/src/agent_configuration/configure_context_server_modal.rs                                |  554 
crates/agent/src/agent_profile.rs                                                                     |  341 
crates/agent/src/context.rs                                                                           |   98 
crates/agent/src/context_server_configuration.rs                                                      |  140 
crates/agent/src/context_server_tool.rs                                                               |   19 
crates/agent/src/context_store.rs                                                                     |  105 
crates/agent/src/history_store.rs                                                                     |  283 
crates/agent/src/prompts/summarize_thread_detailed_prompt.txt                                         |    6 
crates/agent/src/prompts/summarize_thread_prompt.txt                                                  |    4 
crates/agent/src/thread.rs                                                                            |  566 
crates/agent/src/thread_store.rs                                                                      |  797 
crates/agent/src/tool_use.rs                                                                          |  119 
crates/agent/src/trial_markdown.md                                                                    |    3 
crates/agent/src/ui/max_mode_tooltip.rs                                                               |   59 
crates/agent_settings/Cargo.toml                                                                      |   11 
crates/agent_settings/LICENSE-GPL                                                                     |    0 
crates/agent_settings/src/agent_profile.rs                                                            |   25 
crates/agent_settings/src/agent_settings.rs                                                           |  505 
crates/agent_ui/Cargo.toml                                                                            |  110 
crates/agent_ui/LICENSE-GPL                                                                           |    0 
crates/agent_ui/src/active_thread.rs                                                                  |  631 
crates/agent_ui/src/agent_configuration.rs                                                            |  997 
crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs                             |  763 
crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs                                      |   78 
crates/agent_ui/src/agent_configuration/manage_profiles_modal/profile_modal_header.rs                 |    0 
crates/agent_ui/src/agent_configuration/tool_picker.rs                                                |  125 
crates/agent_ui/src/agent_diff.rs                                                                     |   80 
crates/agent_ui/src/agent_model_selector.rs                                                           |   86 
crates/agent_ui/src/agent_panel.rs                                                                    |  635 
crates/agent_ui/src/agent_ui.rs                                                                       |  292 
crates/agent_ui/src/buffer_codegen.rs                                                                 |   35 
crates/agent_ui/src/burn_mode_tooltip.rs                                                              |   61 
crates/agent_ui/src/context_picker.rs                                                                 |   21 
crates/agent_ui/src/context_picker/completion_provider.rs                                             |  125 
crates/agent_ui/src/context_picker/fetch_context_picker.rs                                            |    2 
crates/agent_ui/src/context_picker/file_context_picker.rs                                             |    2 
crates/agent_ui/src/context_picker/rules_context_picker.rs                                            |    4 
crates/agent_ui/src/context_picker/symbol_context_picker.rs                                           |    6 
crates/agent_ui/src/context_picker/thread_context_picker.rs                                           |   32 
crates/agent_ui/src/context_server_configuration.rs                                                   |  116 
crates/agent_ui/src/context_strip.rs                                                                  |  100 
crates/agent_ui/src/debug.rs                                                                          |   10 
crates/agent_ui/src/inline_assistant.rs                                                               |  169 
crates/agent_ui/src/inline_prompt_editor.rs                                                           |   86 
crates/agent_ui/src/language_model_selector.rs                                                        |  403 
crates/agent_ui/src/message_editor.rs                                                                 |  652 
crates/agent_ui/src/profile_selector.rs                                                               |  103 
crates/agent_ui/src/slash_command.rs                                                                  |  218 
crates/agent_ui/src/slash_command_picker.rs                                                           |   14 
crates/agent_ui/src/slash_command_settings.rs                                                         |    0 
crates/agent_ui/src/terminal_codegen.rs                                                               |   14 
crates/agent_ui/src/terminal_inline_assistant.rs                                                      |   23 
crates/agent_ui/src/text_thread_editor.rs                                                             |  744 
crates/agent_ui/src/thread_history.rs                                                                 |   23 
crates/agent_ui/src/tool_compatibility.rs                                                             |   24 
crates/agent_ui/src/ui.rs                                                                             |    4 
crates/agent_ui/src/ui/agent_notification.rs                                                          |    0 
crates/agent_ui/src/ui/animated_label.rs                                                              |    0 
crates/agent_ui/src/ui/burn_mode_tooltip.rs                                                           |   70 
crates/agent_ui/src/ui/context_pill.rs                                                                |  252 
crates/agent_ui/src/ui/onboarding_modal.rs                                                            |    0 
crates/agent_ui/src/ui/preview.rs                                                                     |    0 
crates/agent_ui/src/ui/preview/agent_preview.rs                                                       |   38 
crates/agent_ui/src/ui/preview/usage_callouts.rs                                                      |   60 
crates/agent_ui/src/ui/upsell.rs                                                                      |    0 
crates/anthropic/src/anthropic.rs                                                                     |  325 
crates/askpass/Cargo.toml                                                                             |    1 
crates/askpass/src/askpass.rs                                                                         |   36 
crates/assets/src/assets.rs                                                                           |    6 
crates/assistant_context/Cargo.toml                                                                   |   19 
crates/assistant_context/LICENSE-GPL                                                                  |    0 
crates/assistant_context/src/assistant_context.rs                                                     |  111 
crates/assistant_context/src/assistant_context_tests.rs                                               |   10 
crates/assistant_context/src/context_store.rs                                                         |  168 
crates/assistant_context_editor/src/assistant_context_editor.rs                                       |   34 
crates/assistant_context_editor/src/context_history.rs                                                |  271 
crates/assistant_settings/src/assistant_settings.rs                                                   | 1074 
crates/assistant_slash_command/src/assistant_slash_command.rs                                         |   14 
crates/assistant_slash_commands/Cargo.toml                                                            |    3 
crates/assistant_slash_commands/src/assistant_slash_commands.rs                                       |   18 
crates/assistant_slash_commands/src/context_server_command.rs                                         |   95 
crates/assistant_slash_commands/src/delta_command.rs                                                  |    7 
crates/assistant_slash_commands/src/diagnostics_command.rs                                            |    5 
crates/assistant_slash_commands/src/docs_command.rs                                                   |   18 
crates/assistant_slash_commands/src/fetch_command.rs                                                  |    2 
crates/assistant_slash_commands/src/file_command.rs                                                   |   50 
crates/assistant_slash_commands/src/tab_command.rs                                                    |    1 
crates/assistant_tool/Cargo.toml                                                                      |    3 
crates/assistant_tool/src/action_log.rs                                                               |  831 
crates/assistant_tool/src/assistant_tool.rs                                                           |   41 
crates/assistant_tool/src/outline.rs                                                                  |   10 
crates/assistant_tool/src/tool_schema.rs                                                              |  118 
crates/assistant_tool/src/tool_working_set.rs                                                         |   84 
crates/assistant_tools/Cargo.toml                                                                     |    9 
crates/assistant_tools/src/assistant_tools.rs                                                         |    9 
crates/assistant_tools/src/batch_tool/description.md                                                  |    9 
crates/assistant_tools/src/code_action_tool/description.md                                            |   19 
crates/assistant_tools/src/code_symbols_tool/description.md                                           |   39 
crates/assistant_tools/src/contents_tool/description.md                                               |    9 
crates/assistant_tools/src/copy_path_tool.rs                                                          |   24 
crates/assistant_tools/src/create_directory_tool.rs                                                   |   12 
crates/assistant_tools/src/delete_path_tool.rs                                                        |   30 
crates/assistant_tools/src/diagnostics_tool.rs                                                        |    4 
crates/assistant_tools/src/edit_agent.rs                                                              |  769 
crates/assistant_tools/src/edit_agent/create_file_parser.rs                                           |  234 
crates/assistant_tools/src/edit_agent/edit_parser.rs                                                  |  756 
crates/assistant_tools/src/edit_agent/evals.rs                                                        |  594 
crates/assistant_tools/src/edit_agent/evals/fixtures/add_overwrite_test/before.rs                     |    4 
crates/assistant_tools/src/edit_agent/evals/fixtures/delete_run_git_blame/after.rs                    |    8 
crates/assistant_tools/src/edit_agent/evals/fixtures/delete_run_git_blame/before.rs                   |   17 
crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs                |  222 
crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/after.rs           |  378 
crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/before.rs          |   17 
crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-01.diff   |   11 
crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-02.diff   |   26 
crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-03.diff   |   11 
crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-04.diff   |   24 
crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-05.diff   |   26 
crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-06.diff   |   23 
crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-07.diff   |   26 
crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-08.diff   |   26 
crates/assistant_tools/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs |   66 
crates/assistant_tools/src/edit_agent/evals/fixtures/zode/prompt.md                                   |    2 
crates/assistant_tools/src/edit_agent/streaming_fuzzy_matcher.rs                                      |  803 
crates/assistant_tools/src/edit_file_tool.rs                                                          |  780 
crates/assistant_tools/src/fetch_tool.rs                                                              |   20 
crates/assistant_tools/src/find_path_tool.rs                                                          |   53 
crates/assistant_tools/src/grep_tool.rs                                                               |  545 
crates/assistant_tools/src/grep_tool/description.md                                                   |    1 
crates/assistant_tools/src/list_directory_tool.rs                                                     |  745 
crates/assistant_tools/src/move_path_tool.rs                                                          |   21 
crates/assistant_tools/src/now_tool.rs                                                                |    4 
crates/assistant_tools/src/open_tool.rs                                                               |    4 
crates/assistant_tools/src/read_file_tool.rs                                                          |  670 
crates/assistant_tools/src/rename_tool/description.md                                                 |   15 
crates/assistant_tools/src/symbol_info_tool/description.md                                            |   11 
crates/assistant_tools/src/templates/create_file_prompt.hbs                                           |   13 
crates/assistant_tools/src/templates/edit_file_prompt.hbs                                             |   53 
crates/assistant_tools/src/templates/edit_file_prompt_diff_fenced.hbs                                 |   77 
crates/assistant_tools/src/templates/edit_file_prompt_xml.hbs                                         |   92 
crates/assistant_tools/src/terminal_tool.rs                                                           |  205 
crates/assistant_tools/src/thinking_tool.rs                                                           |    4 
crates/assistant_tools/src/ui.rs                                                                      |    2 
crates/assistant_tools/src/ui/tool_output_preview.rs                                                  |  115 
crates/assistant_tools/src/web_search_tool.rs                                                         |   32 
crates/audio/src/assets.rs                                                                            |    6 
crates/audio/src/audio.rs                                                                             |    2 
crates/auto_update/src/auto_update.rs                                                                 |  502 
crates/auto_update_helper/src/auto_update_helper.rs                                                   |    2 
crates/auto_update_helper/src/updater.rs                                                              |    6 
crates/auto_update_ui/src/auto_update_ui.rs                                                           |   14 
crates/bedrock/src/bedrock.rs                                                                         |    6 
crates/bedrock/src/models.rs                                                                          |  414 
crates/breadcrumbs/Cargo.toml                                                                         |    1 
crates/breadcrumbs/src/breadcrumbs.rs                                                                 |   65 
crates/buffer_diff/Cargo.toml                                                                         |    2 
crates/buffer_diff/src/buffer_diff.rs                                                                 |   12 
crates/call/src/call_impl/mod.rs                                                                      |   19 
crates/call/src/call_impl/participant.rs                                                              |   16 
crates/call/src/call_impl/room.rs                                                                     |   63 
crates/call/src/call_settings.rs                                                                      |    1 
crates/channel/Cargo.toml                                                                             |    1 
crates/channel/src/channel_buffer.rs                                                                  |   12 
crates/channel/src/channel_chat.rs                                                                    |   38 
crates/channel/src/channel_store.rs                                                                   |  200 
crates/channel/src/channel_store/channel_index.rs                                                     |   30 
crates/channel/src/channel_store_tests.rs                                                             |  115 
crates/cli/src/cli.rs                                                                                 |    1 
crates/cli/src/main.rs                                                                                |   55 
crates/client/Cargo.toml                                                                              |   17 
crates/client/src/client.rs                                                                           |  121 
crates/client/src/proxy.rs                                                                            |   66 
crates/client/src/proxy/http_proxy.rs                                                                 |  193 
crates/client/src/proxy/socks_proxy.rs                                                                |  226 
crates/client/src/socks.rs                                                                            |  176 
crates/client/src/telemetry.rs                                                                        |  313 
crates/client/src/test.rs                                                                             |   10 
crates/client/src/user.rs                                                                             |  205 
crates/collab/Cargo.toml                                                                              |    9 
crates/collab/README.md                                                                               |    2 
crates/collab/k8s/environments/production.sh                                                          |    2 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql                                        |   10 
crates/collab/migrations/20250530175450_add_channel_order.sql                                         |   16 
crates/collab/migrations/20250612153105_add_collaborator_commit_email.sql                             |    4 
crates/collab/migrations/20250617082236_add_debug_adapter_provides_field_to_extensions.sql            |    2 
crates/collab/migrations_llm/20250521211721_drop_monthly_and_lifetime_usages_tables.sql               |    2 
crates/collab/migrations_llm/20250521222416_drop_billing_events_table.sql                             |    1 
crates/collab/src/api.rs                                                                              |   93 
crates/collab/src/api/billing.rs                                                                      |  548 
crates/collab/src/api/contributors.rs                                                                 |    3 
crates/collab/src/api/extensions.rs                                                                   |   14 
crates/collab/src/api/ips_file.rs                                                                     |   15 
crates/collab/src/auth.rs                                                                             |    8 
crates/collab/src/db.rs                                                                               |   52 
crates/collab/src/db/queries/access_tokens.rs                                                         |    3 
crates/collab/src/db/queries/billing_customers.rs                                                     |   21 
crates/collab/src/db/queries/billing_preferences.rs                                                   |   10 
crates/collab/src/db/queries/billing_subscriptions.rs                                                 |   38 
crates/collab/src/db/queries/buffers.rs                                                               |   19 
crates/collab/src/db/queries/channels.rs                                                              |  189 
crates/collab/src/db/queries/contacts.rs                                                              |    8 
crates/collab/src/db/queries/contributors.rs                                                          |    8 
crates/collab/src/db/queries/extensions.rs                                                            |   28 
crates/collab/src/db/queries/messages.rs                                                              |    3 
crates/collab/src/db/queries/notifications.rs                                                         |    5 
crates/collab/src/db/queries/processed_stripe_events.rs                                               |    6 
crates/collab/src/db/queries/projects.rs                                                              |   70 
crates/collab/src/db/queries/rooms.rs                                                                 |   32 
crates/collab/src/db/queries/servers.rs                                                               |   83 
crates/collab/src/db/queries/users.rs                                                                 |   11 
crates/collab/src/db/tables/billing_subscription.rs                                                   |   15 
crates/collab/src/db/tables/channel.rs                                                                |    3 
crates/collab/src/db/tables/extension_version.rs                                                      |    5 
crates/collab/src/db/tables/project.rs                                                                |    6 
crates/collab/src/db/tables/project_collaborator.rs                                                   |    2 
crates/collab/src/db/tables/user.rs                                                                   |    5 
crates/collab/src/db/tests.rs                                                                         |   62 
crates/collab/src/db/tests/buffer_tests.rs                                                            |    4 
crates/collab/src/db/tests/channel_tests.rs                                                           |  321 
crates/collab/src/db/tests/db_tests.rs                                                                |   19 
crates/collab/src/db/tests/embedding_tests.rs                                                         |    5 
crates/collab/src/env.rs                                                                              |    6 
crates/collab/src/lib.rs                                                                              |   29 
crates/collab/src/llm.rs                                                                              |    8 
crates/collab/src/llm/db.rs                                                                           |   14 
crates/collab/src/llm/db/queries/usages.rs                                                            |   68 
crates/collab/src/llm/db/tables.rs                                                                    |    1 
crates/collab/src/llm/db/tables/monthly_usage.rs                                                      |   22 
crates/collab/src/llm/token.rs                                                                        |   35 
crates/collab/src/main.rs                                                                             |    9 
crates/collab/src/migrations.rs                                                                       |   11 
crates/collab/src/rpc.rs                                                                              |  317 
crates/collab/src/rpc/connection_pool.rs                                                              |    4 
crates/collab/src/seed.rs                                                                             |    2 
crates/collab/src/stripe_billing.rs                                                                   |  328 
crates/collab/src/stripe_client.rs                                                                    |  273 
crates/collab/src/stripe_client/fake_stripe_client.rs                                                 |  245 
crates/collab/src/stripe_client/real_stripe_client.rs                                                 |  592 
crates/collab/src/tests.rs                                                                            |    5 
crates/collab/src/tests/channel_buffer_tests.rs                                                       |   10 
crates/collab/src/tests/channel_guest_tests.rs                                                        |    2 
crates/collab/src/tests/debug_panel_tests.rs                                                          |    4 
crates/collab/src/tests/editor_tests.rs                                                               |  919 
crates/collab/src/tests/following_tests.rs                                                            |   27 
crates/collab/src/tests/integration_tests.rs                                                          |  160 
crates/collab/src/tests/random_project_collaboration_tests.rs                                         |    5 
crates/collab/src/tests/remote_editing_collaboration_tests.rs                                         |   12 
crates/collab/src/tests/stripe_billing_tests.rs                                                       |  603 
crates/collab/src/tests/test_server.rs                                                                |   20 
crates/collab/src/user_backfiller.rs                                                                  |   11 
crates/collab_ui/src/channel_view.rs                                                                  |   21 
crates/collab_ui/src/chat_panel.rs                                                                    |    7 
crates/collab_ui/src/chat_panel/message_editor.rs                                                     |   87 
crates/collab_ui/src/collab_panel.rs                                                                  |  124 
crates/collab_ui/src/collab_panel/channel_modal.rs                                                    |    1 
crates/collab_ui/src/notifications/project_shared_notification.rs                                     |    4 
crates/collab_ui/src/notifications/stories/collab_notification.rs                                     |    6 
crates/collab_ui/src/panel_settings.rs                                                                |    3 
crates/command_palette/src/command_palette.rs                                                         |   11 
crates/component/Cargo.toml                                                                           |    2 
crates/component/src/component.rs                                                                     |   31 
crates/context_server/Cargo.toml                                                                      |    3 
crates/context_server/src/client.rs                                                                   |    6 
crates/context_server/src/context_server.rs                                                           |   21 
crates/context_server/src/protocol.rs                                                                 |  167 
crates/context_server/src/test.rs                                                                     |  118 
crates/context_server/src/types.rs                                                                    |  316 
crates/copilot/Cargo.toml                                                                             |    7 
crates/copilot/src/copilot.rs                                                                         |  196 
crates/copilot/src/copilot_chat.rs                                                                    |  651 
crates/copilot/src/copilot_completion_provider.rs                                                     |   13 
crates/dap/Cargo.toml                                                                                 |    8 
crates/dap/src/adapters.rs                                                                            |  253 
crates/dap/src/client.rs                                                                              |  209 
crates/dap/src/dap.rs                                                                                 |   58 
crates/dap/src/inline_value.rs                                                                        |  257 
crates/dap/src/proto_conversions.rs                                                                   |    6 
crates/dap/src/registry.rs                                                                            |   81 
crates/dap/src/transport.rs                                                                           |  790 
crates/dap_adapters/Cargo.toml                                                                        |    4 
crates/dap_adapters/src/codelldb.rs                                                                   |  335 
crates/dap_adapters/src/dap_adapters.rs                                                               |   49 
crates/dap_adapters/src/gdb.rs                                                                        |  175 
crates/dap_adapters/src/go.rs                                                                         |  517 
crates/dap_adapters/src/javascript.rs                                                                 |  492 
crates/dap_adapters/src/php.rs                                                                        |  310 
crates/dap_adapters/src/python.rs                                                                     |  731 
crates/dap_adapters/src/ruby.rs                                                                       |  172 
crates/db/src/db.rs                                                                                   |    4 
crates/db/src/kvp.rs                                                                                  |   27 
crates/debug_adapter_extension/Cargo.toml                                                             |   23 
crates/debug_adapter_extension/LICENSE-GPL                                                            |    1 
crates/debug_adapter_extension/src/debug_adapter_extension.rs                                         |   66 
crates/debug_adapter_extension/src/extension_dap_adapter.rs                                           |  117 
crates/debug_adapter_extension/src/extension_locator_adapter.rs                                       |   50 
crates/debugger_tools/src/dap_log.rs                                                                  |  479 
crates/debugger_ui/Cargo.toml                                                                         |   15 
crates/debugger_ui/src/attach_modal.rs                                                                |   61 
crates/debugger_ui/src/debugger_panel.rs                                                              |  729 
crates/debugger_ui/src/debugger_ui.rs                                                                 |  465 
crates/debugger_ui/src/dropdown_menus.rs                                                              |  223 
crates/debugger_ui/src/new_process_modal.rs                                                           | 1583 
crates/debugger_ui/src/new_session_modal.rs                                                           |  924 
crates/debugger_ui/src/onboarding_modal.rs                                                            |  166 
crates/debugger_ui/src/persistence.rs                                                                 |   78 
crates/debugger_ui/src/session.rs                                                                     |   60 
crates/debugger_ui/src/session/running.rs                                                             |  560 
crates/debugger_ui/src/session/running/breakpoint_list.rs                                             |  849 
crates/debugger_ui/src/session/running/console.rs                                                     |  664 
crates/debugger_ui/src/session/running/module_list.rs                                                 |  189 
crates/debugger_ui/src/session/running/stack_frame_list.rs                                            |  461 
crates/debugger_ui/src/session/running/variable_list.rs                                               |  736 
crates/debugger_ui/src/stack_trace_view.rs                                                            |  454 
crates/debugger_ui/src/tests.rs                                                                       |   21 
crates/debugger_ui/src/tests/attach_modal.rs                                                          |   16 
crates/debugger_ui/src/tests/console.rs                                                               |  205 
crates/debugger_ui/src/tests/debugger_panel.rs                                                        |  270 
crates/debugger_ui/src/tests/inline_values.rs                                                         |  482 
crates/debugger_ui/src/tests/module_list.rs                                                           |    4 
crates/debugger_ui/src/tests/new_process_modal.rs                                                     |  351 
crates/debugger_ui/src/tests/stack_frame_list.rs                                                      |   20 
crates/debugger_ui/src/tests/variable_list.rs                                                         |  557 
crates/deepseek/src/deepseek.rs                                                                       |   29 
crates/diagnostics/Cargo.toml                                                                         |    5 
crates/diagnostics/src/diagnostic_renderer.rs                                                         |    5 
crates/diagnostics/src/diagnostics.rs                                                                 |    9 
crates/diagnostics/src/diagnostics_tests.rs                                                           |   54 
crates/diagnostics/src/items.rs                                                                       |   10 
crates/docs_preprocessor/Cargo.toml                                                                   |    6 
crates/docs_preprocessor/src/docs_preprocessor.rs                                                     |   94 
crates/docs_preprocessor/src/main.rs                                                                  |  229 
crates/docs_preprocessor/src/templates.rs                                                             |   25 
crates/docs_preprocessor/src/templates/action.rs                                                      |   50 
crates/docs_preprocessor/src/templates/keybinding.rs                                                  |   41 
crates/editor/Cargo.toml                                                                              |    7 
crates/editor/src/actions.rs                                                                          |  146 
crates/editor/src/clangd_ext.rs                                                                       |    4 
crates/editor/src/code_completion_tests.rs                                                            | 2227 
crates/editor/src/code_context_menus.rs                                                               |  674 
crates/editor/src/display_map.rs                                                                      |   89 
crates/editor/src/display_map/block_map.rs                                                            |    7 
crates/editor/src/display_map/custom_highlights.rs                                                    |   17 
crates/editor/src/display_map/fold_map.rs                                                             |   10 
crates/editor/src/display_map/inlay_map.rs                                                            |  132 
crates/editor/src/display_map/wrap_map.rs                                                             |    8 
crates/editor/src/editor.rs                                                                           |  483 
crates/editor/src/editor_settings.rs                                                                  |  134 
crates/editor/src/editor_tests.rs                                                                     |  645 
crates/editor/src/element.rs                                                                          |  741 
crates/editor/src/git/blame.rs                                                                        |    4 
crates/editor/src/highlight_matching_bracket.rs                                                       |    7 
crates/editor/src/hover_links.rs                                                                      |   20 
crates/editor/src/hover_popover.rs                                                                    |   34 
crates/editor/src/indent_guides.rs                                                                    |   50 
crates/editor/src/inlay_hint_cache.rs                                                                 |   95 
crates/editor/src/inline_completion_tests.rs                                                          |    4 
crates/editor/src/items.rs                                                                            |  113 
crates/editor/src/jsx_tag_auto_close.rs                                                               |   34 
crates/editor/src/lsp_colors.rs                                                                       |  375 
crates/editor/src/lsp_ext.rs                                                                          |  100 
crates/editor/src/mouse_context_menu.rs                                                               |   21 
crates/editor/src/movement.rs                                                                         |  173 
crates/editor/src/proposed_changes_editor.rs                                                          |   22 
crates/editor/src/rust_analyzer_ext.rs                                                                |   13 
crates/editor/src/scroll.rs                                                                           |   89 
crates/editor/src/scroll/autoscroll.rs                                                                |   16 
crates/editor/src/scroll/scroll_amount.rs                                                             |   21 
crates/editor/src/selections_collection.rs                                                            |  100 
crates/editor/src/signature_help.rs                                                                   |   15 
crates/editor/src/test.rs                                                                             |   12 
crates/editor/src/test/editor_lsp_test_context.rs                                                     |    8 
crates/editor/src/test/editor_test_context.rs                                                         |   21 
crates/eval/Cargo.toml                                                                                |    8 
crates/eval/src/assertions.rs                                                                         |   15 
crates/eval/src/eval.rs                                                                               |  167 
crates/eval/src/example.rs                                                                            |   40 
crates/eval/src/examples/add_arg_to_trait_method.rs                                                   |    4 
crates/eval/src/examples/code_block_citations.rs                                                      |    4 
crates/eval/src/examples/comment_translation.rs                                                       |    8 
crates/eval/src/examples/file_change_notification.rs                                                  |   74 
crates/eval/src/examples/file_search.rs                                                               |    4 
crates/eval/src/examples/grep_params_escapement.rs                                                    |   59 
crates/eval/src/examples/mod.rs                                                                       |   26 
crates/eval/src/examples/no_tools_enabled.toml                                                        |   19 
crates/eval/src/examples/overwrite_file.rs                                                            |   54 
crates/eval/src/examples/planets.rs                                                                   |    4 
crates/eval/src/examples/threads/overwrite-file.json                                                  |   27 
crates/eval/src/explorer.rs                                                                           |  157 
crates/eval/src/ids.rs                                                                                |    4 
crates/eval/src/instance.rs                                                                           |   78 
crates/extension/Cargo.toml                                                                           |    3 
crates/extension/src/extension.rs                                                                     |   34 
crates/extension/src/extension_builder.rs                                                             |  123 
crates/extension/src/extension_events.rs                                                              |    1 
crates/extension/src/extension_host_proxy.rs                                                          |   58 
crates/extension/src/extension_manifest.rs                                                            |   22 
crates/extension/src/types.rs                                                                         |   21 
crates/extension/src/types/dap.rs                                                                     |    8 
crates/extension_api/Cargo.toml                                                                       |    2 
crates/extension_api/README.md                                                                        |    5 
crates/extension_api/src/extension_api.rs                                                             |  115 
crates/extension_api/wit/since_v0.6.0/common.wit                                                      |   12 
crates/extension_api/wit/since_v0.6.0/context-server.wit                                              |   11 
crates/extension_api/wit/since_v0.6.0/dap.wit                                                         |  123 
crates/extension_api/wit/since_v0.6.0/extension.wit                                                   |  167 
crates/extension_api/wit/since_v0.6.0/github.wit                                                      |   35 
crates/extension_api/wit/since_v0.6.0/http-client.wit                                                 |   67 
crates/extension_api/wit/since_v0.6.0/lsp.wit                                                         |   90 
crates/extension_api/wit/since_v0.6.0/nodejs.wit                                                      |   13 
crates/extension_api/wit/since_v0.6.0/platform.wit                                                    |   24 
crates/extension_api/wit/since_v0.6.0/process.wit                                                     |   29 
crates/extension_api/wit/since_v0.6.0/settings.rs                                                     |   40 
crates/extension_api/wit/since_v0.6.0/slash-command.wit                                               |   41 
crates/extension_cli/src/main.rs                                                                      |   61 
crates/extension_host/Cargo.toml                                                                      |   10 
crates/extension_host/benches/extension_compilation_benchmark.rs                                      |  147 
crates/extension_host/src/extension_host.rs                                                           |   73 
crates/extension_host/src/extension_store_test.rs                                                     |   41 
crates/extension_host/src/headless_host.rs                                                            |    4 
crates/extension_host/src/wasm_host.rs                                                                |  244 
crates/extension_host/src/wasm_host/wit.rs                                                            |  276 
crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs                                               |    5 
crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs                                               |    5 
crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs                                               |    5 
crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs                                               |   47 
crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs                                               |    5 
crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs                                               |    5 
crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs                                               |    5 
crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs                                               |  726 
crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs                                               | 1082 
crates/extensions_ui/Cargo.toml                                                                       |    3 
crates/extensions_ui/src/components/extension_card.rs                                                 |    2 
crates/extensions_ui/src/extension_suggest.rs                                                         |    1 
crates/extensions_ui/src/extension_version_selector.rs                                                |    1 
crates/extensions_ui/src/extensions_ui.rs                                                             |   69 
crates/feature_flags/src/feature_flags.rs                                                             |   11 
crates/feedback/src/system_specs.rs                                                                   |   35 
crates/file_finder/Cargo.toml                                                                         |    4 
crates/file_finder/src/file_finder.rs                                                                 |  411 
crates/file_finder/src/file_finder_settings.rs                                                        |   15 
crates/file_finder/src/file_finder_tests.rs                                                           |  415 
crates/file_finder/src/new_path_prompt.rs                                                             |  525 
crates/file_finder/src/open_path_prompt.rs                                                            |  756 
crates/file_finder/src/open_path_prompt_tests.rs                                                      |   57 
crates/fs/src/fake_git_repo.rs                                                                        |   68 
crates/fs/src/fs.rs                                                                                   |  139 
crates/fs/src/fs_watcher.rs                                                                           |    6 
crates/fs/src/mac_watcher.rs                                                                          |    2 
crates/fuzzy/src/matcher.rs                                                                           |  122 
crates/fuzzy/src/paths.rs                                                                             |    4 
crates/fuzzy/src/strings.rs                                                                           |   10 
crates/git/src/blame.rs                                                                               |   19 
crates/git/src/checkpoint.gitignore                                                                   |   91 
crates/git/src/commit.rs                                                                              |    0 
crates/git/src/git.rs                                                                                 |   22 
crates/git/src/hosting_provider.rs                                                                    |   30 
crates/git/src/repository.rs                                                                          |  531 
crates/git/src/status.rs                                                                              |    6 
crates/git_hosting_providers/src/git_hosting_providers.rs                                             |    5 
crates/git_hosting_providers/src/providers/chromium.rs                                                |    2 
crates/git_hosting_providers/src/providers/codeberg.rs                                                |    2 
crates/git_hosting_providers/src/providers/github.rs                                                  |    2 
crates/git_hosting_providers/src/settings.rs                                                          |   40 
crates/git_ui/Cargo.toml                                                                              |   10 
crates/git_ui/src/branch_picker.rs                                                                    |   82 
crates/git_ui/src/commit_modal.rs                                                                     |    1 
crates/git_ui/src/commit_view.rs                                                                      |    8 
crates/git_ui/src/conflict_view.rs                                                                    |  188 
crates/git_ui/src/diff_view.rs                                                                        |  581 
crates/git_ui/src/git_panel.rs                                                                        |  700 
crates/git_ui/src/git_panel_settings.rs                                                               |    6 
crates/git_ui/src/git_ui.rs                                                                           |   58 
crates/git_ui/src/picker_prompt.rs                                                                    |    5 
crates/git_ui/src/project_diff.rs                                                                     |  159 
crates/git_ui/src/remote_output.rs                                                                    |   12 
crates/git_ui/src/repository_selector.rs                                                              |   13 
crates/go_to_line/src/cursor_position.rs                                                              |   31 
crates/go_to_line/src/go_to_line.rs                                                                   |   13 
crates/google_ai/src/google_ai.rs                                                                     |  223 
crates/gpui/Cargo.toml                                                                                |   14 
crates/gpui/examples/data_table.rs                                                                    |   25 
crates/gpui/examples/image_loading.rs                                                                 |    5 
crates/gpui/examples/input.rs                                                                         |   14 
crates/gpui/examples/opacity.rs                                                                       |    2 
crates/gpui/examples/painting.rs                                                                      |  104 
crates/gpui/examples/scrollable.rs                                                                    |   60 
crates/gpui/examples/shadow.rs                                                                        |   74 
crates/gpui/examples/text.rs                                                                          |  333 
crates/gpui/examples/text_wrapper.rs                                                                  |    4 
crates/gpui/examples/uniform_list.rs                                                                  |    5 
crates/gpui/examples/window.rs                                                                        |   46 
crates/gpui/examples/window_shadow.rs                                                                 |   16 
crates/gpui/src/action.rs                                                                             |  467 
crates/gpui/src/app.rs                                                                                |  122 
crates/gpui/src/app/async_context.rs                                                                  |   72 
crates/gpui/src/app/context.rs                                                                        |   12 
crates/gpui/src/app/entity_map.rs                                                                     |   19 
crates/gpui/src/app/test_context.rs                                                                   |   17 
crates/gpui/src/arena.rs                                                                              |  169 
crates/gpui/src/bounds_tree.rs                                                                        |   61 
crates/gpui/src/color.rs                                                                              |   77 
crates/gpui/src/colors.rs                                                                             |  122 
crates/gpui/src/element.rs                                                                            |  111 
crates/gpui/src/elements/anchored.rs                                                                  |   13 
crates/gpui/src/elements/animation.rs                                                                 |   11 
crates/gpui/src/elements/canvas.rs                                                                    |   11 
crates/gpui/src/elements/common.rs                                                                    |  115 
crates/gpui/src/elements/deferred.rs                                                                  |   10 
crates/gpui/src/elements/div.rs                                                                       |  251 
crates/gpui/src/elements/image_cache.rs                                                               |   12 
crates/gpui/src/elements/img.rs                                                                       |   25 
crates/gpui/src/elements/list.rs                                                                      |   17 
crates/gpui/src/elements/mod.rs                                                                       |    2 
crates/gpui/src/elements/surface.rs                                                                   |   11 
crates/gpui/src/elements/svg.rs                                                                       |   31 
crates/gpui/src/elements/text.rs                                                                      |   72 
crates/gpui/src/elements/uniform_list.rs                                                              |   46 
crates/gpui/src/geometry.rs                                                                           |  451 
crates/gpui/src/gpui.rs                                                                               |    6 
crates/gpui/src/inspector.rs                                                                          |  254 
crates/gpui/src/interactive.rs                                                                        |    8 
crates/gpui/src/key_dispatch.rs                                                                       |   65 
crates/gpui/src/keymap.rs                                                                             |  228 
crates/gpui/src/keymap/binding.rs                                                                     |   25 
crates/gpui/src/keymap/context.rs                                                                     |   30 
crates/gpui/src/path_builder.rs                                                                       |  110 
crates/gpui/src/platform.rs                                                                           |   83 
crates/gpui/src/platform/blade/apple_compat.rs                                                        |    4 
crates/gpui/src/platform/blade/blade_context.rs                                                       |    5 
crates/gpui/src/platform/blade/blade_renderer.rs                                                      |    7 
crates/gpui/src/platform/keystroke.rs                                                                 |  157 
crates/gpui/src/platform/linux/headless/client.rs                                                     |    6 
crates/gpui/src/platform/linux/keyboard.rs                                                            |   13 
crates/gpui/src/platform/linux/platform.rs                                                            |   86 
crates/gpui/src/platform/linux/text_system.rs                                                         |  211 
crates/gpui/src/platform/linux/wayland/client.rs                                                      |  124 
crates/gpui/src/platform/linux/wayland/clipboard.rs                                                   |    4 
crates/gpui/src/platform/linux/wayland/cursor.rs                                                      |  175 
crates/gpui/src/platform/linux/wayland/display.rs                                                     |   11 
crates/gpui/src/platform/linux/wayland/window.rs                                                      |   76 
crates/gpui/src/platform/linux/x11/client.rs                                                          |  594 
crates/gpui/src/platform/linux/x11/clipboard.rs                                                       |  126 
crates/gpui/src/platform/linux/x11/display.rs                                                         |   15 
crates/gpui/src/platform/linux/x11/window.rs                                                          |  234 
crates/gpui/src/platform/mac/display_link.rs                                                          |   16 
crates/gpui/src/platform/mac/events.rs                                                                |   12 
crates/gpui/src/platform/mac/metal_atlas.rs                                                           |    4 
crates/gpui/src/platform/mac/metal_renderer.rs                                                        |   22 
crates/gpui/src/platform/mac/platform.rs                                                              |   97 
crates/gpui/src/platform/mac/text_system.rs                                                           |    6 
crates/gpui/src/platform/mac/window.rs                                                                |  275 
crates/gpui/src/platform/test/platform.rs                                                             |   10 
crates/gpui/src/platform/test/window.rs                                                               |   16 
crates/gpui/src/platform/windows/destination_list.rs                                                  |   18 
crates/gpui/src/platform/windows/direct_write.rs                                                      |  222 
crates/gpui/src/platform/windows/display.rs                                                           |    2 
crates/gpui/src/platform/windows/events.rs                                                            |  763 
crates/gpui/src/platform/windows/keyboard.rs                                                          |  101 
crates/gpui/src/platform/windows/platform.rs                                                          |   84 
crates/gpui/src/platform/windows/util.rs                                                              |   13 
crates/gpui/src/platform/windows/window.rs                                                            |  114 
crates/gpui/src/scene.rs                                                                              |   11 
crates/gpui/src/style.rs                                                                              |  339 
crates/gpui/src/styled.rs                                                                             |   18 
crates/gpui/src/subscription.rs                                                                       |   22 
crates/gpui/src/svg_renderer.rs                                                                       |    5 
crates/gpui/src/taffy.rs                                                                              |   32 
crates/gpui/src/text_system.rs                                                                        |   38 
crates/gpui/src/text_system/line_layout.rs                                                            |    8 
crates/gpui/src/text_system/line_wrapper.rs                                                           |   31 
crates/gpui/src/util.rs                                                                               |   50 
crates/gpui/src/view.rs                                                                               |  154 
crates/gpui/src/window.rs                                                                             |  749 
crates/gpui/src/window/prompts.rs                                                                     |   14 
crates/gpui/tests/action_macros.rs                                                                    |   24 
crates/gpui_macros/Cargo.toml                                                                         |    6 
crates/gpui_macros/src/derive_action.rs                                                               |  176 
crates/gpui_macros/src/derive_inspector_reflection.rs                                                 |  307 
crates/gpui_macros/src/derive_into_element.rs                                                         |    1 
crates/gpui_macros/src/derive_path_static_str.rs                                                      |   73 
crates/gpui_macros/src/gpui_macros.rs                                                                 |   49 
crates/gpui_macros/src/register_action.rs                                                             |   15 
crates/gpui_macros/src/styles.rs                                                                      |   22 
crates/gpui_macros/src/test.rs                                                                        |  202 
crates/gpui_macros/tests/derive_inspector_reflection.rs                                               |  148 
crates/http_client/src/github.rs                                                                      |   18 
crates/http_client/src/http_client.rs                                                                 |   22 
crates/icons/src/icons.rs                                                                             |   20 
crates/image_viewer/Cargo.toml                                                                        |    1 
crates/image_viewer/src/image_viewer.rs                                                               |   21 
crates/image_viewer/src/image_viewer_settings.rs                                                      |    5 
crates/indexed_docs/src/providers/rustdoc.rs                                                          |    2 
crates/indexed_docs/src/store.rs                                                                      |    7 
crates/inline_completion/Cargo.toml                                                                   |    3 
crates/inline_completion/src/inline_completion.rs                                                     |   33 
crates/inline_completion_button/Cargo.toml                                                            |    2 
crates/inline_completion_button/src/inline_completion_button.rs                                       |  204 
crates/inspector_ui/Cargo.toml                                                                        |   29 
crates/inspector_ui/LICENSE-GPL                                                                       |    1 
crates/inspector_ui/README.md                                                                         |  112 
crates/inspector_ui/build.rs                                                                          |   20 
crates/inspector_ui/src/div_inspector.rs                                                              |  723 
crates/inspector_ui/src/inspector.rs                                                                  |  174 
crates/inspector_ui/src/inspector_ui.rs                                                               |   24 
crates/install_cli/src/install_cli.rs                                                                 |    9 
crates/jj/Cargo.toml                                                                                  |   18 
crates/jj/LICENSE-GPL                                                                                 |    1 
crates/jj/src/jj.rs                                                                                   |    5 
crates/jj/src/jj_repository.rs                                                                        |   72 
crates/jj/src/jj_store.rs                                                                             |   41 
crates/jj_ui/Cargo.toml                                                                               |   25 
crates/jj_ui/LICENSE-GPL                                                                              |    1 
crates/jj_ui/src/bookmark_picker.rs                                                                   |  198 
crates/jj_ui/src/jj_ui.rs                                                                             |   39 
crates/journal/src/journal.rs                                                                         |   11 
crates/language/Cargo.toml                                                                            |    7 
crates/language/src/buffer.rs                                                                         |  294 
crates/language/src/buffer_tests.rs                                                                   |   83 
crates/language/src/language.rs                                                                       |  243 
crates/language/src/language_registry.rs                                                              |  243 
crates/language/src/language_settings.rs                                                              |   83 
crates/language/src/manifest.rs                                                                       |   10 
crates/language/src/outline.rs                                                                        |    1 
crates/language/src/proto.rs                                                                          |   63 
crates/language/src/syntax_map.rs                                                                     |  119 
crates/language/src/syntax_map/syntax_map_tests.rs                                                    |   91 
crates/language/src/task_context.rs                                                                   |   24 
crates/language/src/text_diff.rs                                                                      |   15 
crates/language/src/toolchain.rs                                                                      |    7 
crates/language_extension/src/extension_lsp_adapter.rs                                                |   15 
crates/language_model/Cargo.toml                                                                      |    4 
crates/language_model/src/fake_provider.rs                                                            |   19 
crates/language_model/src/language_model.rs                                                           |  203 
crates/language_model/src/model/cloud_model.rs                                                        |  123 
crates/language_model/src/rate_limiter.rs                                                             |   17 
crates/language_model/src/registry.rs                                                                 |   87 
crates/language_model/src/request.rs                                                                  |  378 
crates/language_model/src/telemetry.rs                                                                |   51 
crates/language_model_selector/Cargo.toml                                                             |   36 
crates/language_models/Cargo.toml                                                                     |   12 
crates/language_models/src/language_models.rs                                                         |   15 
crates/language_models/src/provider.rs                                                                |    2 
crates/language_models/src/provider/anthropic.rs                                                      |  226 
crates/language_models/src/provider/bedrock.rs                                                        |  230 
crates/language_models/src/provider/cloud.rs                                                          |  405 
crates/language_models/src/provider/copilot_chat.rs                                                   |  396 
crates/language_models/src/provider/deepseek.rs                                                       |  290 
crates/language_models/src/provider/google.rs                                                         |  178 
crates/language_models/src/provider/lmstudio.rs                                                       |  425 
crates/language_models/src/provider/mistral.rs                                                        |  460 
crates/language_models/src/provider/ollama.rs                                                         |  135 
crates/language_models/src/provider/open_ai.rs                                                        |  432 
crates/language_models/src/provider/open_router.rs                                                    |  924 
crates/language_models/src/provider/vercel.rs                                                         |  577 
crates/language_models/src/settings.rs                                                                |  231 
crates/language_models/src/ui/instruction_list_item.rs                                                |   43 
crates/language_selector/src/language_selector.rs                                                     |   11 
crates/language_tools/Cargo.toml                                                                      |    7 
crates/language_tools/src/key_context_view.rs                                                         |    2 
crates/language_tools/src/language_tools.rs                                                           |   39 
crates/language_tools/src/lsp_log.rs                                                                  |  330 
crates/language_tools/src/lsp_log_tests.rs                                                            |    4 
crates/language_tools/src/lsp_tool.rs                                                                 |  945 
crates/language_tools/src/syntax_tree_view.rs                                                         |   13 
crates/languages/Cargo.toml                                                                           |    4 
crates/languages/src/bash.rs                                                                          |    8 
crates/languages/src/bash/config.toml                                                                 |    4 
crates/languages/src/c.rs                                                                             |  155 
crates/languages/src/c/config.toml                                                                    |    1 
crates/languages/src/cpp/config.toml                                                                  |    1 
crates/languages/src/css.rs                                                                           |   27 
crates/languages/src/css/highlights.scm                                                               |   45 
crates/languages/src/go.rs                                                                            |  102 
crates/languages/src/go/config.toml                                                                   |    1 
crates/languages/src/go/debugger.scm                                                                  |   26 
crates/languages/src/javascript/brackets.scm                                                          |    2 
crates/languages/src/javascript/config.toml                                                           |    2 
crates/languages/src/javascript/highlights.scm                                                        |   13 
crates/languages/src/javascript/outline.scm                                                           |   25 
crates/languages/src/javascript/runnables.scm                                                         |   27 
crates/languages/src/jsdoc/highlights.scm                                                             |    4 
crates/languages/src/json.rs                                                                          |  300 
crates/languages/src/json/config.toml                                                                 |    2 
crates/languages/src/lib.rs                                                                           |   19 
crates/languages/src/package_json.rs                                                                  |  106 
crates/languages/src/python.rs                                                                        |  216 
crates/languages/src/python/config.toml                                                               |   11 
crates/languages/src/python/debugger.scm                                                              |   43 
crates/languages/src/python/highlights.scm                                                            |   27 
crates/languages/src/python/indents.scm                                                               |   85 
crates/languages/src/rust.rs                                                                          |  127 
crates/languages/src/rust/config.toml                                                                 |    1 
crates/languages/src/rust/debugger.scm                                                                |   50 
crates/languages/src/rust/injections.scm                                                              |   18 
crates/languages/src/tailwind.rs                                                                      |   25 
crates/languages/src/tsx/config.toml                                                                  |    4 
crates/languages/src/tsx/highlights.scm                                                               |   11 
crates/languages/src/tsx/outline.scm                                                                  |   25 
crates/languages/src/tsx/overrides.scm                                                                |    4 
crates/languages/src/tsx/runnables.scm                                                                |   27 
crates/languages/src/typescript.rs                                                                    |  741 
crates/languages/src/typescript/config.toml                                                           |    8 
crates/languages/src/typescript/highlights.scm                                                        |   13 
crates/languages/src/typescript/outline.scm                                                           |   25 
crates/languages/src/typescript/overrides.scm                                                         |    4 
crates/languages/src/typescript/runnables.scm                                                         |   27 
crates/languages/src/vtsls.rs                                                                         |   31 
crates/languages/src/yaml.rs                                                                          |   25 
crates/livekit_api/src/livekit_api.rs                                                                 |    6 
crates/livekit_api/src/token.rs                                                                       |    6 
crates/livekit_client/Cargo.toml                                                                      |    8 
crates/livekit_client/src/lib.rs                                                                      |   10 
crates/livekit_client/src/livekit_client.rs                                                           |    6 
crates/livekit_client/src/livekit_client/playback.rs                                                  |   15 
crates/livekit_client/src/test.rs                                                                     |   64 
crates/lmstudio/src/lmstudio.rs                                                                       |  274 
crates/lsp/Cargo.toml                                                                                 |    2 
crates/lsp/src/input_handler.rs                                                                       |    6 
crates/lsp/src/lsp.rs                                                                                 |   40 
crates/markdown/Cargo.toml                                                                            |    2 
crates/markdown/examples/markdown.rs                                                                  |   16 
crates/markdown/examples/markdown_as_child.rs                                                         |    6 
crates/markdown/src/markdown.rs                                                                       |   85 
crates/markdown/src/parser.rs                                                                         |   48 
crates/markdown_preview/Cargo.toml                                                                    |    1 
crates/markdown_preview/src/markdown_parser.rs                                                        |    8 
crates/markdown_preview/src/markdown_preview.rs                                                       |    5 
crates/markdown_preview/src/markdown_preview_view.rs                                                  |  116 
crates/markdown_preview/src/markdown_renderer.rs                                                      |    3 
crates/media/src/media.rs                                                                             |   42 
crates/migrator/src/migrations.rs                                                                     |   18 
crates/migrator/src/migrations/m_2025_05_29/settings.rs                                               |   51 
crates/migrator/src/migrations/m_2025_06_16/settings.rs                                               |   90 
crates/migrator/src/migrations/m_2025_06_25/settings.rs                                               |  133 
crates/migrator/src/migrator.rs                                                                       |  348 
crates/mistral/src/mistral.rs                                                                         |  139 
crates/multi_buffer/Cargo.toml                                                                        |    2 
crates/multi_buffer/src/multi_buffer.rs                                                               |  296 
crates/multi_buffer/src/multi_buffer_tests.rs                                                         |   88 
crates/multi_buffer/src/position.rs                                                                   |    6 
crates/node_runtime/Cargo.toml                                                                        |   10 
crates/node_runtime/src/archive.rs                                                                    |  118 
crates/node_runtime/src/node_runtime.rs                                                               |  309 
crates/notifications/Cargo.toml                                                                       |    2 
crates/notifications/src/status_toast.rs                                                              |   43 
crates/ollama/src/ollama.rs                                                                           |  193 
crates/open_ai/src/open_ai.rs                                                                         |  371 
crates/open_router/Cargo.toml                                                                         |   25 
crates/open_router/LICENSE-GPL                                                                        |    1 
crates/open_router/src/open_router.rs                                                                 |  645 
crates/outline/src/outline.rs                                                                         |   13 
crates/outline_panel/src/outline_panel.rs                                                             |  145 
crates/paths/src/paths.rs                                                                             |   94 
crates/picker/src/picker.rs                                                                           |   40 
crates/picker/src/popover_menu.rs                                                                     |   93 
crates/prettier/src/prettier.rs                                                                       |   13 
crates/prettier/src/prettier_server.js                                                                |   69 
crates/project/Cargo.toml                                                                             |    2 
crates/project/src/buffer_store.rs                                                                    |   66 
crates/project/src/context_server_store.rs                                                            |  805 
crates/project/src/debugger/README.md                                                                 |  354 
crates/project/src/debugger/breakpoint_store.rs                                                       |  400 
crates/project/src/debugger/dap_command.rs                                                            |    6 
crates/project/src/debugger/dap_store.rs                                                              |  217 
crates/project/src/debugger/locators.rs                                                               |    3 
crates/project/src/debugger/locators/cargo.rs                                                         |   62 
crates/project/src/debugger/locators/go.rs                                                            |  428 
crates/project/src/debugger/locators/node.rs                                                          |   62 
crates/project/src/debugger/locators/python.rs                                                        |  106 
crates/project/src/debugger/session.rs                                                                |  555 
crates/project/src/direnv.rs                                                                          |   30 
crates/project/src/environment.rs                                                                     |  117 
crates/project/src/git_store.rs                                                                       |  133 
crates/project/src/git_store/conflict_set.rs                                                          |  136 
crates/project/src/git_store/git_traversal.rs                                                         |    7 
crates/project/src/image_store.rs                                                                     |   56 
crates/project/src/lsp_command.rs                                                                     | 1131 
crates/project/src/lsp_store.rs                                                                       |  610 
crates/project/src/lsp_store/clangd_ext.rs                                                            |   22 
crates/project/src/lsp_store/lsp_ext_command.rs                                                       |   38 
crates/project/src/lsp_store/rust_analyzer_ext.rs                                                     |  102 
crates/project/src/manifest_tree.rs                                                                   |   40 
crates/project/src/manifest_tree/path_trie.rs                                                         |    2 
crates/project/src/manifest_tree/server_tree.rs                                                       |   50 
crates/project/src/prettier_store.rs                                                                  |    9 
crates/project/src/project.rs                                                                         |  442 
crates/project/src/project_settings.rs                                                                |  206 
crates/project/src/project_tests.rs                                                                   |  555 
crates/project/src/search.rs                                                                          |    6 
crates/project/src/search_history.rs                                                                  |   14 
crates/project/src/task_inventory.rs                                                                  |  792 
crates/project/src/task_store.rs                                                                      |   64 
crates/project/src/terminals.rs                                                                       |   18 
crates/project/src/toolchain_store.rs                                                                 |  125 
crates/project/src/worktree_store.rs                                                                  |   26 
crates/project/src/yarn.rs                                                                            |    6 
crates/project_panel/src/project_panel.rs                                                             |  622 
crates/project_panel/src/project_panel_settings.rs                                                    |    5 
crates/project_panel/src/project_panel_tests.rs                                                       | 1050 
crates/project_symbols/src/project_symbols.rs                                                         |   22 
crates/prompt_store/src/prompt_store.rs                                                               |    8 
crates/prompt_store/src/prompts.rs                                                                    |    3 
crates/proto/build.rs                                                                                 |    1 
crates/proto/proto/app.proto                                                                          |    2 
crates/proto/proto/buffer.proto                                                                       |    9 
crates/proto/proto/call.proto                                                                         |    2 
crates/proto/proto/channel.proto                                                                      |   10 
crates/proto/proto/core.proto                                                                         |    4 
crates/proto/proto/debugger.proto                                                                     |   87 
crates/proto/proto/git.proto                                                                          |    1 
crates/proto/proto/lsp.proto                                                                          |  152 
crates/proto/proto/toolchain.proto                                                                    |    1 
crates/proto/proto/zed.proto                                                                          |   13 
crates/proto/src/error.rs                                                                             |    6 
crates/proto/src/proto.rs                                                                             |   19 
crates/proto/src/typed_envelope.rs                                                                    |    4 
crates/recent_projects/src/recent_projects.rs                                                         |   75 
crates/recent_projects/src/remote_servers.rs                                                          |  527 
crates/recent_projects/src/ssh_config.rs                                                              |   96 
crates/recent_projects/src/ssh_connections.rs                                                         |   19 
crates/refineable/derive_refineable/src/derive_refineable.rs                                          |  209 
crates/refineable/src/refineable.rs                                                                   |  124 
crates/release_channel/src/lib.rs                                                                     |   19 
crates/remote/Cargo.toml                                                                              |    1 
crates/remote/src/ssh_session.rs                                                                      |  309 
crates/remote_server/Cargo.toml                                                                       |    6 
crates/remote_server/src/headless_project.rs                                                          |   35 
crates/remote_server/src/main.rs                                                                      |    8 
crates/remote_server/src/remote_editing_tests.rs                                                      |  171 
crates/remote_server/src/unix.rs                                                                      |   28 
crates/repl/src/kernels/mod.rs                                                                        |    2 
crates/repl/src/kernels/native_kernel.rs                                                              |    9 
crates/repl/src/kernels/remote_kernels.rs                                                             |   71 
crates/repl/src/notebook/cell.rs                                                                      |    5 
crates/repl/src/notebook/notebook_ui.rs                                                               |    8 
crates/repl/src/outputs.rs                                                                            |    2 
crates/repl/src/outputs/image.rs                                                                      |    4 
crates/repl/src/outputs/plain.rs                                                                      |    2 
crates/repl/src/outputs/table.rs                                                                      |    8 
crates/repl/src/repl_editor.rs                                                                        |   93 
crates/repl/src/repl_store.rs                                                                         |   14 
crates/repl/src/session.rs                                                                            |   15 
crates/reqwest_client/src/reqwest_client.rs                                                           |    2 
crates/rope/Cargo.toml                                                                                |    4 
crates/rope/src/chunk.rs                                                                              |    4 
crates/rope/src/rope.rs                                                                               |   16 
crates/rpc/Cargo.toml                                                                                 |    2 
crates/rpc/src/conn.rs                                                                                |   14 
crates/rpc/src/extension.rs                                                                           |    1 
crates/rpc/src/message_stream.rs                                                                      |   12 
crates/rpc/src/peer.rs                                                                                |   29 
crates/rpc/src/proto_client.rs                                                                        |    4 
crates/rules_library/Cargo.toml                                                                       |    1 
crates/rules_library/src/rules_library.rs                                                             |  592 
crates/search/src/buffer_search.rs                                                                    |   71 
crates/search/src/project_search.rs                                                                   |  316 
crates/search/src/search_status_button.rs                                                             |    9 
crates/semantic_index/Cargo.toml                                                                      |    2 
crates/semantic_index/examples/index.rs                                                               |    2 
crates/semantic_index/src/embedding_index.rs                                                          |    4 
crates/semantic_index/src/project_index.rs                                                            |    9 
crates/semantic_index/src/project_index_debug_view.rs                                                 |    7 
crates/semantic_index/src/semantic_index.rs                                                           |   25 
crates/semantic_index/src/summary_index.rs                                                            |    3 
crates/semantic_version/src/semantic_version.rs                                                       |    8 
crates/settings/Cargo.toml                                                                            |    2 
crates/settings/src/json_schema.rs                                                                    |   75 
crates/settings/src/keymap_file.rs                                                                    |  594 
crates/settings/src/settings.rs                                                                       |   15 
crates/settings/src/settings_file.rs                                                                  |    7 
crates/settings/src/settings_json.rs                                                                  | 1646 
crates/settings/src/settings_store.rs                                                                 |  486 
crates/settings/src/vscode_import.rs                                                                  |   67 
crates/settings_ui/Cargo.toml                                                                         |    8 
crates/settings_ui/src/settings_ui.rs                                                                 |  151 
crates/snippet/src/snippet.rs                                                                         |   17 
crates/snippet_provider/src/lib.rs                                                                    |   19 
crates/snippets_ui/Cargo.toml                                                                         |    5 
crates/snippets_ui/src/snippets_ui.rs                                                                 |  140 
crates/sqlez/src/connection.rs                                                                        |    8 
crates/sqlez/src/migrations.rs                                                                        |   10 
crates/sqlez/src/savepoint.rs                                                                         |    2 
crates/sqlez/src/statement.rs                                                                         |   33 
crates/story/src/story.rs                                                                             |   93 
crates/storybook/src/assets.rs                                                                        |    4 
crates/storybook/src/stories.rs                                                                       |    2 
crates/storybook/src/stories/auto_height_editor.rs                                                    |    2 
crates/storybook/src/stories/cursor.rs                                                                |    8 
crates/storybook/src/stories/default_colors.rs                                                        |   89 
crates/storybook/src/stories/indent_guides.rs                                                         |    6 
crates/storybook/src/stories/kitchen_sink.rs                                                          |    6 
crates/storybook/src/stories/overflow_scroll.rs                                                       |   10 
crates/storybook/src/stories/picker.rs                                                                |    1 
crates/storybook/src/stories/text.rs                                                                  |    6 
crates/storybook/src/stories/viewport_units.rs                                                        |    4 
crates/storybook/src/stories/with_rem_size.rs                                                         |    4 
crates/storybook/src/story_selector.rs                                                                |    5 
crates/storybook/src/storybook.rs                                                                     |    4 
crates/sum_tree/Cargo.toml                                                                            |    2 
crates/sum_tree/src/sum_tree.rs                                                                       |    6 
crates/supermaven_api/Cargo.toml                                                                      |    1 
crates/supermaven_api/src/supermaven_api.rs                                                           |   34 
crates/svg_preview/Cargo.toml                                                                         |   20 
crates/svg_preview/LICENSE-GPL                                                                        |    1 
crates/svg_preview/src/svg_preview.rs                                                                 |   19 
crates/svg_preview/src/svg_preview_view.rs                                                            |  323 
crates/tab_switcher/Cargo.toml                                                                        |    2 
crates/tab_switcher/src/tab_switcher.rs                                                               |   10 
crates/tab_switcher/src/tab_switcher_tests.rs                                                         |    6 
crates/task/Cargo.toml                                                                                |    3 
crates/task/src/adapter_schema.rs                                                                     |   62 
crates/task/src/debug_format.rs                                                                       |  323 
crates/task/src/lib.rs                                                                                |   26 
crates/task/src/task_template.rs                                                                      |    4 
crates/task/src/vscode_debug_format.rs                                                                |  123 
crates/task/src/vscode_format.rs                                                                      |   25 
crates/tasks_ui/src/modal.rs                                                                          |  161 
crates/tasks_ui/src/tasks_ui.rs                                                                       |  106 
1,000 files changed, 81,321 insertions(+), 32,729 deletions(-)

Detailed changes

.cargo/config.toml 🔗

@@ -13,12 +13,6 @@ rustflags = ["-C", "link-arg=-fuse-ld=mold"]
 linker = "clang"
 rustflags = ["-C", "link-arg=-fuse-ld=mold"]
 
-[target.aarch64-apple-darwin]
-rustflags = ["-C", "link-args=-all_load"]
-
-[target.x86_64-apple-darwin]
-rustflags = ["-C", "link-args=-all_load"]
-
 [target.'cfg(target_os = "windows")']
 rustflags = [
     "--cfg",

.git-blame-ignore-revs 🔗

@@ -30,3 +30,7 @@ ffdda588b41f7d9d270ffe76cab116f828ad545e
 # 2024-07-05 Improved formatting of default keymaps (single line per bind)
 # https://github.com/zed-industries/zed/pull/13887
 813cc3f5e537372fc86720b5e71b6e1c815440ab
+
+# 2024-07-24 docs: Format docs
+# https://github.com/zed-industries/zed/pull/15352
+3a44a59f8ec114ac1ba22f7da1652717ef7e4e5c

.github/ISSUE_TEMPLATE/01_bug_agent.yml → .github/ISSUE_TEMPLATE/01_bug_ai.yml 🔗

@@ -1,8 +1,8 @@
-name: Bug Report (Agent Panel)
+name: Bug Report (AI)
 description: Zed Agent Panel Bugs
 type: "Bug"
-labels: ["agent", "ai"]
-title: "Agent Panel: <a short description of the Agent Panel bug>"
+labels: ["ai"]
+title: "AI: <a short description of the AI Related bug>"
 body:
   - type: textarea
     attributes:
@@ -14,14 +14,19 @@ body:
 
         ### Description
         <!--  Describe with sufficient detail to reproduce from a clean Zed install. -->
-        <!--  Please include the LLM provider and model name you are using -->
         Steps to trigger the problem:
         1.
         2.
         3.
 
-        Actual Behavior:
-        Expected Behavior:
+        **Expected Behavior**:
+        **Actual Behavior**:
+
+        ### Model Provider Details
+        - Provider: (Anthropic via ZedPro, Anthropic via API key, Copilot Chat, Mistral, OpenAI, etc)
+        - Model Name:
+        - Mode: (Agent Panel, Inline Assistant, Terminal Assistant or Text Threads)
+        - Other Details (MCPs, other settings, etc):
     validations:
       required: true
 
@@ -29,8 +34,8 @@ body:
     id: environment
     attributes:
       label: Zed Version and System Specs
-      description: 'Open Zed, and in the command palette select "zed: Copy System Specs Into Clipboard"'
+      description: 'Open Zed, and in the command palette select "zed: copy system specs into clipboard"'
       placeholder: |
-        Output of "zed: Copy System Specs Into Clipboard"
+        Output of "zed: copy system specs into clipboard"
     validations:
       required: true

.github/ISSUE_TEMPLATE/02_bug_edit_predictions.yml 🔗

@@ -1,36 +0,0 @@
-name: Bug Report (Edit Predictions)
-description: Zed Edit Predictions bugs
-type: "Bug"
-labels: ["ai", "inline completion", "zeta"]
-title: "Edit Predictions: <a short description of the Edit Prediction bug>"
-body:
-  - type: textarea
-    attributes:
-      label: Summary
-      description: Describe the bug with a one line summary, and provide detailed reproduction steps
-      value: |
-        <!-- Please insert a one line summary of the issue below -->
-        SUMMARY_SENTENCE_HERE
-
-        ### Description
-        <!--  Describe with sufficient detail to reproduce from a clean Zed install. -->
-        <!--  Please include the LLM provider and model name you are using -->
-        Steps to trigger the problem:
-        1.
-        2.
-        3.
-
-        Actual Behavior:
-        Expected Behavior:
-    validations:
-      required: true
-
-  - type: textarea
-    id: environment
-    attributes:
-      label: Zed Version and System Specs
-      description: 'Open Zed, and in the command palette select "zed: Copy System Specs Into Clipboard"'
-      placeholder: |
-        Output of "zed: Copy System Specs Into Clipboard"
-    validations:
-      required: true

.github/ISSUE_TEMPLATE/03_bug_git.yml → .github/ISSUE_TEMPLATE/04_bug_debugger.yml 🔗

@@ -1,8 +1,8 @@
-name: Bug Report (Git)
-description: Zed Git-Related Bugs
+name: Bug Report (Debugger)
+description: Zed Debugger-Related Bugs
 type: "Bug"
-labels: ["git"]
-title: "Git: <a short description of the Git bug>"
+labels: ["debugger"]
+title: "Debugger: <a short description of the Debugger bug>"
 body:
   - type: textarea
     attributes:
@@ -19,8 +19,8 @@ body:
         2.
         3.
 
-        Actual Behavior:
-        Expected Behavior:
+        **Expected Behavior**:
+        **Actual Behavior**:
 
     validations:
       required: true
@@ -28,8 +28,8 @@ body:
     id: environment
     attributes:
       label: Zed Version and System Specs
-      description: 'Open Zed, and in the command palette select "zed: Copy System Specs Into Clipboard"'
+      description: 'Open Zed, and in the command palette select "zed: copy system specs into clipboard"'
       placeholder: |
-        Output of "zed: Copy System Specs Into Clipboard"
+        Output of "zed: copy system specs into clipboard"
     validations:
       required: true

.github/ISSUE_TEMPLATE/10_bug_report.yml 🔗

@@ -18,14 +18,16 @@ body:
           - Issues with insufficient detail may be summarily closed.
         -->
 
+        DESCRIPTION_HERE
+
         Steps to reproduce:
         1.
         2.
         3.
         4.
 
-        Expected Behavior:
-        Actual Behavior:
+        **Expected Behavior**:
+        **Actual Behavior**:
 
         <!-- Before Submitting, did you:
           1. Include settings.json, keymap.json, .editorconfig if relevant?
@@ -49,8 +51,8 @@ body:
     attributes:
       label: Zed Version and System Specs
       description: |
-        Open Zed, from the command palette select "zed: Copy System Specs Into Clipboard"
+        Open Zed, from the command palette select "zed: copy system specs into clipboard"
       placeholder: |
-        Output of "zed: Copy System Specs Into Clipboard"
+        Output of "zed: copy system specs into clipboard"
     validations:
       required: true

.github/ISSUE_TEMPLATE/11_crash_report.yml 🔗

@@ -26,9 +26,9 @@ body:
     id: environment
     attributes:
       label: Zed Version and System Specs
-      description: 'Open Zed, and in the command palette select "zed: Copy System Specs Into Clipboard"'
+      description: 'Open Zed, and in the command palette select "zed: copy system specs into clipboard"'
       placeholder: |
-        Output of "zed: Copy System Specs Into Clipboard"
+        Output of "zed: copy system specs into clipboard"
     validations:
       required: true
   - type: textarea

.github/actions/build_docs/action.yml 🔗

@@ -0,0 +1,32 @@
+name: "Build docs"
+description: "Build the docs"
+
+runs:
+  using: "composite"
+  steps:
+    - name: Setup mdBook
+      uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 # v2
+      with:
+        mdbook-version: "0.4.37"
+
+    - name: Cache dependencies
+      uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
+      with:
+        save-if: ${{ github.ref == 'refs/heads/main' }}
+        cache-provider: "buildjet"
+
+    - name: Install Linux dependencies
+      shell: bash -euxo pipefail {0}
+      run: ./script/linux
+
+    - name: Check for broken links
+      uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1
+      with:
+        args: --no-progress --exclude '^http' './docs/src/**/*'
+        fail: true
+
+    - name: Build book
+      shell: bash -euxo pipefail {0}
+      run: |
+        mkdir -p target/deploy
+        mdbook build ./docs --dest-dir=../target/deploy/docs/

.github/actions/run_tests_windows/action.yml 🔗

@@ -10,8 +10,8 @@ inputs:
 runs:
   using: "composite"
   steps:
-    - name: Install Rust
-      shell: pwsh
+    - name: Install test runner
+      shell: powershell
       working-directory: ${{ inputs.working-directory }}
       run: cargo install cargo-nextest --locked
 
@@ -21,6 +21,6 @@ runs:
         node-version: "18"
 
     - name: Run tests
-      shell: pwsh
+      shell: powershell
       working-directory: ${{ inputs.working-directory }}
-      run: cargo nextest run --workspace --no-fail-fast --config='profile.dev.debug="limited"'
+      run: cargo nextest run --workspace --no-fail-fast

.github/workflows/ci.yml 🔗

@@ -29,6 +29,7 @@ jobs:
     outputs:
       run_tests: ${{ steps.filter.outputs.run_tests }}
       run_license: ${{ steps.filter.outputs.run_license }}
+      run_docs: ${{ steps.filter.outputs.run_docs }}
     runs-on:
       - ubuntu-latest
     steps:
@@ -58,6 +59,11 @@ jobs:
           else
             echo "run_tests=false" >> $GITHUB_OUTPUT
           fi
+          if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep '^docs/') ]]; then
+            echo "run_docs=true" >> $GITHUB_OUTPUT
+          else
+            echo "run_docs=false" >> $GITHUB_OUTPUT
+          fi
           if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep '^Cargo.lock') ]]; then
             echo "run_license=true" >> $GITHUB_OUTPUT
           else
@@ -73,7 +79,7 @@ jobs:
     timeout-minutes: 60
     runs-on:
       - self-hosted
-      - test
+      - macOS
     steps:
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -183,6 +189,9 @@ jobs:
       - name: Check for todo! and FIXME comments
         run: script/check-todos
 
+      - name: Check modifier use in keymaps
+        run: script/check-keymaps
+
       - name: Run style checks
         uses: ./.github/actions/check_style
 
@@ -191,6 +200,29 @@ jobs:
         with:
           config: ./typos.toml
 
+  check_docs:
+    timeout-minutes: 60
+    name: Check docs
+    needs: [job_spec]
+    if: |
+      github.repository_owner == 'zed-industries' &&
+      (needs.job_spec.outputs.run_tests == 'true' || needs.job_spec.outputs.run_docs == 'true')
+    runs-on:
+      - buildjet-8vcpu-ubuntu-2204
+    steps:
+      - name: Checkout repo
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+        with:
+          clean: false
+
+      - name: Configure CI
+        run: |
+          mkdir -p ./../.cargo
+          cp ./.cargo/ci-config.toml ./../.cargo/config.toml
+
+      - name: Build docs
+        uses: ./.github/actions/build_docs
+
   macos_tests:
     timeout-minutes: 60
     name: (macOS) Run Clippy and tests
@@ -200,7 +232,7 @@ jobs:
       needs.job_spec.outputs.run_tests == 'true'
     runs-on:
       - self-hosted
-      - test
+      - macOS
     steps:
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -349,116 +381,52 @@ jobs:
         if: always()
         run: rm -rf ./../.cargo
 
-  windows_clippy:
+  windows_tests:
     timeout-minutes: 60
-    name: (Windows) Run Clippy
+    name: (Windows) Run Tests
     needs: [job_spec]
     if: |
       github.repository_owner == 'zed-industries' &&
       needs.job_spec.outputs.run_tests == 'true'
-    runs-on: windows-2025-16
+    runs-on: [self-hosted, Windows, X64]
     steps:
-      # more info here:- https://github.com/rust-lang/cargo/issues/13020
-      - name: Enable longer pathnames for git
-        run: git config --system core.longpaths true
+      - name: Environment Setup
+        run: |
+          $RunnerDir = Split-Path -Parent $env:RUNNER_WORKSPACE
+          Write-Output `
+            "RUSTUP_HOME=$RunnerDir\.rustup" `
+            "CARGO_HOME=$RunnerDir\.cargo" `
+            "PATH=$RunnerDir\.cargo\bin;$env:PATH" `
+          >> $env:GITHUB_ENV
 
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           clean: false
 
-      - name: Create Dev Drive using ReFS
-        run: ./script/setup-dev-driver.ps1
-
-      # actions/checkout does not let us clone into anywhere outside ${{ github.workspace }}, so we have to copy the clone...
-      - name: Copy Git Repo to Dev Drive
-        run: |
-          Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.ZED_WORKSPACE }}" -Recurse
-
-      - name: Cache dependencies
-        uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
-        with:
-          save-if: ${{ github.ref == 'refs/heads/main' }}
-          workspaces: ${{ env.ZED_WORKSPACE }}
-          cache-provider: "github"
-
-      - name: Configure CI
+      - name: Setup Cargo and Rustup
         run: |
           mkdir -p ${{ env.CARGO_HOME }} -ErrorAction Ignore
           cp ./.cargo/ci-config.toml ${{ env.CARGO_HOME }}/config.toml
+          .\script\install-rustup.ps1
 
       - name: cargo clippy
-        working-directory: ${{ env.ZED_WORKSPACE }}
-        run: ./script/clippy.ps1
-
-      - name: Check dev drive space
-        working-directory: ${{ env.ZED_WORKSPACE }}
-        # `setup-dev-driver.ps1` creates a 100GB drive, with CI taking up ~45GB of the drive.
-        run: ./script/exit-ci-if-dev-drive-is-full.ps1 95
-
-      # Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug.
-      - name: Clean CI config file
-        if: always()
         run: |
-          if (Test-Path "${{ env.CARGO_HOME }}/config.toml") {
-            Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml"  -Force
-          }
-
-  # Windows CI takes twice as long as our other platforms and fast github hosted runners are expensive.
-  # But we still want to do CI, so let's only run tests on main and come back to this when we're
-  # ready to self host our Windows CI (e.g. during the push for full Windows support)
-  windows_tests:
-    timeout-minutes: 60
-    name: (Windows) Run Tests
-    needs: [job_spec]
-    if: |
-      github.repository_owner == 'zed-industries' &&
-      needs.job_spec.outputs.run_tests == 'true'
-    # Use bigger runners for PRs (speed); smaller for async (cost)
-    runs-on: ${{ github.event_name == 'pull_request' && 'windows-2025-32' || 'windows-2025-16' }}
-    steps:
-      # more info here:- https://github.com/rust-lang/cargo/issues/13020
-      - name: Enable longer pathnames for git
-        run: git config --system core.longpaths true
-
-      - name: Checkout repo
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
-        with:
-          clean: false
-
-      - name: Create Dev Drive using ReFS
-        run: ./script/setup-dev-driver.ps1
-
-      # actions/checkout does not let us clone into anywhere outside ${{ github.workspace }}, so we have to copy the clone...
-      - name: Copy Git Repo to Dev Drive
-        run: |
-          Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.ZED_WORKSPACE }}" -Recurse
-
-      - name: Cache dependencies
-        uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
-        with:
-          save-if: ${{ github.ref == 'refs/heads/main' }}
-          workspaces: ${{ env.ZED_WORKSPACE }}
-          cache-provider: "github"
-
-      - name: Configure CI
-        run: |
-          mkdir -p ${{ env.CARGO_HOME }} -ErrorAction Ignore
-          cp ./.cargo/ci-config.toml ${{ env.CARGO_HOME }}/config.toml
+          .\script\clippy.ps1
 
       - name: Run tests
         uses: ./.github/actions/run_tests_windows
-        with:
-          working-directory: ${{ env.ZED_WORKSPACE }}
 
       - name: Build Zed
-        working-directory: ${{ env.ZED_WORKSPACE }}
         run: cargo build
 
-      - name: Check dev drive space
-        working-directory: ${{ env.ZED_WORKSPACE }}
-        # `setup-dev-driver.ps1` creates a 100GB drive, with CI taking up ~45GB of the drive.
-        run: ./script/exit-ci-if-dev-drive-is-full.ps1 95
+      - name: Limit target directory size
+        run: ./script/clear-target-dir-if-larger-than.ps1 250
+
+      # - name: Check dev drive space
+      #   working-directory: ${{ env.ZED_WORKSPACE }}
+      #   # `setup-dev-driver.ps1` creates a 100GB drive, with CI taking up ~45GB of the drive.
+      #   run: ./script/exit-ci-if-dev-drive-is-full.ps1 95
 
       # Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug.
       - name: Clean CI config file
@@ -474,30 +442,34 @@ jobs:
     needs:
       - job_spec
       - style
+      - check_docs
       - migration_checks
       # run_tests: If adding required tests, add them here and to script below.
       - workspace_hack
       - linux_tests
       - build_remote_server
       - macos_tests
-      - windows_clippy
       - windows_tests
-    if: always()
+    if: |
+      github.repository_owner == 'zed-industries' &&
+      always()
     steps:
       - name: Check all tests passed
         run: |
           # Check dependent jobs...
           RET_CODE=0
           # Always check style
-          [[ "${{ needs.style.result }}" != 'success' ]] && { RET_CODE=1; echo "style tests failed"; }
+          [[ "${{ needs.style.result }}"      != 'success' ]] && { RET_CODE=1; echo "style tests failed"; }
 
+          if [[ "${{ needs.job_spec.outputs.run_docs }}" == "true" ]]; then
+            [[ "${{ needs.check_docs.result }}" != 'success' ]] && { RET_CODE=1; echo "docs checks failed"; }
+          fi
           # Only check test jobs if they were supposed to run
           if [[ "${{ needs.job_spec.outputs.run_tests }}" == "true" ]]; then
             [[ "${{ needs.workspace_hack.result }}"       != 'success' ]] && { RET_CODE=1; echo "Workspace Hack failed"; }
             [[ "${{ needs.macos_tests.result }}"          != 'success' ]] && { RET_CODE=1; echo "macOS tests failed"; }
             [[ "${{ needs.linux_tests.result }}"          != 'success' ]] && { RET_CODE=1; echo "Linux tests failed"; }
             [[ "${{ needs.windows_tests.result }}"        != 'success' ]] && { RET_CODE=1; echo "Windows tests failed"; }
-            [[ "${{ needs.windows_clippy.result }}"       != 'success' ]] && { RET_CODE=1; echo "Windows clippy failed"; }
             [[ "${{ needs.build_remote_server.result }}"  != 'success' ]] && { RET_CODE=1; echo "Remote server build failed"; }
             # This check is intentionally disabled. See: https://github.com/zed-industries/zed/pull/28431
             # [[ "${{ needs.migration_checks.result }}"     != 'success' ]] && { RET_CODE=1; echo "Migration Checks failed"; }
@@ -524,7 +496,6 @@ jobs:
       APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
       APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
       ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
-      ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
       DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
       DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
     steps:
@@ -611,7 +582,6 @@ jobs:
     needs: [linux_tests]
     env:
       ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
-      ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
       DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
       DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
     steps:
@@ -669,7 +639,6 @@ jobs:
     needs: [linux_tests]
     env:
       ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
-      ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
       DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
       DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
     steps:
@@ -716,62 +685,85 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
-  nix-build:
+  freebsd:
     timeout-minutes: 60
-    name: Nix Build
-    continue-on-error: true
-    if: github.repository_owner == 'zed-industries' && contains(github.event.pull_request.labels.*.name, 'run-nix')
-    strategy:
-      fail-fast: false
-      matrix:
-        system:
-          - os: x86 Linux
-            runner: buildjet-16vcpu-ubuntu-2204
-            install_nix: true
-          - os: arm Mac
-            runner: [macOS, ARM64, test]
-            install_nix: false
-    runs-on: ${{ matrix.system.runner }}
-    env:
-      ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
-      ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
-      GIT_LFS_SKIP_SMUDGE: 1 # breaks the livekit rust sdk examples which we don't actually depend on
+    runs-on: github-8vcpu-ubuntu-2404
+    if: |
+      startsWith(github.ref, 'refs/tags/v')
+      || contains(github.event.pull_request.labels.*.name, 'run-bundling')
+    needs: [linux_tests]
+    name: Build Zed on FreeBSD
+    # env:
+    #   MYTOKEN : ${{ secrets.MYTOKEN }}
+    #   MYTOKEN2: "value2"
     steps:
-      - name: Checkout repo
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
-        with:
-          clean: false
-      - name: Set path
-        if: ${{ ! matrix.system.install_nix }}
-        run: |
-          echo "/nix/var/nix/profiles/default/bin" >> $GITHUB_PATH
-          echo "/Users/administrator/.nix-profile/bin" >> $GITHUB_PATH
+      - uses: actions/checkout@v4
+      - name: Build FreeBSD remote-server
+        id: freebsd-build
+        uses: vmactions/freebsd-vm@c3ae29a132c8ef1924775414107a97cac042aad5 # v1.2.0
+        with:
+          # envs: "MYTOKEN MYTOKEN2"
+          usesh: true
+          release: 13.5
+          copyback: true
+          prepare: |
+            pkg install -y \
+              bash curl jq git \
+              rustup-init cmake-core llvm-devel-lite pkgconf protobuf # ibx11 alsa-lib rust-bindgen-cli
+          run: |
+            freebsd-version
+            sysctl hw.model
+            sysctl hw.ncpu
+            sysctl hw.physmem
+            sysctl hw.usermem
+            git config --global --add safe.directory /home/runner/work/zed/zed
+            rustup-init --profile minimal --default-toolchain none -y
+            . "$HOME/.cargo/env"
+            ./script/bundle-freebsd
+            mkdir -p out/
+            mv "target/zed-remote-server-freebsd-x86_64.gz" out/
+            rm -rf target/
+            cargo clean
 
-      - uses: cachix/install-nix-action@d1ca217b388ee87b2507a9a93bf01368bde7cec2 # v31
-        if: ${{ matrix.system.install_nix }}
+      - name: Upload Artifact to Workflow - zed-remote-server (run-bundling)
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+        if: contains(github.event.pull_request.labels.*.name, 'run-bundling')
         with:
-          github_access_token: ${{ secrets.GITHUB_TOKEN }}
+          name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-freebsd.gz
+          path: out/zed-remote-server-freebsd-x86_64.gz
 
-      - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
+      - name: Upload Artifacts to release
+        uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
+        if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) }}
         with:
-          name: zed-industries
-          authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
-          skipPush: true
-      - run: nix build .#debug
-      - name: Limit /nix/store to 50GB
-        run: "[ $(du -sm /nix/store | cut -f1) -gt 50000 ] && nix-collect-garbage -d"
+          draft: true
+          prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
+          files: |
+            out/zed-remote-server-freebsd-x86_64.gz
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+  nix-build:
+    name: Build with Nix
+    uses: ./.github/workflows/nix.yml
+    if: github.repository_owner == 'zed-industries' && contains(github.event.pull_request.labels.*.name, 'run-nix')
+    secrets: inherit
+    with:
+      flake-output: debug
+      # excludes the final package to only cache dependencies
+      cachix-filter: "-zed-editor-[0-9.]*-nightly"
 
   auto-release-preview:
     name: Auto release preview
     if: |
       startsWith(github.ref, 'refs/tags/v')
       && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')
-    needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64]
+    needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, freebsd]
     runs-on:
       - self-hosted
       - bundle
     steps:
       - name: gh release
-        run: gh release edit $GITHUB_REF_NAME --draft=true
+        run: gh release edit $GITHUB_REF_NAME --draft=false
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/community_delete_comments.yml 🔗

@@ -1,34 +0,0 @@
-name: Delete Mediafire Comments
-
-on:
-  issue_comment:
-    types: [created]
-
-permissions:
-  issues: write
-
-jobs:
-  delete_comment:
-    if: github.repository_owner == 'zed-industries'
-    runs-on: ubuntu-latest
-    steps:
-      - name: Check for specific strings in comment
-        id: check_comment
-        uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
-        with:
-          script: |
-            const comment = context.payload.comment.body;
-            const triggerStrings = ['www.mediafire.com'];
-            return triggerStrings.some(triggerString => comment.includes(triggerString));
-
-      - name: Delete comment if it contains any of the specific strings
-        if: steps.check_comment.outputs.result == 'true'
-        uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
-        with:
-          script: |
-            const commentId = context.payload.comment.id;
-            await github.rest.issues.deleteComment({
-              owner: context.repo.owner,
-              repo: context.repo.repo,
-              comment_id: commentId
-            });

.github/workflows/deploy_cloudflare.yml 🔗

@@ -9,7 +9,7 @@ jobs:
   deploy-docs:
     name: Deploy Docs
     if: github.repository_owner == 'zed-industries'
-    runs-on: ubuntu-latest
+    runs-on: buildjet-16vcpu-ubuntu-2204
 
     steps:
       - name: Checkout repo
@@ -17,24 +17,11 @@ jobs:
         with:
           clean: false
 
-      - name: Setup mdBook
-        uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 # v2
-        with:
-          mdbook-version: "0.4.37"
-
       - name: Set up default .cargo/config.toml
         run: cp ./.cargo/collab-config.toml ./.cargo/config.toml
 
-      - name: Install system dependencies
-        run: |
-          sudo apt-get update
-          sudo apt-get install libxkbcommon-dev libxkbcommon-x11-dev
-
-      - name: Build book
-        run: |
-          set -euo pipefail
-          mkdir -p target/deploy
-          mdbook build ./docs --dest-dir=../target/deploy/docs/
+      - name: Build docs
+        uses: ./.github/actions/build_docs
 
       - name: Deploy Docs
         uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3

.github/workflows/deploy_collab.yml 🔗

@@ -15,7 +15,7 @@ jobs:
     if: github.repository_owner == 'zed-industries'
     runs-on:
       - self-hosted
-      - test
+      - macOS
     steps:
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -33,7 +33,7 @@ jobs:
     name: Run tests
     runs-on:
       - self-hosted
-      - test
+      - macOS
     needs: style
     steps:
       - name: Checkout repo

.github/workflows/eval.yml 🔗

@@ -7,7 +7,7 @@ on:
   pull_request:
     branches:
       - "**"
-    types: [opened, synchronize, reopened, labeled]
+    types: [synchronize, reopened, labeled]
 
   workflow_dispatch:
 
@@ -25,15 +25,6 @@ env:
   ZED_EVAL_TELEMETRY: 1
 
 jobs:
-  # This is a no-op job that we run to prevent GitHub from marking the workflow
-  # as failed for PRs that don't have the `run-eval` label.
-  noop:
-    name: No-op
-    runs-on: ubuntu-latest
-    steps:
-      - name: No-op
-        run: echo "Nothing to do"
-
   run_eval:
     timeout-minutes: 60
     name: Run Agent Eval

.github/workflows/nix.yml 🔗

@@ -0,0 +1,66 @@
+name: "Nix build"
+
+on:
+  workflow_call:
+    inputs:
+      flake-output:
+        type: string
+        default: "default"
+      cachix-filter:
+        type: string
+        default: ""
+
+jobs:
+  nix-build:
+    timeout-minutes: 60
+    name: (${{ matrix.system.os }}) Nix Build
+    continue-on-error: true # TODO: remove when we want this to start blocking CI
+    strategy:
+      fail-fast: false
+      matrix:
+        system:
+          - os: x86 Linux
+            runner: buildjet-16vcpu-ubuntu-2204
+            install_nix: true
+          - os: arm Mac
+            runner: [macOS, ARM64, test]
+            install_nix: false
+    if: github.repository_owner == 'zed-industries'
+    runs-on: ${{ matrix.system.runner }}
+    env:
+      ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
+      ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
+      GIT_LFS_SKIP_SMUDGE: 1 # breaks the livekit rust sdk examples which we don't actually depend on
+    steps:
+      - name: Checkout repo
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+        with:
+          clean: false
+
+      # on our macs we manually install nix. for some reason the cachix action is running
+      # under a non-login /bin/bash shell which doesn't source the proper script to add the
+      # nix profile to PATH, so we manually add them here
+      - name: Set path
+        if: ${{ ! matrix.system.install_nix }}
+        run: |
+          echo "/nix/var/nix/profiles/default/bin" >> $GITHUB_PATH
+          echo "/Users/administrator/.nix-profile/bin" >> $GITHUB_PATH
+
+      - uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f # v31
+        if: ${{ matrix.system.install_nix }}
+        with:
+          github_access_token: ${{ secrets.GITHUB_TOKEN }}
+
+      - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
+        with:
+          name: zed
+          authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
+          pushFilter: "${{ inputs.cachix-filter }}"
+          cachixArgs: '-v'
+
+      - run: nix build .#${{ inputs.flake-output }} -L --accept-flake-config
+
+      - name: Limit /nix/store to 50GB on macs
+        if: ${{ ! matrix.system.install_nix }}
+        run: |
+          [ $(du -sm /nix/store | cut -f1) -gt 50000 ] && nix-collect-garbage -d || :

.github/workflows/release_nightly.yml 🔗

@@ -20,7 +20,7 @@ jobs:
     if: github.repository_owner == 'zed-industries'
     runs-on:
       - self-hosted
-      - test
+      - macOS
     steps:
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -40,7 +40,7 @@ jobs:
     if: github.repository_owner == 'zed-industries'
     runs-on:
       - self-hosted
-      - test
+      - macOS
     needs: style
     steps:
       - name: Checkout repo
@@ -68,7 +68,6 @@ jobs:
       DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
       DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
       ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
-      ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
     steps:
       - name: Install Node
         uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
@@ -104,7 +103,6 @@ jobs:
       DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
       DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
       ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
-      ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
     steps:
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -144,7 +142,6 @@ jobs:
       DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
       DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
       ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
-      ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
     steps:
       - name: Checkout repo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -170,6 +167,56 @@ jobs:
       - name: Upload Zed Nightly
         run: script/upload-nightly linux-targz
 
+  freebsd:
+    timeout-minutes: 60
+    if: github.repository_owner == 'zed-industries'
+    runs-on: github-8vcpu-ubuntu-2404
+    needs: tests
+    env:
+      DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }}
+      DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }}
+    name: Build Zed on FreeBSD
+    # env:
+    #   MYTOKEN : ${{ secrets.MYTOKEN }}
+    #   MYTOKEN2: "value2"
+    steps:
+      - uses: actions/checkout@v4
+      - name: Build FreeBSD remote-server
+        id: freebsd-build
+        uses: vmactions/freebsd-vm@c3ae29a132c8ef1924775414107a97cac042aad5 # v1.2.0
+        with:
+          # envs: "MYTOKEN MYTOKEN2"
+          usesh: true
+          release: 13.5
+          copyback: true
+          prepare: |
+            pkg install -y \
+              bash curl jq git \
+              rustup-init cmake-core llvm-devel-lite pkgconf protobuf # ibx11 alsa-lib rust-bindgen-cli
+          run: |
+            freebsd-version
+            sysctl hw.model
+            sysctl hw.ncpu
+            sysctl hw.physmem
+            sysctl hw.usermem
+            git config --global --add safe.directory /home/runner/work/zed/zed
+            rustup-init --profile minimal --default-toolchain none -y
+            . "$HOME/.cargo/env"
+            ./script/bundle-freebsd
+            mkdir -p out/
+            mv "target/zed-remote-server-freebsd-x86_64.gz" out/
+            rm -rf target/
+            cargo clean
+
+      - name: Upload Zed Nightly
+        run: script/upload-nightly freebsd
+
+  bundle-nix:
+    name: Build and cache Nix package
+    needs: tests
+    secrets: inherit
+    uses: ./.github/workflows/nix.yml
+
   update-nightly-tag:
     name: Update nightly tag
     if: github.repository_owner == 'zed-industries'

.github/workflows/unit_evals.yml 🔗

@@ -0,0 +1,86 @@
+name: Run Unit Evals
+
+on:
+  schedule:
+    # GitHub might drop jobs at busy times, so we choose a random time in the middle of the night.
+    - cron: "47 1 * * *"
+  workflow_dispatch:
+
+concurrency:
+  # Allow only one workflow per any non-`main` branch.
+  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
+  cancel-in-progress: true
+
+env:
+  CARGO_TERM_COLOR: always
+  CARGO_INCREMENTAL: 0
+  RUST_BACKTRACE: 1
+  ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
+
+jobs:
+  unit_evals:
+    if: github.repository_owner == 'zed-industries'
+    timeout-minutes: 60
+    name: Run unit evals
+    runs-on:
+      - buildjet-16vcpu-ubuntu-2204
+    steps:
+      - name: Add Rust to the PATH
+        run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+
+      - name: Checkout repo
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+        with:
+          clean: false
+
+      - name: Cache dependencies
+        uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
+        with:
+          save-if: ${{ github.ref == 'refs/heads/main' }}
+          cache-provider: "buildjet"
+
+      - name: Install Linux dependencies
+        run: ./script/linux
+
+      - name: Configure CI
+        run: |
+          mkdir -p ./../.cargo
+          cp ./.cargo/ci-config.toml ./../.cargo/config.toml
+
+      - name: Install Rust
+        shell: bash -euxo pipefail {0}
+        run: |
+          cargo install cargo-nextest --locked
+
+      - name: Install Node
+        uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+        with:
+          node-version: "18"
+
+      - name: Limit target directory size
+        shell: bash -euxo pipefail {0}
+        run: script/clear-target-dir-if-larger-than 100
+
+      - name: Run unit evals
+        shell: bash -euxo pipefail {0}
+        run: cargo nextest run --workspace --no-fail-fast --features eval --no-capture -E 'test(::eval_)'
+        env:
+          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+
+      - name: Send failure message to Slack channel if needed
+        if: ${{ failure() }}
+        uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52
+        with:
+          method: chat.postMessage
+          token: ${{ secrets.SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN }}
+          payload: |
+            channel: C04UDRNNJFQ
+            text: "Unit Evals Failed: https://github.com/zed-industries/zed/actions/runs/${{ github.run_id }}"
+
+      # Even the Linux runner is not stateful, in theory there is no need to do this cleanup.
+      # But, to avoid potential issues in the future if we choose to use a stateful Linux runner and forget to add code
+      # to clean up the config file, I’ve included the cleanup code here as a precaution.
+      # While it’s not strictly necessary at this moment, I believe it’s better to err on the side of caution.
+      - name: Clean CI config file
+        if: always()
+        run: rm -rf ./../.cargo

.gitignore 🔗

@@ -2,6 +2,7 @@
 **/cargo-target
 **/target
 **/venv
+**/.direnv
 *.wasm
 *.xcodeproj
 .DS_Store
@@ -11,6 +12,7 @@
 .flatpak-builder
 .idea
 .netrc
+*.pyc
 .pytest_cache
 .swiftpm
 .swiftpm/config/registries.json

.mailmap 🔗

@@ -19,6 +19,8 @@ amtoaer <amtoaer@gmail.com>
 amtoaer <amtoaer@gmail.com> <amtoaer@outlook.com>
 Andrei Zvonimir Crnković <andrei@0x7f.dev>
 Andrei Zvonimir Crnković <andrei@0x7f.dev> <andreicek@0x7f.dev>
+Angelk90 <angelo.k90@hotmail.it>
+Angelk90 <angelo.k90@hotmail.it> <20476002+Angelk90@users.noreply.github.com>
 Antonio Scandurra <me@as-cii.com>
 Antonio Scandurra <me@as-cii.com> <antonio@zed.dev>
 Ben Kunkle <ben@zed.dev>
@@ -38,6 +40,8 @@ Dairon Medina <dairon.medina@gmail.com>
 Danilo Leal <danilo@zed.dev>
 Danilo Leal <danilo@zed.dev> <67129314+danilo-leal@users.noreply.github.com>
 Edwin Aronsson <75266237+4teapo@users.noreply.github.com>
+Elvis Pranskevichus <elvis@geldata.com>
+Elvis Pranskevichus <elvis@geldata.com> <elvis@magic.io>
 Evren Sen <nervenes@icloud.com>
 Evren Sen <nervenes@icloud.com> <146845123+evrensen467@users.noreply.github.com>
 Evren Sen <nervenes@icloud.com> <146845123+evrsen@users.noreply.github.com>
@@ -69,6 +73,8 @@ Lilith Iris <itslirissama@gmail.com> <83819417+Irilith@users.noreply.github.com>
 LoganDark <contact@logandark.mozmail.com>
 LoganDark <contact@logandark.mozmail.com> <git@logandark.mozmail.com>
 LoganDark <contact@logandark.mozmail.com> <github@logandark.mozmail.com>
+Marko Kungla <marko.kungla@gmail.com>
+Marko Kungla <marko.kungla@gmail.com> <marko@mkungla.dev>
 Marshall Bowers <git@maxdeviant.com>
 Marshall Bowers <git@maxdeviant.com> <elliott.codes@gmail.com>
 Marshall Bowers <git@maxdeviant.com> <marshall@zed.dev>
@@ -84,6 +90,7 @@ Michael Sloan <michael@zed.dev> <mgsloan@google.com>
 Mikayla Maki <mikayla@zed.dev>
 Mikayla Maki <mikayla@zed.dev> <mikayla.c.maki@gmail.com>
 Mikayla Maki <mikayla@zed.dev> <mikayla.c.maki@icloud.com>
+Morgan Krey <morgan@zed.dev>
 Muhammad Talal Anwar <mail@talal.io>
 Muhammad Talal Anwar <mail@talal.io> <talalanwar@outlook.com>
 Nate Butler <iamnbutler@gmail.com>
@@ -116,11 +123,18 @@ Shish <webmaster@shishnet.org>
 Shish <webmaster@shishnet.org> <shish@shishnet.org>
 Smit Barmase <0xtimsb@gmail.com>
 Smit Barmase <0xtimsb@gmail.com> <smit@zed.dev>
+Thomas <github.thomaub@gmail.com>
+Thomas <github.thomaub@gmail.com> <thomas.aubry94@gmail.com>
+Thomas <github.thomaub@gmail.com> <thomas.aubry@paylead.fr>
+Thomas Heartman <thomasheartman+github@gmail.com>
+Thomas Heartman <thomasheartman+github@gmail.com> <thomas@getunleash.io>
+Thomas Mickley-Doyle <tmickleydoyle@gmail.com>
+Thomas Mickley-Doyle <tmickleydoyle@gmail.com> <thomas@zed.dev>
 Thorben Kröger <dev@thorben.net>
 Thorben Kröger <dev@thorben.net> <thorben.kroeger@hexagon.com>
-Thorsten Ball <thorsten@zed.dev>
-Thorsten Ball <thorsten@zed.dev> <me@thorstenball.com>
-Thorsten Ball <thorsten@zed.dev> <mrnugget@gmail.com>
+Thorsten Ball <mrnugget@gmail.com>
+Thorsten Ball <mrnugget@gmail.com> <me@thorstenball.com>
+Thorsten Ball <mrnugget@gmail.com> <thorsten@zed.dev>
 Tristan Hume <tris.hume@gmail.com>
 Tristan Hume <tris.hume@gmail.com> <tristan@anthropic.com>
 Uladzislau Kaminski <i@uladkaminski.com>

.rules 🔗

@@ -5,6 +5,12 @@
 * Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
 * Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
 * Be careful with operations like indexing which may panic if the indexes are out of bounds.
+* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
+  - Propagate errors with `?` when the calling function should handle them
+  - Use `.log_err()` or similar when you need to ignore errors but want visibility
+  - Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
+  - Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
+* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
 * Never create files with `mod.rs` paths - prefer `src/some_module.rs` instead of `src/some_module/mod.rs`.
 
 # GPUI
@@ -94,9 +100,7 @@ Often event handlers will want to update the entity that's in the current `Conte
 
 Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
 
-Actions which have no data inside are created and registered with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call.
-
-Actions that do have data must implement `Clone, Default, PartialEq, Deserialize, JsonSchema` and can be registered with an `impl_actions!(some_namespace, [SomeActionWithData])` macro call.
+Actions with no data defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
 
 Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
 
@@ -115,7 +119,7 @@ Other entities can then register a callback to handle these events by doing `cx.
 GPUI has had some changes to its APIs. Always write code using the new APIs:
 
 * `spawn` methods now take async closures (`AsyncFn`), and so should be called like `cx.spawn(async move |cx| ...)`.
-* Use `Entity<T>`. This replaces `Model<T>` and `View<T>` which longer exists and should NEVER be used.
+* Use `Entity<T>`. This replaces `Model<T>` and `View<T>` which no longer exist and should NEVER be used.
 * Use `App` references. This replaces `AppContext` which no longer exists and should NEVER be used.
 * Use `Context<T>` references. This replaces `ModelContext<T>` which no longer exists and should NEVER be used.
 * `Window` is now passed around explicitly. The new interface adds a `Window` reference parameter to some methods, and adds some new "*_in" methods for plumbing `Window`. The old types `WindowContext` and `ViewContext<T>` should NEVER be used.

.zed/debug.json 🔗

@@ -2,18 +2,23 @@
   {
     "label": "Debug Zed (CodeLLDB)",
     "adapter": "CodeLLDB",
-    "program": "$ZED_WORKTREE_ROOT/target/debug/zed",
-    "request": "launch",
-    "cwd": "$ZED_WORKTREE_ROOT"
+    "build": {
+      "label": "Build Zed",
+      "command": "cargo",
+      "args": [
+        "build"
+      ]
+    }
   },
   {
     "label": "Debug Zed (GDB)",
     "adapter": "GDB",
-    "program": "$ZED_WORKTREE_ROOT/target/debug/zed",
-    "request": "launch",
-    "cwd": "$ZED_WORKTREE_ROOT",
-    "initialize_args": {
-      "stopAtBeginningOfMainSubprogram": true
+    "build": {
+      "label": "Build Zed",
+      "command": "cargo",
+      "args": [
+        "build"
+      ]
     }
-  }
+  },
 ]

.zed/settings.json 🔗

@@ -40,6 +40,7 @@
   },
   "file_types": {
     "Dockerfile": ["Dockerfile*[!dockerignore]"],
+    "JSONC": ["assets/**/*.json", "renovate.json"],
     "Git Ignore": ["dockerignore"]
   },
   "hard_tabs": false,
@@ -47,6 +48,7 @@
   "remove_trailing_whitespace_on_save": true,
   "ensure_final_newline_on_save": true,
   "file_scan_exclusions": [
+    "crates/assistant_tools/src/edit_agent/evals/fixtures",
     "crates/eval/worktrees/",
     "crates/eval/repos/",
     "**/.git",

Cargo.lock 🔗

@@ -14,6 +14,8 @@ dependencies = [
  "gpui",
  "language",
  "project",
+ "proto",
+ "release_channel",
  "smallvec",
  "ui",
  "util",
@@ -52,41 +54,117 @@ dependencies = [
 name = "agent"
 version = "0.1.0"
 dependencies = [
+ "agent_settings",
+ "anyhow",
+ "assistant_context",
+ "assistant_tool",
+ "assistant_tools",
+ "chrono",
+ "client",
+ "collections",
+ "component",
+ "context_server",
+ "convert_case 0.8.0",
+ "feature_flags",
+ "fs",
+ "futures 0.3.31",
+ "git",
+ "gpui",
+ "heed",
+ "http_client",
+ "icons",
+ "indoc",
+ "itertools 0.14.0",
+ "language",
+ "language_model",
+ "log",
+ "parking_lot",
+ "paths",
+ "postage",
+ "pretty_assertions",
+ "project",
+ "prompt_store",
+ "proto",
+ "rand 0.8.5",
+ "ref-cast",
+ "rope",
+ "schemars",
+ "serde",
+ "serde_json",
+ "settings",
+ "smol",
+ "sqlez",
+ "telemetry",
+ "text",
+ "theme",
+ "thiserror 2.0.12",
+ "time",
+ "util",
+ "uuid",
+ "workspace",
+ "workspace-hack",
+ "zed_llm_client",
+ "zstd",
+]
+
+[[package]]
+name = "agent_settings"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "collections",
+ "fs",
+ "gpui",
+ "language_model",
+ "paths",
+ "schemars",
+ "serde",
+ "serde_json",
+ "serde_json_lenient",
+ "settings",
+ "workspace-hack",
+ "zed_llm_client",
+]
+
+[[package]]
+name = "agent_ui"
+version = "0.1.0"
+dependencies = [
+ "agent",
+ "agent_settings",
  "anyhow",
- "assistant_context_editor",
- "assistant_settings",
+ "assistant_context",
  "assistant_slash_command",
  "assistant_slash_commands",
  "assistant_tool",
- "async-watch",
+ "assistant_tools",
+ "audio",
  "buffer_diff",
  "chrono",
  "client",
  "collections",
  "component",
  "context_server",
- "convert_case 0.8.0",
  "db",
  "editor",
  "extension",
+ "extension_host",
  "feature_flags",
  "file_icons",
  "fs",
  "futures 0.3.31",
  "fuzzy",
- "git",
  "gpui",
- "heed",
  "html_to_markdown",
  "http_client",
  "indexed_docs",
  "indoc",
+ "inventory",
  "itertools 0.14.0",
  "jsonschema",
  "language",
  "language_model",
- "language_model_selector",
- "linkme",
+ "languages",
  "log",
  "lsp",
  "markdown",
@@ -97,12 +175,11 @@ dependencies = [
  "parking_lot",
  "paths",
  "picker",
- "postage",
+ "pretty_assertions",
  "project",
  "prompt_store",
  "proto",
  "rand 0.8.5",
- "ref-cast",
  "release_channel",
  "rope",
  "rules_library",
@@ -112,7 +189,6 @@ dependencies = [
  "serde_json",
  "serde_json_lenient",
  "settings",
- "smallvec",
  "smol",
  "streaming_diff",
  "telemetry",
@@ -121,14 +197,15 @@ dependencies = [
  "terminal_view",
  "text",
  "theme",
- "thiserror 2.0.12",
  "time",
  "time_format",
+ "tree-sitter-md",
  "ui",
- "ui_input",
+ "unindent",
  "urlencoding",
  "util",
  "uuid",
+ "watch",
  "workspace",
  "workspace-hack",
  "zed_actions",
@@ -366,6 +443,12 @@ version = "1.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
 
+[[package]]
+name = "arc-swap"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
+
 [[package]]
 name = "arg_enum_proc_macro"
 version = "0.3.4"
@@ -374,7 +457,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -455,7 +538,6 @@ dependencies = [
  "anyhow",
  "futures 0.3.31",
  "gpui",
- "shlex",
  "smol",
  "tempfile",
  "util",
@@ -473,11 +555,11 @@ dependencies = [
 ]
 
 [[package]]
-name = "assistant_context_editor"
+name = "assistant_context"
 version = "0.1.0"
 dependencies = [
+ "agent_settings",
  "anyhow",
- "assistant_settings",
  "assistant_slash_command",
  "assistant_slash_commands",
  "chrono",
@@ -485,28 +567,23 @@ dependencies = [
  "clock",
  "collections",
  "context_server",
- "editor",
  "fs",
  "futures 0.3.31",
  "fuzzy",
  "gpui",
- "indexed_docs",
+ "indoc",
  "language",
  "language_model",
- "language_model_selector",
- "languages",
  "log",
- "multi_buffer",
  "open_ai",
  "parking_lot",
  "paths",
- "picker",
  "pretty_assertions",
  "project",
  "prompt_store",
+ "proto",
  "rand 0.8.5",
  "regex",
- "rope",
  "rpc",
  "serde",
  "serde_json",
@@ -515,40 +592,12 @@ dependencies = [
  "smol",
  "telemetry_events",
  "text",
- "theme",
- "tree-sitter-md",
  "ui",
  "unindent",
  "util",
  "uuid",
  "workspace",
  "workspace-hack",
- "zed_actions",
-]
-
-[[package]]
-name = "assistant_settings"
-version = "0.1.0"
-dependencies = [
- "anthropic",
- "anyhow",
- "collections",
- "deepseek",
- "fs",
- "gpui",
- "indexmap",
- "language_model",
- "lmstudio",
- "log",
- "ollama",
- "open_ai",
- "paths",
- "schemars",
- "serde",
- "serde_json",
- "serde_json_lenient",
- "settings",
- "workspace-hack",
  "zed_llm_client",
 ]
 
@@ -585,7 +634,6 @@ dependencies = [
  "collections",
  "context_server",
  "editor",
- "env_logger 0.11.8",
  "feature_flags",
  "fs",
  "futures 0.3.31",
@@ -604,7 +652,6 @@ dependencies = [
  "serde_json",
  "settings",
  "smol",
- "terminal_view",
  "text",
  "toml 0.8.20",
  "ui",
@@ -612,6 +659,7 @@ dependencies = [
  "workspace",
  "workspace-hack",
  "worktree",
+ "zlog",
 ]
 
 [[package]]
@@ -624,7 +672,6 @@ dependencies = [
  "collections",
  "ctor",
  "derive_more",
- "env_logger 0.11.8",
  "futures 0.3.31",
  "gpui",
  "icons",
@@ -641,17 +688,18 @@ dependencies = [
  "settings",
  "text",
  "util",
+ "watch",
  "workspace",
  "workspace-hack",
+ "zlog",
 ]
 
 [[package]]
 name = "assistant_tools"
 version = "0.1.0"
 dependencies = [
- "aho-corasick",
+ "agent_settings",
  "anyhow",
- "assistant_settings",
  "assistant_tool",
  "buffer_diff",
  "chrono",
@@ -674,14 +722,15 @@ dependencies = [
  "language",
  "language_model",
  "language_models",
- "linkme",
  "log",
+ "lsp",
  "markdown",
  "open",
  "paths",
  "portable-pty",
  "pretty_assertions",
  "project",
+ "prompt_store",
  "rand 0.8.5",
  "regex",
  "reqwest_client",
@@ -691,6 +740,7 @@ dependencies = [
  "serde_json",
  "settings",
  "smallvec",
+ "smol",
  "streaming_diff",
  "strsim",
  "task",
@@ -702,6 +752,7 @@ dependencies = [
  "ui",
  "unindent",
  "util",
+ "watch",
  "web_search",
  "which 6.0.3",
  "workspace",
@@ -919,7 +970,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -987,7 +1038,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -1038,7 +1089,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -1060,15 +1111,6 @@ dependencies = [
  "tungstenite 0.26.2",
 ]
 
-[[package]]
-name = "async-watch"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a078faf4e27c0c6cc0efb20e5da59dcccc04968ebf2801d8e0b2195124cdcdb2"
-dependencies = [
- "event-listener 2.5.3",
-]
-
 [[package]]
 name = "async_zip"
 version = "0.0.17"
@@ -1873,6 +1915,12 @@ dependencies = [
  "workspace-hack",
 ]
 
+[[package]]
+name = "beef"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
+
 [[package]]
 name = "bigdecimal"
 version = "0.4.8"
@@ -1915,7 +1963,7 @@ dependencies = [
  "regex",
  "rustc-hash 1.1.0",
  "shlex",
- "syn 2.0.100",
+ "syn 2.0.101",
  "which 4.4.2",
 ]
 
@@ -1934,7 +1982,7 @@ dependencies = [
  "regex",
  "rustc-hash 1.1.0",
  "shlex",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -1954,7 +2002,7 @@ dependencies = [
  "regex",
  "rustc-hash 2.1.1",
  "shlex",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -2029,7 +2077,7 @@ dependencies = [
 [[package]]
 name = "blade-graphics"
 version = "0.6.0"
-source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad"
+source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5"
 dependencies = [
  "ash",
  "ash-window",
@@ -2062,17 +2110,17 @@ dependencies = [
 [[package]]
 name = "blade-macros"
 version = "0.3.0"
-source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad"
+source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
 name = "blade-util"
 version = "0.2.0"
-source = "git+https://github.com/kvark/blade?rev=416375211bb0b5826b3584dccdb6a43369e499ad#416375211bb0b5826b3584dccdb6a43369e499ad"
+source = "git+https://github.com/kvark/blade?rev=e0ec4e720957edd51b945b64dd85605ea54bcfe5#e0ec4e720957edd51b945b64dd85605ea54bcfe5"
 dependencies = [
  "blade-graphics",
  "bytemuck",
@@ -2080,6 +2128,15 @@ dependencies = [
  "profiling",
 ]
 
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
 [[package]]
 name = "blake3"
 version = "1.8.2"
@@ -2165,7 +2222,7 @@ dependencies = [
  "proc-macro-crate",
  "proc-macro2",
  "quote",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -2175,6 +2232,7 @@ dependencies = [
  "editor",
  "gpui",
  "itertools 0.14.0",
+ "settings",
  "theme",
  "ui",
  "workspace",
@@ -2200,7 +2258,6 @@ dependencies = [
  "anyhow",
  "clock",
  "ctor",
- "env_logger 0.11.8",
  "futures 0.3.31",
  "git2",
  "gpui",
@@ -2215,6 +2272,7 @@ dependencies = [
  "unindent",
  "util",
  "workspace-hack",
+ "zlog",
 ]
 
 [[package]]
@@ -2283,7 +2341,7 @@ checksum = "7ecc273b49b3205b83d648f0690daa588925572cc5063745bfe547fe7ec8e1a1"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -2426,7 +2484,7 @@ checksum = "9f83833816c66c986e913b22ac887cec216ea09301802054316fc5301809702c"
 dependencies = [
  "cap-primitives",
  "cap-std",
- "rustix 1.0.5",
+ "rustix 1.0.7",
  "smallvec",
 ]
 
@@ -2442,7 +2500,7 @@ dependencies = [
  "io-lifetimes",
  "ipnet",
  "maybe-owned",
- "rustix 1.0.5",
+ "rustix 1.0.7",
  "rustix-linux-procfs",
  "windows-sys 0.59.0",
  "winx",
@@ -2467,7 +2525,7 @@ dependencies = [
  "cap-primitives",
  "io-extras",
  "io-lifetimes",
- "rustix 1.0.5",
+ "rustix 1.0.7",
 ]
 
 [[package]]
@@ -2480,7 +2538,7 @@ dependencies = [
  "cap-primitives",
  "iana-time-zone",
  "once_cell",
- "rustix 1.0.5",
+ "rustix 1.0.7",
  "winx",
 ]
 
@@ -2545,7 +2603,7 @@ dependencies = [
  "quote",
  "serde",
  "serde_json",
- "syn 2.0.100",
+ "syn 2.0.101",
  "tempfile",
  "toml 0.8.20",
 ]
@@ -2626,6 +2684,7 @@ dependencies = [
  "http_client",
  "language",
  "log",
+ "postage",
  "rand 0.8.5",
  "release_channel",
  "rpc",
@@ -2639,9 +2698,9 @@ dependencies = [
 
 [[package]]
 name = "chrono"
-version = "0.4.40"
+version = "0.4.41"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
+checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
 dependencies = [
  "android-tzdata",
  "iana-time-zone",
@@ -2754,7 +2813,7 @@ dependencies = [
  "heck 0.5.0",
  "proc-macro2",
  "quote",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -2793,24 +2852,30 @@ dependencies = [
  "anyhow",
  "async-recursion 0.3.2",
  "async-tungstenite",
+ "base64 0.22.1",
  "chrono",
  "clock",
  "cocoa 0.26.0",
  "collections",
  "credentials_provider",
+ "derive_more",
  "feature_flags",
+ "fs",
  "futures 0.3.31",
  "gpui",
  "gpui_tokio",
  "http_client",
  "http_client_tls",
+ "httparse",
  "log",
  "parking_lot",
  "paths",
  "postage",
  "rand 0.8.5",
+ "regex",
  "release_channel",
  "rpc",
+ "rustls-pki-types",
  "schemars",
  "serde",
  "serde_json",
@@ -2824,12 +2889,15 @@ dependencies = [
  "time",
  "tiny_http",
  "tokio",
+ "tokio-native-tls",
+ "tokio-rustls 0.26.2",
  "tokio-socks",
  "url",
  "util",
  "windows 0.61.1",
  "workspace-hack",
  "worktree",
+ "zed_llm_client",
 ]
 
 [[package]]
@@ -2842,6 +2910,12 @@ dependencies = [
  "workspace-hack",
 ]
 
+[[package]]
+name = "clru"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbd0f76e066e64fdc5631e3bb46381254deab9ef1158292f27c8c57e3bf3fe59"
+
 [[package]]
 name = "cmake"
 version = "0.1.54"
@@ -2942,11 +3016,10 @@ dependencies = [
 name = "collab"
 version = "0.44.0"
 dependencies = [
+ "agent_settings",
  "anyhow",
- "assistant_context_editor",
- "assistant_settings",
+ "assistant_context",
  "assistant_slash_command",
- "assistant_tool",
  "async-stripe",
  "async-trait",
  "async-tungstenite",
@@ -2969,11 +3042,11 @@ dependencies = [
  "context_server",
  "ctor",
  "dap",
+ "dap_adapters",
  "dashmap 6.1.0",
  "debugger_ui",
  "derive_more",
  "editor",
- "env_logger 0.11.8",
  "envy",
  "extension",
  "file_finder",
@@ -3048,6 +3121,7 @@ dependencies = [
  "workspace-hack",
  "worktree",
  "zed_llm_client",
+ "zlog",
 ]
 
 [[package]]
@@ -3127,6 +3201,16 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "command-fds"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ec1052629a80c28594777d1252efc8a6b005d13f9edfd8c3fc0f44d5b32489a"
+dependencies = [
+ "nix 0.30.1",
+ "thiserror 2.0.12",
+]
+
 [[package]]
 name = "command_palette"
 version = "0.1.0"
@@ -3177,7 +3261,7 @@ version = "0.1.0"
 dependencies = [
  "collections",
  "gpui",
- "linkme",
+ "inventory",
  "parking_lot",
  "strum 0.27.1",
  "theme",
@@ -3301,14 +3385,15 @@ dependencies = [
  "collections",
  "command_palette_hooks",
  "ctor",
+ "dirs 4.0.0",
  "editor",
- "env_logger 0.11.8",
  "fs",
  "futures 0.3.31",
  "gpui",
  "http_client",
  "indoc",
  "inline_completion",
+ "itertools 0.14.0",
  "language",
  "log",
  "lsp",
@@ -3318,17 +3403,16 @@ dependencies = [
  "paths",
  "project",
  "rpc",
- "schemars",
  "serde",
  "serde_json",
  "settings",
- "strum 0.27.1",
  "task",
  "theme",
  "ui",
  "util",
  "workspace",
  "workspace-hack",
+ "zlog",
 ]
 
 [[package]]
@@ -3497,6 +3581,20 @@ dependencies = [
  "coreaudio-sys",
 ]
 
+[[package]]
+name = "coreaudio-rs"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+ "objc2-audio-toolbox",
+ "objc2-core-audio",
+ "objc2-core-audio-types",
+ "objc2-core-foundation",
+]
+
 [[package]]
 name = "coreaudio-sys"
 version = "0.2.16"
@@ -3532,7 +3630,8 @@ dependencies = [
 [[package]]
 name = "cpal"
 version = "0.15.3"
-source = "git+https://github.com/zed-industries/cpal?rev=fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50#fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
 dependencies = [
  "alsa",
  "core-foundation-sys",
@@ -3542,7 +3641,7 @@ dependencies = [
  "js-sys",
  "libc",
  "mach2",
- "ndk",
+ "ndk 0.8.0",
  "ndk-context",
  "oboe",
  "wasm-bindgen",
@@ -3551,6 +3650,32 @@ dependencies = [
  "windows 0.54.0",
 ]
 
+[[package]]
+name = "cpal"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f"
+dependencies = [
+ "alsa",
+ "coreaudio-rs 0.13.0",
+ "dasp_sample",
+ "jni",
+ "js-sys",
+ "libc",
+ "mach2",
+ "ndk 0.9.0",
+ "ndk-context",
+ "num-derive",
+ "num-traits",
+ "objc2-audio-toolbox",
+ "objc2-core-audio",
+ "objc2-core-audio-types",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows 0.54.0",
+]
+
 [[package]]
 name = "cpp_demangle"
 version = "0.4.4"
@@ -3605,9 +3730,12 @@ dependencies = [
  "gimli",
  "hashbrown 0.14.5",
  "log",
+ "postcard",
  "regalloc2",
  "rustc-hash 2.1.1",
  "serde",
+ "serde_derive",
+ "sha2",
  "smallvec",
  "target-lexicon 0.13.2",
 ]
@@ -3870,7 +3998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
 dependencies = [
  "quote",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -3930,7 +4058,7 @@ dependencies = [
  "proc-macro2",
  "quote",
  "scratch",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -3943,7 +4071,7 @@ dependencies = [
  "codespan-reporting 0.12.0",
  "proc-macro2",
  "quote",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -3961,7 +4089,7 @@ dependencies = [
  "proc-macro2",
  "quote",
  "rustversion",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -3976,12 +4104,12 @@ dependencies = [
  "client",
  "collections",
  "dap-types",
- "env_logger 0.11.8",
  "fs",
  "futures 0.3.31",
  "gpui",
  "http_client",
  "language",
+ "libc",
  "log",
  "node_runtime",
  "parking_lot",
@@ -3994,14 +4122,18 @@ dependencies = [
  "smallvec",
  "smol",
  "task",
+ "telemetry",
+ "tree-sitter",
+ "tree-sitter-go",
  "util",
  "workspace-hack",
+ "zlog",
 ]
 
 [[package]]
 name = "dap-types"
 version = "0.0.1"
-source = "git+https://github.com/zed-industries/dap-types?rev=be69a016ba710191b9fdded28c8b042af4b617f7#be69a016ba710191b9fdded28c8b042af4b617f7"
+source = "git+https://github.com/zed-industries/dap-types?rev=b40956a7f4d1939da67429d941389ee306a3a308#b40956a7f4d1939da67429d941389ee306a3a308"
 dependencies = [
  "schemars",
  "serde",
@@ -4014,13 +4146,17 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "async-trait",
+ "collections",
  "dap",
  "futures 0.3.31",
  "gpui",
+ "json_dotpath",
  "language",
+ "log",
  "paths",
  "serde",
  "serde_json",
+ "shlex",
  "task",
  "util",
  "workspace-hack",
@@ -4047,7 +4183,7 @@ dependencies = [
  "proc-macro2",
  "quote",
  "strsim",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -4058,7 +4194,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
 dependencies = [
  "darling_core",
  "quote",
- "syn 2.0.100",
+ "syn 2.0.101",
 ]
 
 [[package]]
@@ -4135,6 +4271,21 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "debug_adapter_extension"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "dap",
+ "extension",
+ "gpui",
+ "serde_json",
+ "task",
+ "util",
+ "workspace-hack",
+]
+
 [[package]]
 name = "debugger_tools"
 version = "0.1.0"
@@ -4157,7 +4308,9 @@ dependencies = [
 name = "debugger_ui"
 version = "0.1.0"
 dependencies = [
+ "alacritty_terminal",
  "anyhow",
+ "bitflags 2.9.0",
  "client",
  "collections",
  "command_palette_hooks",

Cargo.toml 🔗

@@ -2,12 +2,13 @@
 resolver = "2"
 members = [
     "crates/activity_indicator",
+    "crates/agent_ui",
     "crates/agent",
+    "crates/agent_settings",
     "crates/anthropic",
     "crates/askpass",
     "crates/assets",
-    "crates/assistant_context_editor",
-    "crates/assistant_settings",
+    "crates/assistant_context",
     "crates/assistant_slash_command",
     "crates/assistant_slash_commands",
     "crates/assistant_tool",
@@ -37,6 +38,7 @@ members = [
     "crates/dap",
     "crates/dap_adapters",
     "crates/db",
+    "crates/debug_adapter_extension",
     "crates/debugger_tools",
     "crates/debugger_ui",
     "crates/deepseek",
@@ -64,6 +66,7 @@ members = [
     "crates/gpui",
     "crates/gpui_macros",
     "crates/gpui_tokio",
+
     "crates/html_to_markdown",
     "crates/http_client",
     "crates/http_client_tls",
@@ -72,12 +75,14 @@ members = [
     "crates/indexed_docs",
     "crates/inline_completion",
     "crates/inline_completion_button",
+    "crates/inspector_ui",
     "crates/install_cli",
+    "crates/jj",
+    "crates/jj_ui",
     "crates/journal",
     "crates/language",
     "crates/language_extension",
     "crates/language_model",
-    "crates/language_model_selector",
     "crates/language_models",
     "crates/language_selector",
     "crates/language_tools",
@@ -90,6 +95,7 @@ members = [
     "crates/markdown_preview",
     "crates/media",
     "crates/menu",
+    "crates/svg_preview",
     "crates/migrator",
     "crates/mistral",
     "crates/multi_buffer",
@@ -97,6 +103,7 @@ members = [
     "crates/notifications",
     "crates/ollama",
     "crates/open_ai",
+    "crates/open_router",
     "crates/outline",
     "crates/outline_panel",
     "crates/panel",
@@ -159,8 +166,10 @@ members = [
     "crates/ui_prompt",
     "crates/util",
     "crates/util_macros",
+    "crates/vercel",
     "crates/vim",
     "crates/vim_mode_setting",
+    "crates/watch",
     "crates/web_search",
     "crates/web_search_providers",
     "crates/welcome",
@@ -208,12 +217,13 @@ edition = "2024"
 
 activity_indicator = { path = "crates/activity_indicator" }
 agent = { path = "crates/agent" }
+agent_ui = { path = "crates/agent_ui" }
+agent_settings = { path = "crates/agent_settings" }
 ai = { path = "crates/ai" }
 anthropic = { path = "crates/anthropic" }
 askpass = { path = "crates/askpass" }
 assets = { path = "crates/assets" }
-assistant_context_editor = { path = "crates/assistant_context_editor" }
-assistant_settings = { path = "crates/assistant_settings" }
+assistant_context = { path = "crates/assistant_context" }
 assistant_slash_command = { path = "crates/assistant_slash_command" }
 assistant_slash_commands = { path = "crates/assistant_slash_commands" }
 assistant_tool = { path = "crates/assistant_tool" }
@@ -243,6 +253,7 @@ credentials_provider = { path = "crates/credentials_provider" }
 dap = { path = "crates/dap" }
 dap_adapters = { path = "crates/dap_adapters" }
 db = { path = "crates/db" }
+debug_adapter_extension = { path = "crates/debug_adapter_extension" }
 debugger_tools = { path = "crates/debugger_tools" }
 debugger_ui = { path = "crates/debugger_ui" }
 deepseek = { path = "crates/deepseek" }
@@ -276,12 +287,14 @@ image_viewer = { path = "crates/image_viewer" }
 indexed_docs = { path = "crates/indexed_docs" }
 inline_completion = { path = "crates/inline_completion" }
 inline_completion_button = { path = "crates/inline_completion_button" }
+inspector_ui = { path = "crates/inspector_ui" }
 install_cli = { path = "crates/install_cli" }
+jj = { path = "crates/jj" }
+jj_ui = { path = "crates/jj_ui" }
 journal = { path = "crates/journal" }
 language = { path = "crates/language" }
 language_extension = { path = "crates/language_extension" }
 language_model = { path = "crates/language_model" }
-language_model_selector = { path = "crates/language_model_selector" }
 language_models = { path = "crates/language_models" }
 language_selector = { path = "crates/language_selector" }
 language_tools = { path = "crates/language_tools" }
@@ -292,6 +305,7 @@ lmstudio = { path = "crates/lmstudio" }
 lsp = { path = "crates/lsp" }
 markdown = { path = "crates/markdown" }
 markdown_preview = { path = "crates/markdown_preview" }
+svg_preview = { path = "crates/svg_preview" }
 media = { path = "crates/media" }
 menu = { path = "crates/menu" }
 migrator = { path = "crates/migrator" }
@@ -301,6 +315,7 @@ node_runtime = { path = "crates/node_runtime" }
 notifications = { path = "crates/notifications" }
 ollama = { path = "crates/ollama" }
 open_ai = { path = "crates/open_ai" }
+open_router = { path = "crates/open_router", features = ["schemars"] }
 outline = { path = "crates/outline" }
 outline_panel = { path = "crates/outline_panel" }
 panel = { path = "crates/panel" }
@@ -363,8 +378,11 @@ ui_macros = { path = "crates/ui_macros" }
 ui_prompt = { path = "crates/ui_prompt" }
 util = { path = "crates/util" }
 util_macros = { path = "crates/util_macros" }
+vercel = { path = "crates/vercel" }
 vim = { path = "crates/vim" }
 vim_mode_setting = { path = "crates/vim_mode_setting" }
+
+watch = { path = "crates/watch" }
 web_search = { path = "crates/web_search" }
 web_search_providers = { path = "crates/web_search_providers" }
 welcome = { path = "crates/welcome" }
@@ -395,7 +413,6 @@ async-recursion = "1.0.0"
 async-tar = "0.5.0"
 async-trait = "0.1"
 async-tungstenite = "0.29.1"
-async-watch = "0.3.1"
 async_zip = { version = "0.0.17", features = ["deflate", "deflate64"] }
 aws-config = { version = "1.6.1", features = ["behavior-version-latest"] }
 aws-credential-types = { version = "1.2.2", features = [
@@ -408,9 +425,9 @@ aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] }
 aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] }
 base64 = "0.22"
 bitflags = "2.6.0"
-blade-graphics = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" }
-blade-macros = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" }
-blade-util = { git = "https://github.com/kvark/blade", rev = "416375211bb0b5826b3584dccdb6a43369e499ad" }
+blade-graphics = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" }
+blade-macros = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" }
+blade-util = { git = "https://github.com/kvark/blade", rev = "e0ec4e720957edd51b945b64dd85605ea54bcfe5" }
 blake3 = "1.5.3"
 bytes = "1.0"
 cargo_metadata = "0.19"
@@ -424,8 +441,10 @@ convert_case = "0.8.0"
 core-foundation = "0.10.0"
 core-foundation-sys = "0.8.6"
 core-video = { version = "0.4.3", features = ["metal"] }
+cpal = "0.16"
+criterion = { version = "0.5", features = ["html_reports"] }
 ctor = "0.4.0"
-dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "be69a016ba710191b9fdded28c8b042af4b617f7" }
+dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "b40956a7f4d1939da67429d941389ee306a3a308" }
 dashmap = "6.0"
 derive_more = "0.99.17"
 dirs = "4.0"
@@ -456,6 +475,8 @@ indexmap = { version = "2.7.0", features = ["serde"] }
 indoc = "2"
 inventory = "0.3.19"
 itertools = "0.14.0"
+jj-lib = { git = "https://github.com/jj-vcs/jj", rev = "e18eb8e05efaa153fad5ef46576af145bba1807f" }
+json_dotpath = "1.1"
 jsonschema = "0.30.0"
 jsonwebtoken = "9.3"
 jupyter-protocol = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
@@ -463,12 +484,11 @@ jupyter-websocket-client = {  git = "https://github.com/ConradIrwin/runtimed" ,r
 libc = "0.2"
 libsqlite3-sys = { version = "0.30.1", features = ["bundled"] }
 linkify = "0.10.0"
-linkme = "0.3.31"
 log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
 lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "c9c189f1c5dd53c624a419ce35bc77ad6a908d18" }
 markup5ever_rcdom = "0.3.0"
 metal = "0.29"
-mlua = { version = "0.10", features = ["lua54", "vendored", "async", "send"] }
+moka = { version = "0.12.10", features = ["sync"] }
 naga = { version = "25.0", features = ["wgsl-in"] }
 nanoid = "0.4"
 nbformat = {  git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
@@ -502,7 +522,6 @@ rand = "0.8.5"
 rayon = "1.8"
 ref-cast = "1.0.24"
 regex = "1.5"
-repair_json = "0.1.0"
 reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "951c770a32f1998d6e999cef3e59e0013e6c4415", default-features = false, features = [
     "charset",
     "http2",
@@ -534,7 +553,6 @@ serde_repr = "0.1"
 sha2 = "0.10"
 shellexpand = "2.1.0"
 shlex = "1.3.0"
-signal-hook = "0.3.17"
 simplelog = "0.12.2"
 smallvec = { version = "1.6", features = ["union"] }
 smol = "2.0"
@@ -543,13 +561,13 @@ streaming-iterator = "0.1"
 strsim = "0.11"
 strum = { version = "0.27.0", features = ["derive"] }
 subtle = "2.5.0"
-syn = { version = "1.0.72", features = ["full", "extra-traits"] }
+syn = { version = "2.0.101", features = ["full", "extra-traits"] }
 sys-locale = "0.3.1"
 sysinfo = "0.31.0"
 take-until = "0.2.0"
-tempfile = "3.9.0"
+tempfile = "3.20.0"
 thiserror = "2.0.12"
-tiktoken-rs = "0.6.0"
+tiktoken-rs = "0.7.0"
 time = { version = "0.3", features = [
     "macros",
     "parsing",
@@ -562,8 +580,8 @@ tokio = { version = "1" }
 tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] }
 toml = "0.8"
 tower-http = "0.4.4"
-tree-sitter = { version = "0.25.3", features = ["wasm"] }
-tree-sitter-bash = "0.23"
+tree-sitter = { version = "0.25.6", features = ["wasm"] }
+tree-sitter-bash = "0.25.0"
 tree-sitter-c = "0.23"
 tree-sitter-cpp = "0.23"
 tree-sitter-css = "0.23"
@@ -579,7 +597,7 @@ tree-sitter-html = "0.23"
 tree-sitter-jsdoc = "0.23"
 tree-sitter-json = "0.24"
 tree-sitter-md = { git = "https://github.com/tree-sitter-grammars/tree-sitter-markdown", rev = "9a23c1a96c0513d8fc6520972beedd419a973539" }
-tree-sitter-python = "0.23"
+tree-sitter-python = { git = "https://github.com/zed-industries/tree-sitter-python", rev = "218fcbf3fda3d029225f3dec005cb497d111b35e" }
 tree-sitter-regex = "0.24"
 tree-sitter-ruby = "0.23"
 tree-sitter-rust = "0.24"
@@ -592,7 +610,7 @@ unindent = "0.2.0"
 url = "2.2"
 urlencoding = "2.1.2"
 uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] }
-walkdir = "2.3"
+walkdir = "2.5"
 wasm-encoder = "0.221"
 wasmparser = "0.221"
 wasmtime = { version = "29", default-features = false, features = [
@@ -601,12 +619,13 @@ wasmtime = { version = "29", default-features = false, features = [
     "runtime",
     "cranelift",
     "component-model",
+    "incremental-cache",
+    "parallel-compilation",
 ] }
 wasmtime-wasi = "29"
 which = "6.0.0"
-wit-component = "0.221"
 workspace-hack = "0.1.0"
-zed_llm_client = "0.8.1"
+zed_llm_client = "0.8.4"
 zstd = "0.11"
 
 [workspace.dependencies.async-stripe]
@@ -668,9 +687,7 @@ features = [
     "Win32_UI_WindowsAndMessaging",
 ]
 
-# TODO livekit https://github.com/RustAudio/cpal/pull/891
 [patch.crates-io]
-cpal = { git = "https://github.com/zed-industries/cpal", rev = "fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50" }
 notify = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" }
 notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" }
 
@@ -684,6 +701,8 @@ codegen-units = 16
 [profile.dev.package]
 taffy = { opt-level = 3 }
 cranelift-codegen = { opt-level = 3 }
+cranelift-codegen-meta = { opt-level = 3 }
+cranelift-codegen-shared = { opt-level = 3 }
 resvg = { opt-level = 3 }
 rustybuzz = { opt-level = 3 }
 ttf-parser = { opt-level = 3 }
@@ -786,6 +805,9 @@ let_underscore_future = "allow"
 # running afoul of the borrow checker.
 too_many_arguments = "allow"
 
+# We often have large enum variants yet we rarely actually bother with splitting them up.
+large_enum_variant = "allow"
+
 [workspace.metadata.cargo-machete]
 ignored = [
     "bindgen",
@@ -793,7 +815,6 @@ ignored = [
     "prost_build",
     "serde",
     "component",
-    "linkme",
     "documented",
     "workspace-hack",
 ]

Dockerfile-collab 🔗

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

README.md 🔗

@@ -8,10 +8,6 @@ Welcome to Zed, a high-performance, multiplayer code editor from the creators of
 
 ### Installation
 
-<a href="https://repology.org/project/zed-editor/versions">
-    <img src="https://repology.org/badge/vertical-allrepos/zed-editor.svg?minversion=0.143.5" alt="Packaging status" align="right">
-</a>
-
 On macOS and Linux 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).
 
 Other platforms are not yet available:

assets/icons/ai_open_router.svg 🔗

@@ -0,0 +1,8 @@
+<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor" stroke="currentColor">
+  <g clip-path="url(#clip0_205_3)">
+    <path d="M0.094 7.78c0.469 0 2.281 -0.405 3.219 -0.936s0.938 -0.531 2.875 -1.906c2.453 -1.741 4.188 -1.158 7.031 -1.158" stroke-width="2.8125" />
+    <path d="m15.969 3.797 -4.805 2.774V1.023z" />
+    <path d="M0 7.781c0.469 0 2.281 0.405 3.219 0.936s0.938 0.531 2.875 1.906C8.547 12.364 10.281 11.781 13.125 11.781" stroke-width="2.8125" />
+    <path d="m15.875 11.764 -4.805 -2.774v5.548z" />
+  </g>
+</svg>

assets/icons/ai_v_zero.svg 🔗

@@ -0,0 +1,16 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_2639_570)">
+<g clip-path="url(#clip1_2639_570)">
+<path d="M9.85676 4H13.6675C15.2128 4 16.4654 5.25266 16.4654 6.7979V10.4322H14.9002V6.7979C14.9002 6.76067 14.8988 6.7237 14.8959 6.68706L11.0851 10.4316C11.098 10.432 11.1109 10.4322 11.1238 10.4322H14.9002V11.9105H11.1238C9.57856 11.9105 8.29152 10.6456 8.29152 9.10032V5.47569H9.85676V9.10032C9.85676 9.17012 9.86216 9.23908 9.87264 9.30672L13.7673 5.4798C13.7344 5.47708 13.7012 5.47569 13.6675 5.47569H9.85676V4Z" fill="black"/>
+<path d="M6.00752 11.6382L0.5 5.47504H2.71573L5.94924 9.09348V5.47504H7.6014V11.0298C7.6014 11.8682 6.56616 12.2634 6.00752 11.6382Z" fill="black"/>
+</g>
+</g>
+<defs>
+<clipPath id="clip0_2639_570">
+<rect width="16" height="16" fill="white" transform="translate(0.5)"/>
+</clipPath>
+<clipPath id="clip1_2639_570">
+<rect width="16" height="8" fill="white" transform="translate(0.5 4)"/>
+</clipPath>
+</defs>
+</svg>

assets/icons/arrow_down10.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down10-icon lucide-arrow-down-1-0"><path d="m3 16 4 4 4-4"/><path d="M7 20V4"/><path d="M17 10V4h-2"/><path d="M15 10h4"/><rect x="15" y="14" width="4" height="6" ry="2"/></svg>

assets/icons/arrow_up_alt.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 3.5L12.5 8M8 3.5L3.5 8M8 3.5V12.5" stroke="black" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/blocks.svg 🔗

@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-blocks"><rect width="7" height="7" x="14" y="3" rx="1"/><path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-blocks-icon lucide-blocks"><rect width="7" height="7" x="14" y="3" rx="1"/><path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/></svg>

assets/icons/bolt.svg 🔗

@@ -1,3 +1,3 @@
-<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4.76019 3.50003H6.50231C6.71012 3.50003 6.89761 3.62971 6.95698 3.82346C7.04292 4.01876 6.98823 4.23906 6.83199 4.37656L2.83214 7.87643C2.65558 8.02954 2.39731 8.04204 2.20857 7.90455C2.01967 7.76705 1.95092 7.51706 2.04295 7.30301L3.24462 4.49999H1.48844C1.29423 4.49999 1.10767 4.37031 1.0344 4.17657C0.961132 3.98126 1.01643 3.76096 1.17323 3.62346L5.17261 0.123753C5.34917 -0.0299914 5.60697 -0.0417097 5.79603 0.0954726C5.98508 0.232749 6.05383 0.482177 5.96165 0.69695L4.76013 3.49981L4.76019 3.50003Z" fill="white"/>
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.3 1.75L3 7.35H5.8L4.7 12.25L11 6.65H8.2L9.3 1.75Z" stroke="black" stroke-width="1.25" stroke-linejoin="round"/>
 </svg>

assets/icons/bolt_filled.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.76019 3.50003H6.50231C6.71012 3.50003 6.89761 3.62971 6.95698 3.82346C7.04292 4.01876 6.98823 4.23906 6.83199 4.37656L2.83214 7.87643C2.65558 8.02954 2.39731 8.04204 2.20857 7.90455C2.01967 7.76705 1.95092 7.51706 2.04295 7.30301L3.24462 4.49999H1.48844C1.29423 4.49999 1.10767 4.37031 1.0344 4.17657C0.961132 3.98126 1.01643 3.76096 1.17323 3.62346L5.17261 0.123753C5.34917 -0.0299914 5.60697 -0.0417097 5.79603 0.0954726C5.98508 0.232749 6.05383 0.482177 5.96165 0.69695L4.76013 3.49981L4.76019 3.50003Z" fill="white"/>
+</svg>

assets/icons/bolt_filled_alt.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.75776 5.50003H8.49988C8.70769 5.50003 8.89518 5.62971 8.95455 5.82346C9.04049 6.01876 8.9858 6.23906 8.82956 6.37656L4.82971 9.87643C4.65315 10.0295 4.39488 10.042 4.20614 9.90455C4.01724 9.76705 3.94849 9.51706 4.04052 9.30301L5.24219 6.49999H3.48601C3.2918 6.49999 3.10524 6.37031 3.03197 6.17657C2.9587 5.98126 3.014 5.76096 3.1708 5.62346L7.17018 2.12375C7.34674 1.97001 7.60454 1.95829 7.7936 2.09547C7.98265 2.23275 8.0514 2.48218 7.95922 2.69695L6.75776 5.50003Z" fill="black"/>
+</svg>

assets/icons/circle_help.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-help-icon lucide-circle-help"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>

assets/icons/cursor_i_beam.svg 🔗

@@ -1,5 +1,4 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M17 20H16C14.9391 20 13.9217 19.6629 13.1716 19.0627C12.4214 18.4626 12 17.6487 12 16.8V7.2C12 6.35131 12.4214 5.53737 13.1716 4.93726C13.9217 4.33714 14.9391 4 16 4H17" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7 20H8C9.06087 20 10.0783 19.5786 10.8284 18.8284C11.5786 18.0783 12 17.0609 12 16V15" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M7 4H8C9.06087 4 10.0783 4.42143 10.8284 5.17157C11.5786 5.92172 12 6.93913 12 8V9" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 13H10.4C9.76346 13 9.15302 12.7893 8.70296 12.4142C8.25284 12.0391 8 11.5304 8 11V5C8 4.46957 8.25284 3.96086 8.70296 3.58579C9.15302 3.21071 9.76346 3 10.4 3H11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 13H5.6C6.23654 13 6.84698 12.7893 7.29704 12.4142C7.74716 12.0391 8 11.5304 8 11V5C8 4.46957 7.74716 3.96086 7.29704 3.58579C6.84698 3.21071 6.23654 3 5.6 3H5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
 </svg>

assets/icons/list_todo.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-todo-icon lucide-list-todo"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg>

assets/icons/lsp_debug.svg 🔗

@@ -0,0 +1,12 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6 3L7 4" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9 4L10 3" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.002 6V5.51658C5.98992 5.32067 6.03266 5.12502 6.12762 4.94143C6.22259 4.75784 6.36781 4.59012 6.55453 4.44839C6.74125 4.30666 6.9656 4.19386 7.21403 4.1168C7.46246 4.03973 7.72983 4 8 4C8.27017 4 8.53754 4.03973 8.78597 4.1168C9.0344 4.19386 9.25875 4.30666 9.44547 4.44839C9.63219 4.59012 9.77741 4.75784 9.87238 4.94143C9.96734 5.12502 10.0101 5.32067 9.998 5.51658V6" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 13C6.35 13 5 11.5462 5 9.76923V8.15385C5 7.58261 5.21071 7.03477 5.58579 6.63085C5.96086 6.22692 6.46957 6 7 6H9C9.53043 6 10.0391 6.22692 10.4142 6.63085C10.7893 7.03477 11 7.58261 11 8.15385V9.76923C11 11.5462 9.65 13 8 13Z" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 6.16663C3.90652 6.06663 3 5.21663 3 4.16663" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 9H3" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3 13C3 11.95 3.89474 11.05 5 11" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13 4C13 5.05 12.0857 5.9 11 6" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13 9H11" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11 11C12.1053 11.05 13 11.95 13 13" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/lsp_restart.svg 🔗

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.84265 10.7778C4.39206 11.6001 5.17295 12.241 6.08658 12.6194C7.00021 12.9978 8.00555 13.0969 8.97545 12.9039C9.94535 12.711 10.8363 12.2348 11.5355 11.5355C12.2348 10.8363 12.711 9.94535 12.9039 8.97545C13.0969 8.00555 12.9978 7.00021 12.6194 6.08658C12.241 5.17295 11.6001 4.39206 10.7778 3.84265C9.9556 3.29324 8.9889 3 8 3C6.60219 3.00526 5.26054 3.55068 4.25556 4.52222L3 5.77778" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3 3V6H6" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/lsp_stop.svg 🔗

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 13C10.7614 13 13 10.7614 13 8C13 5.23858 10.7614 3 8 3C5.23858 3 3 5.23858 3 8C3 10.7614 5.23858 13 8 13Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5 5L11 11" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/play_alt.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4 3L13 8L4 13V3Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/play_bug.svg 🔗

@@ -0,0 +1,8 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4 12C2.35977 11.85 1 10.575 1 9" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M1.00875 15.2C1.00875 13.625 0.683456 12.275 4.00001 12.2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7 9C7 10.575 5.62857 11.85 4 12" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 12.2C6.98117 12.2 7 13.625 7 15.2" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<rect x="2.5" y="9" width="3" height="6" rx="1.5" fill="black"/>
+<path d="M9 10L13 8L4 3V7.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/scroll_text.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scroll-text-icon lucide-scroll-text"><path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/></svg>

assets/icons/sliders.svg 🔗

@@ -1,3 +1,8 @@
-<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">

assets/icons/split_alt.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-split-icon lucide-split"><path d="M16 3h5v5"/><path d="M8 3H3v5"/><path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"/><path d="m15 9 6-6"/></svg>

assets/icons/star_filled.svg 🔗

@@ -1 +1,3 @@
-<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7.22303 0.665992C7.32551 0.419604 7.67454 0.419604 7.77702 0.665992L9.41343 4.60039C9.45663 4.70426 9.55432 4.77523 9.66645 4.78422L13.914 5.12475C14.18 5.14607 14.2878 5.47802 14.0852 5.65162L10.849 8.42374C10.7636 8.49692 10.7263 8.61176 10.7524 8.72118L11.7411 12.866C11.803 13.1256 11.5206 13.3308 11.2929 13.1917L7.6564 10.9705C7.5604 10.9119 7.43965 10.9119 7.34365 10.9705L3.70718 13.1917C3.47945 13.3308 3.19708 13.1256 3.25899 12.866L4.24769 8.72118C4.2738 8.61176 4.23648 8.49692 4.15105 8.42374L0.914889 5.65162C0.712228 5.47802 0.820086 5.14607 1.08608 5.12475L5.3336 4.78422C5.44573 4.77523 5.54342 4.70426 5.58662 4.60039L7.22303 0.665992Z" fill="currentColor"></path></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">

assets/icons/zed_assistant.svg 🔗

@@ -1,5 +1,5 @@
-<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7 1.75L5.88467 5.14092C5.82759 5.31446 5.73055 5.47218 5.60136 5.60136C5.47218 5.73055 5.31446 5.82759 5.14092 5.88467L1.75 7L5.14092 8.11533C5.31446 8.17241 5.47218 8.26945 5.60136 8.39864C5.73055 8.52782 5.82759 8.68554 5.88467 8.85908L7 12.25L8.11533 8.85908C8.17241 8.68554 8.26945 8.52782 8.39864 8.39864C8.52782 8.26945 8.68554 8.17241 8.85908 8.11533L12.25 7L8.85908 5.88467C8.68554 5.82759 8.52782 5.73055 8.39864 5.60136C8.26945 5.47218 8.17241 5.31446 8.11533 5.14092L7 1.75Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M2.91667 1.75V4.08333M1.75 2.91667H4.08333" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11.0833 9.91667V12.25M9.91667 11.0833H12.25" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 2L6.72534 5.87534C6.6601 6.07367 6.5492 6.25392 6.40155 6.40155C6.25392 6.5492 6.07367 6.6601 5.87534 6.72534L2 8L5.87534 9.27466C6.07367 9.3399 6.25392 9.4508 6.40155 9.59845C6.5492 9.74608 6.6601 9.92633 6.72534 10.1247L8 14L9.27466 10.1247C9.3399 9.92633 9.4508 9.74608 9.59845 9.59845C9.74608 9.4508 9.92633 9.3399 10.1247 9.27466L14 8L10.1247 6.72534C9.92633 6.6601 9.74608 6.5492 9.59845 6.40155C9.4508 6.25392 9.3399 6.07367 9.27466 5.87534L8 2Z" fill="black" fill-opacity="0.15" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3.33334 2V4.66666M2 3.33334H4.66666" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12.6665 11.3333V14M11.3333 12.6666H13.9999" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
 </svg>

assets/icons/zed_burn_mode.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.99207 8.14741C5.37246 8.14741 5.73726 7.9963 6.00623 7.72733C6.27521 7.45836 6.42631 7.09355 6.42631 6.71317C6.42631 5.92147 6.13946 5.56578 5.85262 4.99208C5.23761 3.76265 5.72411 2.66631 7.00001 1.5499C7.28686 2.98414 8.1474 4.36101 9.2948 5.27893C10.4422 6.19684 11.0159 7.28687 11.0159 8.43426C11.0159 8.96163 10.912 9.48384 10.7102 9.97107C10.5084 10.4583 10.2126 10.901 9.83967 11.2739C9.46676 11.6468 9.02405 11.9426 8.53682 12.1444C8.04959 12.3463 7.52738 12.4501 7.00001 12.4501C6.47264 12.4501 5.95043 12.3463 5.4632 12.1444C4.97597 11.9426 4.53326 11.6468 4.16035 11.2739C3.78745 10.901 3.49164 10.4583 3.28982 9.97107C3.088 9.48384 2.98413 8.96163 2.98413 8.43426C2.98413 7.77279 3.23254 7.1182 3.55783 6.71317C3.55783 7.09355 3.70894 7.45836 3.97791 7.72733C4.24688 7.9963 4.61169 8.14741 4.99207 8.14741Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/zed_burn_mode_on.svg 🔗

@@ -0,0 +1,13 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_2595_5640)">
+<path d="M4.99207 8.14741C5.37246 8.14741 5.73726 7.9963 6.00623 7.72733C6.27521 7.45836 6.42631 7.09355 6.42631 6.71317C6.42631 5.92147 6.13946 5.56578 5.85262 4.99208C5.23761 3.76265 5.72411 2.66631 7.00001 1.5499C7.28686 2.98414 8.1474 4.36101 9.2948 5.27893C10.4422 6.19684 11.0159 7.28687 11.0159 8.43426C11.0159 8.96163 10.912 9.48384 10.7102 9.97107C10.5084 10.4583 10.2126 10.901 9.83967 11.2739C9.46676 11.6468 9.02405 11.9426 8.53682 12.1444C8.04959 12.3463 7.52738 12.4501 7.00001 12.4501C6.47264 12.4501 5.95043 12.3463 5.4632 12.1444C4.97597 11.9426 4.53326 11.6468 4.16035 11.2739C3.78745 10.901 3.49164 10.4583 3.28982 9.97107C3.088 9.48384 2.98413 8.96163 2.98413 8.43426C2.98413 7.77279 3.23254 7.1182 3.55783 6.71317C3.55783 7.09355 3.70894 7.45836 3.97791 7.72733C4.24688 7.9963 4.61169 8.14741 4.99207 8.14741Z" fill="black" fill-opacity="0.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2 4C2.55228 4 3 3.55228 3 3C3 2.44772 2.55228 2 2 2C1.44772 2 1 2.44772 1 3C1 3.55228 1.44772 4 2 4Z" fill="black"/>
+<path d="M10 2C10.5523 2 11 1.55228 11 1C11 0.44772 10.5523 0 10 0C9.44772 0 9 0.44772 9 1C9 1.55228 9.44772 2 10 2Z" fill="black"/>
+<path d="M13 5C13.5522 5 14 4.55228 14 4C14 3.44772 13.5522 3 13 3C12.4478 3 12 3.44772 12 4C12 4.55228 12.4478 5 13 5Z" fill="black"/>
+</g>
+<defs>
+<clipPath id="clip0_2595_5640">
+<rect width="14" height="14" fill="white"/>
+</clipPath>
+</defs>
+</svg>

assets/icons/zed_max_mode.svg 🔗

@@ -1,14 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_2489_484)">
-<path d="M11 8.9V11C8.51716 11 7.48284 11 5 11V10.4L11 5.6V5H5V7.1" stroke="black" stroke-width="1.5"/>
-<path d="M1.5 5.5V1.5H5" stroke="black" stroke-opacity="0.5" stroke-width="1.5"/>
-<path d="M14.5 5.5V1.5H11" stroke="black" stroke-opacity="0.5" stroke-width="1.5"/>
-<path d="M1.5 10.5V14.5H5" stroke="black" stroke-opacity="0.5" stroke-width="1.5"/>
-<path d="M14.5 10.5V14.5H11" stroke="black" stroke-opacity="0.5" stroke-width="1.5"/>
-</g>
-<defs>
-<clipPath id="clip0_2489_484">
-<rect width="16" height="16" fill="white"/>
-</clipPath>
-</defs>
-</svg>

assets/icons/zed_mcp_custom.svg 🔗

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.3" x="2" y="2" width="12" height="12" rx="2" stroke="black" stroke-width="1.5"/>

assets/icons/zed_mcp_extension.svg 🔗

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.3" x="2" y="2" width="12" height="12" rx="2" stroke="black" stroke-width="1.5"/>

assets/images/debugger_grid.svg 🔗

@@ -0,0 +1,890 @@
+<svg width="400" height="92" viewBox="0 0 400 92" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_532_2318)">
+<mask id="mask0_532_2318" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="400" height="92">
+<path d="M400 0H0V92H400V0Z" fill="white"/>
+</mask>
+<g mask="url(#mask0_532_2318)">
+<g clip-path="url(#clip1_532_2318)">
+<path d="M62.1878 1.24874C60.4044 1.12767 59.0444 -0.430533 59.1654 -2.21393L59.2755 -3.8352C59.3144 -4.40851 59.5795 -4.94289 60.0124 -5.32076C60.4453 -5.69863 61.0106 -5.88906 61.5839 -5.85013L63.7456 -5.70338C64.3189 -5.66446 64.8533 -5.39938 65.2312 -4.96647C65.6091 -4.53355 65.7995 -3.96825 65.7606 -3.39494L65.6505 -1.77367C65.5294 0.00972748 63.9712 1.36981 62.1878 1.24874Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M62.1879 1.24869L62.5181 -3.61511" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M57.2873 1.45893C57.3644 0.324045 58.3491 -0.586346 59.4877 -0.563343" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M65.108 -0.181763C66.2393 -0.0506748 67.0919 0.984456 67.0149 2.11934" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip2_532_2318)">
+<path d="M87.1305 2.94204C85.3471 2.82097 83.987 1.26277 84.108 -0.52063L84.2181 -2.1419C84.257 -2.71521 84.5221 -3.24959 84.955 -3.62746C85.3879 -4.00534 85.9532 -4.19576 86.5266 -4.15684L88.6883 -4.01008C89.2616 -3.97116 89.7959 -3.70608 90.1738 -3.27317C90.5517 -2.84025 90.7421 -2.27495 90.7032 -1.70164L90.5931 -0.0803691C90.4721 1.70303 88.9139 3.06311 87.1305 2.94204Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M87.1305 2.94199L87.4607 -1.92181" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M82.23 3.15223C82.307 2.01734 83.2918 1.10695 84.4303 1.12996" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M92.7915 -0.474035L90.6298 -0.620789" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M90.0507 1.51154C91.1819 1.64262 92.0346 2.67775 91.9575 3.81264" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip3_532_2318)">
+<path d="M112.073 4.63534C110.29 4.51426 108.929 2.95606 109.051 1.17267L109.161 -0.4486C109.2 -1.02192 109.465 -1.55629 109.898 -1.93416C110.33 -2.31204 110.896 -2.50246 111.469 -2.46354L113.631 -2.31678C114.204 -2.27786 114.738 -2.01279 115.116 -1.57987C115.494 -1.14695 115.685 -0.581655 115.646 -0.00833894L115.536 1.61293C115.415 3.39632 113.856 4.75641 112.073 4.63534Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M112.073 4.63529L112.403 -0.228516" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M109.087 0.632227L106.926 0.485474" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M107.172 4.84553C107.25 3.71064 108.234 2.80025 109.373 2.82325" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M117.734 1.21926L115.572 1.07251" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M114.993 3.20483C116.124 3.33592 116.977 4.37105 116.9 5.50594" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip4_532_2318)">
+<path d="M135.867 -0.736649L135.903 -1.27707C135.908 -1.49674 135.958 -1.71311 136.049 -1.91312C136.14 -2.11313 136.27 -2.29262 136.433 -2.44078C136.595 -2.58894 136.786 -2.70268 136.993 -2.77515C137.2 -2.84762 137.42 -2.8773 137.64 -2.86242C137.859 -2.84754 138.073 -2.78839 138.269 -2.68855C138.464 -2.58872 138.638 -2.45025 138.779 -2.28153C138.919 -2.1128 139.024 -1.9173 139.087 -1.70683C139.151 -1.49636 139.17 -1.27528 139.146 -1.05694L139.109 -0.516519" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M137.016 6.32869C135.232 6.20762 133.872 4.64942 133.993 2.86603L134.103 1.24476C134.142 0.671444 134.407 0.13707 134.84 -0.240804C135.273 -0.618678 135.838 -0.809099 136.412 -0.770178L138.573 -0.623424C139.147 -0.584503 139.681 -0.319426 140.059 0.113491C140.437 0.546408 140.627 1.1117 140.588 1.68502L140.478 3.30629C140.357 5.08968 138.799 6.44977 137.016 6.32869Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M137.016 6.32865L137.346 1.46484" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M134.463 0.183352C133.427 0.00445886 132.625 -0.972961 132.702 -2.10785" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M134.03 2.32559L131.868 2.17883" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M132.115 6.53889C132.192 5.404 133.177 4.49361 134.315 4.51661" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M142.413 -1.44856C142.336 -0.313668 141.409 0.546349 140.375 0.584726" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M142.677 2.91262L140.515 2.76587" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M139.936 4.89819C141.067 5.02928 141.92 6.06441 141.843 7.1993" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip5_532_2318)">
+<path d="M160.457 -1.85242L161.404 -0.767448" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M163.695 -0.611874L164.78 -1.55889" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M160.809 0.956649L160.846 0.416226C160.851 0.196553 160.9 -0.0198148 160.991 -0.219821C161.082 -0.419827 161.213 -0.599325 161.375 -0.747482C161.538 -0.895638 161.728 -1.00938 161.936 -1.08185C162.143 -1.15432 162.363 -1.184 162.582 -1.16912C162.801 -1.15424 163.015 -1.09509 163.211 -0.995255C163.407 -0.895417 163.58 -0.756956 163.721 -0.588227C163.862 -0.419498 163.967 -0.224001 164.03 -0.0135316C164.093 0.196937 164.113 0.418014 164.088 0.636357L164.052 1.17678" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M161.958 8.02199C160.175 7.90092 158.815 6.34272 158.936 4.55933L159.046 2.93806C159.085 2.36474 159.35 1.83037 159.783 1.45249C160.216 1.07462 160.781 0.884199 161.354 0.923121L163.516 1.06987C164.089 1.1088 164.624 1.37387 165.002 1.80679C165.379 2.23971 165.57 2.805 165.531 3.37832L165.421 4.99959C165.3 6.78298 163.742 8.14306 161.958 8.02199Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M161.958 8.02195L162.288 3.15814" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M159.406 1.87665C158.37 1.69776 157.568 0.720337 157.645 -0.414551" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M158.972 4.01888L156.811 3.87213" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M157.058 8.23219C157.135 7.0973 158.12 6.18691 159.258 6.20991" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M167.356 0.244742C167.279 1.37963 166.352 2.23965 165.318 2.27802" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M167.619 4.60592L165.458 4.45917" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M164.878 6.59149C166.01 6.72258 166.862 7.75771 166.785 8.8926" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip6_532_2318)">
+<path d="M185.399 -0.159119L186.346 0.92585" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M188.638 1.08142L189.723 0.134404" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M185.752 2.64995L185.788 2.10952C185.793 1.88985 185.843 1.67348 185.934 1.47348C186.025 1.27347 186.156 1.09397 186.318 0.945817C186.48 0.79766 186.671 0.683916 186.878 0.611449C187.086 0.538982 187.306 0.509294 187.525 0.524177C187.744 0.53906 187.958 0.598205 188.154 0.698043C188.349 0.797881 188.523 0.936343 188.664 1.10507C188.804 1.2738 188.91 1.4693 188.973 1.67977C189.036 1.89024 189.056 2.11131 189.031 2.32966L188.994 2.87008" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M186.901 9.71529C185.117 9.59422 183.757 8.03602 183.878 6.25262L183.988 4.63136C184.027 4.05804 184.292 3.52367 184.725 3.14579C185.158 2.76792 185.724 2.5775 186.297 2.61642L188.459 2.76317C189.032 2.80209 189.566 3.06717 189.944 3.50009C190.322 3.933 190.512 4.4983 190.474 5.07162L190.363 6.69289C190.242 8.47628 188.684 9.83636 186.901 9.71529Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M186.901 9.71525L187.231 4.85144" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M184.348 3.56995C183.313 3.39106 182.51 2.41364 182.587 1.27875" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M183.915 5.71218L181.753 5.56543" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M182 9.92549C182.077 8.7906 183.062 7.88021 184.201 7.90321" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M192.299 1.93804C192.222 3.07293 191.295 3.93295 190.26 3.97132" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M192.562 6.29922L190.4 6.15247" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M189.821 8.28479C190.952 8.41588 191.805 9.45101 191.728 10.5859" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip7_532_2318)">
+<path d="M210.342 1.53418L211.289 2.61915" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M213.58 2.77472L214.665 1.8277" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M210.694 4.34325L210.731 3.80282C210.736 3.58315 210.786 3.36678 210.877 3.16678C210.968 2.96677 211.098 2.78727 211.26 2.63911C211.423 2.49096 211.613 2.37721 211.821 2.30475C212.028 2.23228 212.248 2.20259 212.467 2.21748C212.687 2.23236 212.9 2.2915 213.096 2.39134C213.292 2.49118 213.465 2.62964 213.606 2.79837C213.747 2.9671 213.852 3.1626 213.915 3.37307C213.978 3.58353 213.998 3.80461 213.973 4.02295L213.937 4.56338" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M211.843 11.4086C210.06 11.2875 208.7 9.72932 208.821 7.94592L208.931 6.32465C208.97 5.75134 209.235 5.21697 209.668 4.83909C210.101 4.46122 210.666 4.2708 211.239 4.30972L213.401 4.45647C213.974 4.49539 214.509 4.76047 214.887 5.19339C215.265 5.6263 215.455 6.1916 215.416 6.76492L215.306 8.38618C215.185 10.1696 213.627 11.5297 211.843 11.4086Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M211.843 11.4085L212.174 6.54474" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M209.291 5.26325C208.255 5.08435 207.453 4.10693 207.53 2.97205" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M208.858 7.40548L206.696 7.25873" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M206.943 11.6188C207.02 10.4839 208.005 9.5735 209.143 9.59651" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M217.241 3.63134C217.164 4.76623 216.237 5.62624 215.203 5.66462" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M217.504 7.99252L215.343 7.84576" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M214.764 9.97809C215.895 10.1092 216.748 11.1443 216.67 12.2792" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip8_532_2318)">
+<path d="M235.285 3.22748L236.232 4.31245" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M238.523 4.46802L239.608 3.521" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M235.637 6.03654L235.674 5.49612C235.679 5.27645 235.728 5.06008 235.819 4.86007C235.91 4.66007 236.041 4.48057 236.203 4.33241C236.365 4.18426 236.556 4.07051 236.763 3.99805C236.971 3.92558 237.191 3.89589 237.41 3.91077C237.629 3.92566 237.843 3.9848 238.039 4.08464C238.235 4.18448 238.408 4.32294 238.549 4.49167C238.69 4.6604 238.795 4.85589 238.858 5.06636C238.921 5.27683 238.941 5.49791 238.916 5.71625L238.879 6.25667" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M236.786 13.1019C235.003 12.9808 233.642 11.4226 233.764 9.63922L233.874 8.01795C233.913 7.44464 234.178 6.91026 234.611 6.53239C235.043 6.15452 235.609 5.96409 236.182 6.00302L238.344 6.14977C238.917 6.18869 239.451 6.45377 239.829 6.88668C240.207 7.3196 240.398 7.8849 240.359 8.45821L240.249 10.0795C240.128 11.8629 238.569 13.223 236.786 13.1019Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M236.786 13.1018L237.116 8.23804" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M234.233 6.95655C233.198 6.77765 232.395 5.80023 232.472 4.66534" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M233.8 9.09878L231.639 8.95203" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M231.885 13.3121C231.963 12.1772 232.947 11.2668 234.086 11.2898" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M242.184 5.32464C242.107 6.45953 241.18 7.31954 240.146 7.35792" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M242.447 9.68582L240.285 9.53906" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M239.706 11.6714C240.837 11.8025 241.69 12.8376 241.613 13.9725" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip9_532_2318)">
+<path d="M260.227 4.92084L261.174 6.00581" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M263.466 6.16138L264.551 5.21436" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M260.579 7.7299L260.616 7.18948C260.621 6.96981 260.671 6.75344 260.762 6.55343C260.853 6.35343 260.983 6.17393 261.146 6.02577C261.308 5.87762 261.498 5.76387 261.706 5.6914C261.913 5.61894 262.133 5.58925 262.353 5.60413C262.572 5.61902 262.786 5.67816 262.981 5.778C263.177 5.87784 263.351 6.0163 263.491 6.18503C263.632 6.35376 263.737 6.54925 263.8 6.75972C263.864 6.97019 263.883 7.19127 263.859 7.40961L263.822 7.95003" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M261.729 14.7952C259.945 14.6742 258.585 13.116 258.706 11.3326L258.816 9.71131C258.855 9.138 259.12 8.60362 259.553 8.22575C259.986 7.84788 260.551 7.65745 261.125 7.69638L263.286 7.84313C263.86 7.88205 264.394 8.14713 264.772 8.58004C265.15 9.01296 265.34 9.57826 265.301 10.1516L265.191 11.7728C265.07 13.5562 263.512 14.9163 261.729 14.7952Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M261.729 14.7952L262.059 9.9314" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M259.176 8.6499C258.14 8.47101 257.338 7.49359 257.415 6.3587" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M258.743 10.7921L256.581 10.6454" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M256.828 15.0054C256.905 13.8706 257.89 12.9602 259.028 12.9832" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M267.126 7.018C267.049 8.15288 266.122 9.0129 265.088 9.05128" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M267.39 11.3792L265.228 11.2324" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M264.649 13.3647C265.78 13.4958 266.633 14.531 266.556 15.6659" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip10_532_2318)">
+<path d="M285.17 6.61414L286.117 7.6991" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M288.408 7.85468L289.493 6.90766" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M285.522 9.4232L285.559 8.88278C285.564 8.66311 285.613 8.44674 285.704 8.24673C285.795 8.04673 285.926 7.86723 286.088 7.71907C286.25 7.57091 286.441 7.45717 286.648 7.3847C286.856 7.31224 287.076 7.28255 287.295 7.29743C287.514 7.31231 287.728 7.37146 287.924 7.4713C288.12 7.57114 288.293 7.7096 288.434 7.87833C288.575 8.04705 288.68 8.24255 288.743 8.45302C288.806 8.66349 288.826 8.88457 288.801 9.10291L288.765 9.64333" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M286.671 16.4885C284.888 16.3675 283.528 14.8093 283.649 13.0259L283.759 11.4046C283.798 10.8313 284.063 10.2969 284.496 9.91905C284.929 9.54117 285.494 9.35075 286.067 9.38967L288.229 9.53643C288.802 9.57535 289.337 9.84042 289.714 10.2733C290.092 10.7063 290.283 11.2716 290.244 11.8449L290.134 13.4661C290.013 15.2495 288.455 16.6096 286.671 16.4885Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M286.671 16.4885L287.001 11.6247" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M284.119 10.3432C283.083 10.1643 282.281 9.18689 282.358 8.052" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M283.685 12.4854L281.524 12.3387" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M281.771 16.6987C281.848 15.5639 282.832 14.6535 283.971 14.6765" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M292.069 8.7113C291.992 9.84618 291.065 10.7062 290.031 10.7446" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M292.332 13.0725L290.17 12.9257" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M289.591 15.058C290.723 15.1891 291.575 16.2243 291.498 17.3592" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip11_532_2318)">
+<path d="M310.112 8.30743L311.059 9.3924" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M313.351 9.54798L314.436 8.60096" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M310.465 11.1165L310.501 10.5761C310.506 10.3564 310.556 10.14 310.647 9.94003C310.738 9.74002 310.868 9.56053 311.031 9.41237C311.193 9.26421 311.384 9.15047 311.591 9.078C311.799 9.00553 312.018 8.97585 312.238 8.99073C312.457 9.00561 312.671 9.06476 312.867 9.1646C313.062 9.26443 313.236 9.4029 313.377 9.57162C313.517 9.74035 313.622 9.93585 313.686 10.1463C313.749 10.3568 313.769 10.5779 313.744 10.7962L313.707 11.3366" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M311.614 18.1818C309.83 18.0608 308.47 16.5026 308.591 14.7192L308.701 13.0979C308.74 12.5246 309.005 11.9902 309.438 11.6123C309.871 11.2345 310.437 11.0441 311.01 11.083L313.172 11.2297C313.745 11.2686 314.279 11.5337 314.657 11.9666C315.035 12.3996 315.225 12.9649 315.186 13.5382L315.076 15.1594C314.955 16.9428 313.397 18.3029 311.614 18.1818Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M311.614 18.1818L311.944 13.318" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M309.061 12.0365C308.025 11.8576 307.223 10.8802 307.3 9.7453" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M308.628 14.1787L306.466 14.032" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M306.713 18.392C306.79 17.2572 307.775 16.3468 308.914 16.3698" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M317.012 10.4046C316.935 11.5395 316.008 12.3995 314.973 12.4379" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M317.275 14.7658L315.113 14.619" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M314.534 16.7513C315.665 16.8824 316.518 17.9176 316.441 19.0524" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip12_532_2318)">
+<path d="M335.055 10.0007L336.002 11.0857" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M338.293 11.2413L339.378 10.2943" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M335.407 12.8098L335.444 12.2694C335.449 12.0497 335.498 11.8333 335.589 11.6333C335.68 11.4333 335.811 11.2538 335.973 11.1057C336.136 10.9575 336.326 10.8438 336.534 10.7713C336.741 10.6988 336.961 10.6691 337.18 10.684C337.399 10.6989 337.613 10.7581 337.809 10.8579C338.005 10.9577 338.178 11.0962 338.319 11.2649C338.46 11.4337 338.565 11.6291 338.628 11.8396C338.691 12.0501 338.711 12.2712 338.686 12.4895L338.65 13.0299" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M336.556 19.8751C334.773 19.7541 333.413 18.1959 333.534 16.4125L333.644 14.7912C333.683 14.2179 333.948 13.6835 334.381 13.3056C334.814 12.9278 335.379 12.7373 335.952 12.7763L338.114 12.923C338.687 12.9619 339.222 13.227 339.6 13.6599C339.978 14.0929 340.168 14.6582 340.129 15.2315L340.019 16.8527C339.898 18.6361 338.34 19.9962 336.556 19.8751Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M336.556 19.8751L336.886 15.0113" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M334.004 13.7298C332.968 13.5509 332.166 12.5735 332.243 11.4386" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M333.571 15.872L331.409 15.7253" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M331.656 20.0853C331.733 18.9504 332.718 18.0401 333.856 18.0631" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M341.954 12.0979C341.877 13.2328 340.95 14.0928 339.916 14.1312" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M342.217 16.4591L340.056 16.3123" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M339.477 18.4446C340.608 18.5757 341.46 19.6109 341.383 20.7457" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip13_532_2318)">
+<path d="M359.998 11.694L360.945 12.779" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M363.236 12.9346L364.321 11.9876" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M360.35 14.5031L360.386 13.9627C360.392 13.743 360.441 13.5266 360.532 13.3266C360.623 13.1266 360.754 12.9471 360.916 12.799C361.078 12.6508 361.269 12.5371 361.476 12.4646C361.684 12.3921 361.904 12.3624 362.123 12.3773C362.342 12.3922 362.556 12.4514 362.752 12.5512C362.947 12.651 363.121 12.7895 363.262 12.9582C363.402 13.1269 363.508 13.3224 363.571 13.5329C363.634 13.7434 363.654 13.9645 363.629 14.1828L363.592 14.7232" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M361.499 21.5684C359.715 21.4474 358.355 19.8892 358.476 18.1058L358.587 16.4845C358.625 15.9112 358.891 15.3768 359.323 14.9989C359.756 14.6211 360.322 14.4306 360.895 14.4696L363.057 14.6163C363.63 14.6552 364.164 14.9203 364.542 15.3532C364.92 15.7862 365.111 16.3515 365.072 16.9248L364.962 18.546C364.84 20.3294 363.282 21.6895 361.499 21.5684Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M361.499 21.5684L361.829 16.7046" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M358.946 15.4231C357.911 15.2442 357.108 14.2668 357.185 13.1319" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M358.513 17.5653L356.351 17.4186" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M356.598 21.7786C356.675 20.6437 357.66 19.7334 358.799 19.7564" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M366.897 13.7912C366.82 14.9261 365.893 15.7861 364.859 15.8245" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M367.16 18.1524L364.998 18.0056" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M364.419 20.1379C365.55 20.269 366.403 21.3042 366.326 22.439" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip14_532_2318)">
+<path d="M384.94 13.3874L385.887 14.4724" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M388.179 14.6279L389.264 13.6809" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M385.292 16.1965L385.329 15.656C385.334 15.4364 385.384 15.22 385.475 15.02C385.566 14.82 385.696 14.6405 385.859 14.4923C386.021 14.3442 386.211 14.2304 386.419 14.158C386.626 14.0855 386.846 14.0558 387.065 14.0707C387.285 14.0856 387.499 14.1447 387.694 14.2446C387.89 14.3444 388.064 14.4829 388.204 14.6516C388.345 14.8203 388.45 15.0158 388.513 15.2263C388.576 15.4367 388.596 15.6578 388.572 15.8762L388.535 16.4166" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M386.441 23.2618C384.658 23.1407 383.298 21.5825 383.419 19.7991L383.529 18.1779C383.568 17.6045 383.833 17.0702 384.266 16.6923C384.699 16.3144 385.264 16.124 385.838 16.1629L387.999 16.3097C388.573 16.3486 389.107 16.6137 389.485 17.0466C389.863 17.4795 390.053 18.0448 390.014 18.6181L389.904 20.2394C389.783 22.0228 388.225 23.3829 386.441 23.2618Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M386.441 23.2618L386.772 18.3979" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M383.889 17.1165C382.853 16.9376 382.051 15.9601 382.128 14.8253" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M383.456 19.2587L381.294 19.1119" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M381.541 23.472C381.618 22.3371 382.603 21.4267 383.741 21.4497" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M391.839 15.4845C391.762 16.6194 390.835 17.4795 389.801 17.5178" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M392.103 19.8457L389.941 19.699" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M389.362 21.8313C390.493 21.9624 391.346 22.9975 391.269 24.1324" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip15_532_2318)">
+<path d="M59.2642 12.3261L60.2112 13.4111" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M62.5026 13.5667L63.5875 12.6196" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M59.6164 15.1352L59.6531 14.5948C59.6582 14.3751 59.7077 14.1587 59.7987 13.9587C59.8897 13.7587 60.0203 13.5792 60.1825 13.431C60.3448 13.2829 60.5354 13.1691 60.7429 13.0967C60.9503 13.0242 61.1703 12.9945 61.3895 13.0094C61.6087 13.0243 61.8227 13.0834 62.0184 13.1833C62.2141 13.2831 62.3876 13.4216 62.5284 13.5903C62.6691 13.759 62.7742 13.9545 62.8374 14.165C62.9005 14.3755 62.9203 14.5965 62.8957 14.8149L62.859 15.3553" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M60.7655 22.2005C58.9821 22.0794 57.622 20.5212 57.7431 18.7379L57.8531 17.1166C57.892 16.5433 58.1571 16.0089 58.59 15.631C59.023 15.2531 59.5883 15.0627 60.1616 15.1016L62.3233 15.2484C62.8966 15.2873 63.4309 15.5524 63.8088 15.9853C64.1867 16.4182 64.3771 16.9835 64.3382 17.5568L64.2281 19.1781C64.1071 20.9615 62.5489 22.3216 60.7655 22.2005Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M60.7655 22.2005L61.0957 17.3367" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M58.213 16.0552C57.1773 15.8763 56.375 14.8989 56.452 13.764" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M57.7797 18.1974L55.618 18.0507" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M55.865 22.4107C55.942 21.2758 56.9268 20.3654 58.0653 20.3884" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M66.1633 14.4233C66.0863 15.5582 65.1592 16.4182 64.1251 16.4566" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M66.4265 18.7844L64.2648 18.6377" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M63.6857 20.77C64.8169 20.9011 65.6696 21.9362 65.5925 23.0711" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip16_532_2318)">
+<path d="M84.2068 14.0194L85.1538 15.1044" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M87.4452 15.26L88.5302 14.3129" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M84.5591 16.8285L84.5958 16.2881C84.6008 16.0684 84.6503 15.852 84.7413 15.652C84.8323 15.452 84.9629 15.2725 85.1252 15.1243C85.2874 14.9762 85.478 14.8624 85.6855 14.79C85.8929 14.7175 86.1129 14.6878 86.3321 14.7027C86.5513 14.7176 86.7653 14.7767 86.961 14.8766C87.1568 14.9764 87.3302 15.1149 87.471 15.2836C87.6118 15.4523 87.7169 15.6478 87.78 15.8583C87.8431 16.0688 87.863 16.2898 87.8383 16.5082L87.8016 17.0486" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M85.7081 23.8938C83.9247 23.7727 82.5646 22.2145 82.6857 20.4312L82.7958 18.8099C82.8347 18.2366 83.0997 17.7022 83.5327 17.3243C83.9656 16.9464 84.5309 16.756 85.1042 16.7949L87.2659 16.9417C87.8392 16.9806 88.3736 17.2457 88.7515 17.6786C89.1293 18.1115 89.3197 18.6768 89.2808 19.2501L89.1708 20.8714C89.0497 22.6548 87.4915 24.0149 85.7081 23.8938Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M85.7081 23.8938L86.0383 19.03" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M83.1556 17.7485C82.1199 17.5696 81.3176 16.5922 81.3947 15.4573" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M82.7224 19.8907L80.5607 19.744" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M80.8076 24.104C80.8846 22.9691 81.8694 22.0587 83.008 22.0817" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M91.106 16.1166C91.0289 17.2515 90.1019 18.1115 89.0677 18.1499" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M91.3691 20.4777L89.2074 20.331" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M88.6283 22.4633C89.7595 22.5944 90.6122 23.6295 90.5351 24.7644" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip17_532_2318)">
+<path d="M109.149 15.7127L110.096 16.7977" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M112.388 16.9533L113.473 16.0062" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M109.502 18.5218L109.538 17.9814C109.543 17.7617 109.593 17.5453 109.684 17.3453C109.775 17.1453 109.905 16.9658 110.068 16.8176C110.23 16.6695 110.421 16.5557 110.628 16.4833C110.835 16.4108 111.055 16.3811 111.275 16.396C111.494 16.4109 111.708 16.47 111.904 16.5699C112.099 16.6697 112.273 16.8082 112.414 16.9769C112.554 17.1456 112.659 17.3411 112.723 17.5516C112.786 17.7621 112.805 17.9831 112.781 18.2015L112.744 18.7419" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M110.651 25.5871C108.867 25.466 107.507 23.9078 107.628 22.1245L107.738 20.5032C107.777 19.9299 108.042 19.3955 108.475 19.0176C108.908 18.6397 109.473 18.4493 110.047 18.4882L112.208 18.635C112.782 18.6739 113.316 18.939 113.694 19.3719C114.072 19.8048 114.262 20.3701 114.223 20.9434L114.113 22.5647C113.992 24.3481 112.434 25.7082 110.651 25.5871Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M110.651 25.5871L110.981 20.7233" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M108.098 19.4418C107.062 19.2629 106.26 18.2855 106.337 17.1506" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M107.665 21.584L105.503 21.4373" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M105.75 25.7973C105.827 24.6624 106.812 23.752 107.95 23.775" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M116.048 17.8099C115.971 18.9448 115.044 19.8048 114.01 19.8431" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M116.312 22.171L114.15 22.0243" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M113.571 24.1566C114.702 24.2877 115.555 25.3228 115.478 26.4577" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip18_532_2318)">
+<path d="M134.092 17.4061L135.039 18.491" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M137.33 18.6466L138.415 17.6996" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M134.444 20.2151L134.481 19.6747C134.486 19.455 134.535 19.2387 134.626 19.0387C134.717 18.8387 134.848 18.6592 135.01 18.511C135.173 18.3628 135.363 18.2491 135.571 18.1766C135.778 18.1042 135.998 18.0745 136.217 18.0894C136.436 18.1042 136.65 18.1634 136.846 18.2632C137.042 18.3631 137.215 18.5015 137.356 18.6703C137.497 18.839 137.602 19.0345 137.665 19.245C137.728 19.4554 137.748 19.6765 137.723 19.8948L137.687 20.4353" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M135.593 27.2805C133.81 27.1594 132.45 25.6012 132.571 23.8178L132.681 22.1965C132.72 21.6232 132.985 21.0889 133.418 20.711C133.851 20.3331 134.416 20.1427 134.989 20.1816L137.151 20.3284C137.724 20.3673 138.259 20.6324 138.637 21.0653C139.014 21.4982 139.205 22.0635 139.166 22.6368L139.056 24.2581C138.935 26.0415 137.377 27.4015 135.593 27.2805Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M135.593 27.2804L135.923 22.4166" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M133.041 21.1351C132.005 20.9562 131.203 19.9788 131.28 18.8439" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M132.607 23.2774L130.446 23.1306" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M130.693 27.4907C130.77 26.3558 131.755 25.4454 132.893 25.4684" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M140.991 19.5032C140.914 20.6381 139.987 21.4981 138.953 21.5365" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M141.254 23.8644L139.093 23.7177" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M138.513 25.85C139.645 25.9811 140.497 27.0162 140.42 28.1511" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip19_532_2318)">
+<path d="M159.035 19.0994L159.982 20.1843" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M162.273 20.3399L163.358 19.3929" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M159.387 21.9084L159.424 21.368C159.429 21.1483 159.478 20.932 159.569 20.732C159.66 20.532 159.791 20.3525 159.953 20.2043C160.115 20.0561 160.306 19.9424 160.513 19.8699C160.721 19.7975 160.941 19.7678 161.16 19.7827C161.379 19.7975 161.593 19.8567 161.789 19.9565C161.985 20.0564 162.158 20.1948 162.299 20.3636C162.44 20.5323 162.545 20.7278 162.608 20.9383C162.671 21.1487 162.691 21.3698 162.666 21.5881L162.629 22.1286" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M160.536 28.9738C158.752 28.8527 157.392 27.2945 157.513 25.5111L157.624 23.8898C157.662 23.3165 157.928 22.7822 158.36 22.4043C158.793 22.0264 159.359 21.836 159.932 21.8749L162.094 22.0217C162.667 22.0606 163.201 22.3257 163.579 22.7586C163.957 23.1915 164.148 23.7568 164.109 24.3301L163.999 25.9514C163.877 27.7348 162.319 29.0948 160.536 28.9738Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M160.536 28.9737L160.866 24.1099" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M157.983 22.8284C156.948 22.6495 156.145 21.6721 156.222 20.5372" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M157.55 24.9707L155.388 24.8239" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M155.635 29.184C155.712 28.0491 156.697 27.1387 157.836 27.1617" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M165.934 21.1965C165.857 22.3314 164.93 23.1914 163.896 23.2298" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M166.197 25.5577L164.035 25.4109" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M163.456 27.5433C164.587 27.6744 165.44 28.7095 165.363 29.8444" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip20_532_2318)">
+<path d="M183.977 20.7927L184.924 21.8776" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M187.216 22.0332L188.3 21.0862" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M184.329 23.6017L184.366 23.0613C184.371 22.8416 184.421 22.6253 184.512 22.4253C184.603 22.2253 184.733 22.0458 184.895 21.8976C185.058 21.7494 185.248 21.6357 185.456 21.5632C185.663 21.4908 185.883 21.4611 186.102 21.476C186.322 21.4908 186.536 21.55 186.731 21.6498C186.927 21.7497 187.101 21.8881 187.241 22.0569C187.382 22.2256 187.487 22.4211 187.55 22.6315C187.613 22.842 187.633 23.0631 187.609 23.2814L187.572 23.8219" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M185.478 30.6671C183.695 30.546 182.335 28.9878 182.456 27.2044L182.566 25.5831C182.605 25.0098 182.87 24.4754 183.303 24.0976C183.736 23.7197 184.301 23.5293 184.875 23.5682L187.036 23.715C187.61 23.7539 188.144 24.019 188.522 24.4519C188.9 24.8848 189.09 25.4501 189.051 26.0234L188.941 27.6447C188.82 29.4281 187.262 30.7881 185.478 30.6671Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M185.478 30.667L185.809 25.8032" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M182.926 24.5217C181.89 24.3428 181.088 23.3654 181.165 22.2305" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M182.493 26.664L180.331 26.5172" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M180.578 30.8773C180.655 29.7424 181.64 28.832 182.778 28.855" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M190.876 22.8898C190.799 24.0247 189.872 24.8847 188.838 24.9231" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M191.139 27.251L188.978 27.1042" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M188.399 29.2366C189.53 29.3677 190.383 30.4028 190.306 31.5377" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip21_532_2318)">
+<path d="M208.92 22.486L209.867 23.5709" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M212.158 23.7265L213.243 22.7795" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M209.272 25.295L209.309 24.7546C209.314 24.5349 209.363 24.3186 209.454 24.1186C209.545 23.9186 209.676 23.7391 209.838 23.5909C210 23.4427 210.191 23.329 210.398 23.2565C210.606 23.1841 210.826 23.1544 211.045 23.1693C211.264 23.1841 211.478 23.2433 211.674 23.3431C211.87 23.443 212.043 23.5814 212.184 23.7502C212.325 23.9189 212.43 24.1144 212.493 24.3248C212.556 24.5353 212.576 24.7564 212.551 24.9747L212.514 25.5152" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M210.421 32.3604C208.638 32.2393 207.278 30.6811 207.399 28.8977L207.509 27.2764C207.548 26.7031 207.813 26.1687 208.246 25.7909C208.678 25.413 209.244 25.2226 209.817 25.2615L211.979 25.4083C212.552 25.4472 213.086 25.7123 213.464 26.1452C213.842 26.5781 214.033 27.1434 213.994 27.7167L213.884 29.338C213.763 31.1214 212.204 32.4814 210.421 32.3604Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M210.421 32.3603L210.751 27.4965" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M207.868 26.215C206.833 26.0361 206.03 25.0587 206.107 23.9238" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M207.435 28.3573L205.274 28.2105" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M205.52 32.5706C205.598 31.4357 206.582 30.5253 207.721 30.5483" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M215.819 24.5831C215.742 25.718 214.815 26.578 213.781 26.6164" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M216.082 28.9443L213.92 28.7975" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M213.341 30.9299C214.472 31.061 215.325 32.0961 215.248 33.231" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip22_532_2318)">
+<path d="M233.862 24.1793L234.809 25.2642" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M237.101 25.4198L238.186 24.4728" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M234.215 26.9883L234.251 26.4479C234.256 26.2282 234.306 26.0119 234.397 25.8119C234.488 25.6118 234.618 25.4324 234.781 25.2842C234.943 25.136 235.134 25.0223 235.341 24.9498C235.548 24.8774 235.768 24.8477 235.988 24.8626C236.207 24.8774 236.421 24.9366 236.616 25.0364C236.812 25.1363 236.986 25.2747 237.126 25.4435C237.267 25.6122 237.372 25.8077 237.435 26.0181C237.499 26.2286 237.518 26.4497 237.494 26.668L237.457 27.2085" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M235.364 34.0537C233.58 33.9326 232.22 32.3744 232.341 30.591L232.451 28.9697C232.49 28.3964 232.755 27.862 233.188 27.4842C233.621 27.1063 234.186 26.9159 234.76 26.9548L236.921 27.1016C237.495 27.1405 238.029 27.4055 238.407 27.8385C238.785 28.2714 238.975 28.8367 238.936 29.41L238.826 31.0313C238.705 32.8147 237.147 34.1747 235.364 34.0537Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M235.364 34.0536L235.694 29.1898" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M232.811 27.9083C231.775 27.7294 230.973 26.752 231.05 25.6171" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M232.378 30.0506L230.216 29.9038" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M230.463 34.2639C230.54 33.129 231.525 32.2186 232.663 32.2416" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M240.762 26.2764C240.684 27.4113 239.757 28.2713 238.723 28.3097" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M241.025 30.6376L238.863 30.4908" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M238.284 32.6232C239.415 32.7543 240.268 33.7894 240.191 34.9243" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip23_532_2318)">
+<path d="M258.805 25.8726L259.752 26.9576" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M262.043 27.1132L263.128 26.1661" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M259.157 28.6817L259.194 28.1413C259.199 27.9216 259.248 27.7052 259.339 27.5052C259.43 27.3052 259.561 27.1257 259.723 26.9776C259.885 26.8294 260.076 26.7157 260.284 26.6432C260.491 26.5707 260.711 26.541 260.93 26.5559C261.149 26.5708 261.363 26.6299 261.559 26.7298C261.755 26.8296 261.928 26.9681 262.069 27.1368C262.21 27.3055 262.315 27.501 262.378 27.7115C262.441 27.922 262.461 28.1431 262.436 28.3614L262.4 28.9018" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M260.306 35.747C258.523 35.626 257.163 34.0678 257.284 32.2844L257.394 30.6631C257.433 30.0898 257.698 29.5554 258.131 29.1775C258.564 28.7997 259.129 28.6092 259.702 28.6482L261.864 28.7949C262.437 28.8338 262.972 29.0989 263.35 29.5318C263.727 29.9647 263.918 30.53 263.879 31.1034L263.769 32.7246C263.648 34.508 262.09 35.8681 260.306 35.747Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M260.306 35.747L260.636 30.8832" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M257.754 29.6017C256.718 29.4228 255.916 28.4454 255.993 27.3105" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M257.32 31.7439L255.159 31.5972" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M255.406 35.9572C255.483 34.8223 256.467 33.9119 257.606 33.9349" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M265.704 27.9698C265.627 29.1047 264.7 29.9647 263.666 30.0031" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M265.967 32.331L263.806 32.1842" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M263.226 34.3165C264.358 34.4476 265.21 35.4827 265.133 36.6176" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip24_532_2318)">
+<path d="M283.747 27.5659L284.694 28.6509" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M286.986 28.8065L288.071 27.8594" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M284.1 30.375L284.136 29.8346C284.141 29.6149 284.191 29.3985 284.282 29.1985C284.373 28.9985 284.503 28.819 284.666 28.6709C284.828 28.5227 285.019 28.409 285.226 28.3365C285.434 28.264 285.653 28.2343 285.873 28.2492C286.092 28.2641 286.306 28.3232 286.502 28.4231C286.697 28.5229 286.871 28.6614 287.012 28.8301C287.152 28.9988 287.257 29.1943 287.321 29.4048C287.384 29.6153 287.404 29.8363 287.379 30.0547L287.342 30.5951" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M285.249 37.4403C283.465 37.3193 282.105 35.7611 282.226 33.9777L282.336 32.3564C282.375 31.7831 282.64 31.2487 283.073 30.8708C283.506 30.493 284.072 30.3025 284.645 30.3415L286.807 30.4882C287.38 30.5271 287.914 30.7922 288.292 31.2251C288.67 31.658 288.86 32.2233 288.821 32.7967L288.711 34.4179C288.59 36.2013 287.032 37.5614 285.249 37.4403Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M285.249 37.4403L285.579 32.5765" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M282.696 31.295C281.661 31.1161 280.858 30.1387 280.935 29.0038" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M282.263 33.4372L280.101 33.2905" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M280.348 37.6505C280.425 36.5156 281.41 35.6052 282.549 35.6282" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M290.647 29.6631C290.57 30.798 289.643 31.658 288.608 31.6964" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M290.91 34.0243L288.748 33.8775" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M288.169 36.0098C289.3 36.1409 290.153 37.176 290.076 38.3109" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip25_532_2318)">
+<path d="M308.69 29.2592L309.637 30.3442" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M311.928 30.4998L313.013 29.5527" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M309.042 32.0683L309.079 31.5279C309.084 31.3082 309.134 31.0918 309.225 30.8918C309.316 30.6918 309.446 30.5123 309.608 30.3642C309.771 30.216 309.961 30.1023 310.169 30.0298C310.376 29.9573 310.596 29.9276 310.815 29.9425C311.035 29.9574 311.248 30.0165 311.444 30.1164C311.64 30.2162 311.813 30.3547 311.954 30.5234C312.095 30.6921 312.2 30.8876 312.263 31.0981C312.326 31.3086 312.346 31.5296 312.322 31.748L312.285 32.2884" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M310.191 39.1336C308.408 39.0126 307.048 37.4544 307.169 35.671L307.279 34.0497C307.318 33.4764 307.583 32.942 308.016 32.5641C308.449 32.1863 309.014 31.9958 309.587 32.0348L311.749 32.1815C312.322 32.2204 312.857 32.4855 313.235 32.9184C313.613 33.3513 313.803 33.9166 313.764 34.49L313.654 36.1112C313.533 37.8946 311.975 39.2547 310.191 39.1336Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M310.191 39.1336L310.522 34.2698" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M307.639 32.9883C306.603 32.8094 305.801 31.832 305.878 30.6971" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M307.206 35.1305L305.044 34.9838" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M305.291 39.3438C305.368 38.2089 306.353 37.2985 307.491 37.3215" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M315.589 31.3564C315.512 32.4913 314.585 33.3513 313.551 33.3897" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M315.852 35.7176L313.691 35.5708" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M313.112 37.7031C314.243 37.8342 315.096 38.8693 315.018 40.0042" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip26_532_2318)">
+<path d="M333.633 30.9525L334.58 32.0375" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M336.871 32.1931L337.956 31.246" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M333.985 33.7616L334.022 33.2212C334.027 33.0015 334.076 32.7851 334.167 32.5851C334.258 32.3851 334.389 32.2056 334.551 32.0574C334.713 31.9093 334.904 31.7955 335.111 31.7231C335.319 31.6506 335.539 31.6209 335.758 31.6358C335.977 31.6507 336.191 31.7098 336.387 31.8097C336.583 31.9095 336.756 32.048 336.897 32.2167C337.038 32.3854 337.143 32.5809 337.206 32.7914C337.269 33.0019 337.289 33.2229 337.264 33.4413L337.227 33.9817" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M335.134 40.8269C333.351 40.7059 331.99 39.1477 332.112 37.3643L332.222 35.743C332.261 35.1697 332.526 34.6353 332.959 34.2574C333.391 33.8796 333.957 33.6891 334.53 33.7281L336.692 33.8748C337.265 33.9137 337.799 34.1788 338.177 34.6117C338.555 35.0446 338.746 35.6099 338.707 36.1833L338.597 37.8045C338.476 39.5879 336.917 40.948 335.134 40.8269Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M335.134 40.8269L335.464 35.9631" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M332.581 34.6816C331.546 34.5027 330.743 33.5253 330.82 32.3904" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M332.148 36.8238L329.987 36.6771" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M330.233 41.0371C330.31 39.9022 331.295 38.9918 332.434 39.0148" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M340.532 33.0497C340.455 34.1846 339.528 35.0446 338.494 35.083" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M340.795 37.4109L338.633 37.2641" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M338.054 39.3964C339.185 39.5275 340.038 40.5626 339.961 41.6975" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip27_532_2318)">
+<path d="M358.575 32.6458L359.522 33.7308" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M361.814 33.8864L362.899 32.9393" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M358.927 35.4549L358.964 34.9145C358.969 34.6948 359.019 34.4784 359.11 34.2784C359.201 34.0784 359.331 33.8989 359.494 33.7507C359.656 33.6026 359.846 33.4888 360.054 33.4164C360.261 33.3439 360.481 33.3142 360.7 33.3291C360.92 33.344 361.134 33.4031 361.329 33.503C361.525 33.6028 361.699 33.7413 361.839 33.91C361.98 34.0787 362.085 34.2742 362.148 34.4847C362.211 34.6952 362.231 34.9162 362.207 35.1346L362.17 35.675" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M360.077 42.5202C358.293 42.3992 356.933 40.841 357.054 39.0576L357.164 37.4363C357.203 36.863 357.468 36.3286 357.901 35.9507C358.334 35.5729 358.899 35.3824 359.473 35.4214L361.634 35.5681C362.208 35.607 362.742 35.8721 363.12 36.305C363.498 36.7379 363.688 37.3032 363.649 37.8765L363.539 39.4978C363.418 41.2812 361.86 42.6413 360.077 42.5202Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M360.076 42.5202L360.407 37.6564" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M357.524 36.3749C356.488 36.196 355.686 35.2186 355.763 34.0837" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M357.091 38.5171L354.929 38.3704" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M355.176 42.7304C355.253 41.5955 356.238 40.6851 357.376 40.7081" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M365.474 34.743C365.397 35.8779 364.47 36.7379 363.436 36.7763" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M365.738 39.1042L363.576 38.9574" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M362.997 41.0897C364.128 41.2208 364.981 42.2559 364.904 43.3908" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip28_532_2318)">
+<path d="M383.518 34.3392L384.465 35.4241" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M386.756 35.5797L387.841 34.6327" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M383.87 37.1482L383.907 36.6078C383.912 36.3881 383.961 36.1718 384.052 35.9718C384.143 35.7718 384.274 35.5923 384.436 35.4441C384.598 35.296 384.789 35.1822 384.996 35.1097C385.204 35.0373 385.424 35.0076 385.643 35.0225C385.862 35.0374 386.076 35.0965 386.272 35.1963C386.468 35.2962 386.641 35.4346 386.782 35.6034C386.923 35.7721 387.028 35.9676 387.091 36.1781C387.154 36.3885 387.174 36.6096 387.149 36.8279L387.113 37.3684" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M385.019 44.2136C383.236 44.0925 381.876 42.5343 381.997 40.7509L382.107 39.1296C382.146 38.5563 382.411 38.022 382.844 37.6441C383.277 37.2662 383.842 37.0758 384.415 37.1147L386.577 37.2615C387.15 37.3004 387.685 37.5655 388.062 37.9984C388.44 38.4313 388.631 38.9966 388.592 39.5699L388.482 41.1912C388.361 42.9746 386.803 44.3347 385.019 44.2136Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M385.019 44.2135L385.349 39.3497" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M382.467 38.0682C381.431 37.8893 380.629 36.9119 380.706 35.777" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M382.033 40.2105L379.872 40.0637" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M380.119 44.4238C380.196 43.2889 381.18 42.3785 382.319 42.4015" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M390.417 36.4363C390.34 37.5712 389.413 38.4312 388.379 38.4696" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M390.68 40.7975L388.518 40.6508" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M387.939 42.7831C389.071 42.9142 389.923 43.9493 389.846 45.0842" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip29_532_2318)">
+<path d="M57.8418 33.2779L58.7888 34.3629" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M61.0802 34.5184L62.1652 33.5714" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M58.1941 36.087L58.2308 35.5465C58.2358 35.3269 58.2854 35.1105 58.3763 34.9105C58.4673 34.7105 58.5979 34.531 58.7602 34.3828C58.9225 34.2347 59.1131 34.1209 59.3205 34.0485C59.5279 33.976 59.7479 33.9463 59.9671 33.9612C60.1864 33.9761 60.4003 34.0352 60.596 34.1351C60.7918 34.2349 60.9653 34.3734 61.106 34.5421C61.2468 34.7108 61.3519 34.9063 61.415 35.1168C61.4781 35.3272 61.498 35.5483 61.4733 35.7667L61.4366 36.3071" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M59.3431 43.1523C57.5597 43.0312 56.1996 41.473 56.3207 39.6896L56.4308 38.0684C56.4697 37.4951 56.7348 36.9607 57.1677 36.5828C57.6006 36.2049 58.1659 36.0145 58.7392 36.0534L60.9009 36.2002C61.4742 36.2391 62.0086 36.5042 62.3865 36.9371C62.7643 37.37 62.9548 37.9353 62.9158 38.5086L62.8058 40.1299C62.6847 41.9133 61.1265 43.2734 59.3431 43.1523Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M59.3431 43.1523L59.6733 38.2885" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M56.7906 37.007C55.7549 36.8281 54.9526 35.8506 55.0297 34.7158" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M56.3574 39.1492L54.1957 39.0024" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M54.4426 43.3625C54.5196 42.2276 55.5044 41.3172 56.643 41.3402" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M64.741 35.3751C64.6639 36.5099 63.7369 37.37 62.7027 37.4083" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M65.0041 39.7362L62.8424 39.5895" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M62.2633 41.7218C63.3945 41.8529 64.2472 42.888 64.1702 44.0229" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip30_532_2318)">
+<path d="M82.7844 34.9712L83.7314 36.0562" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M86.0228 36.2117L87.1078 35.2647" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M83.1367 37.7803L83.1734 37.2398C83.1785 37.0202 83.228 36.8038 83.319 36.6038C83.41 36.4038 83.5405 36.2243 83.7028 36.0761C83.8651 35.928 84.0557 35.8142 84.2631 35.7418C84.4706 35.6693 84.6905 35.6396 84.9098 35.6545C85.129 35.6694 85.3429 35.7285 85.5387 35.8284C85.7344 35.9282 85.9079 36.0667 86.0486 36.2354C86.1894 36.4041 86.2945 36.5996 86.3576 36.8101C86.4208 37.0205 86.4406 37.2416 86.4159 37.46L86.3792 38.0004" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M84.2857 44.8456C82.5023 44.7245 81.1423 43.1663 81.2633 41.3829L81.3734 39.7617C81.4123 39.1884 81.6774 38.654 82.1103 38.2761C82.5432 37.8982 83.1085 37.7078 83.6818 37.7467L85.8435 37.8935C86.4168 37.9324 86.9512 38.1975 87.3291 38.6304C87.707 39.0633 87.8974 39.6286 87.8585 40.2019L87.7484 41.8232C87.6273 43.6066 86.0691 44.9667 84.2857 44.8456Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M84.2858 44.8456L84.616 39.9818" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M81.7332 38.7003C80.6976 38.5214 79.8952 37.5439 79.9723 36.4091" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M81.3 40.8425L79.1383 40.6957" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M79.3852 45.0558C79.4623 43.9209 80.447 43.0105 81.5856 43.0335" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M89.6836 37.0684C89.6065 38.2032 88.6795 39.0633 87.6454 39.1016" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M89.9467 41.4295L87.785 41.2828" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M87.2059 43.4151C88.3372 43.5462 89.1898 44.5813 89.1128 45.7162" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip31_532_2318)">
+<path d="M107.727 36.6645L108.674 37.7495" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M110.965 37.905L112.05 36.958" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M108.079 39.4736L108.116 38.9331C108.121 38.7135 108.17 38.4971 108.261 38.2971C108.352 38.0971 108.483 37.9176 108.645 37.7694C108.808 37.6213 108.998 37.5075 109.206 37.4351C109.413 37.3626 109.633 37.3329 109.852 37.3478C110.071 37.3627 110.285 37.4218 110.481 37.5217C110.677 37.6215 110.85 37.76 110.991 37.9287C111.132 38.0974 111.237 38.2929 111.3 38.5034C111.363 38.7138 111.383 38.9349 111.358 39.1533L111.322 39.6937" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M109.228 46.5389C107.445 46.4178 106.085 44.8596 106.206 43.0762L106.316 41.455C106.355 40.8816 106.62 40.3473 107.053 39.9694C107.486 39.5915 108.051 39.4011 108.624 39.44L110.786 39.5868C111.359 39.6257 111.894 39.8908 112.272 40.3237C112.649 40.7566 112.84 41.3219 112.801 41.8952L112.691 43.5165C112.57 45.2999 111.012 46.66 109.228 46.5389Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M109.228 46.5389L109.558 41.675" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M106.676 40.3936C105.64 40.2147 104.838 39.2372 104.915 38.1024" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M106.243 42.5358L104.081 42.389" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M104.328 46.7491C104.405 45.6142 105.39 44.7038 106.528 44.7268" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M114.626 38.7616C114.549 39.8965 113.622 40.7566 112.588 40.7949" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M114.889 43.1228L112.728 42.9761" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M112.148 45.1084C113.28 45.2395 114.132 46.2746 114.055 47.4095" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip32_532_2318)">
+<path d="M132.67 38.3578L133.617 39.4428" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M135.908 39.5984L136.993 38.6514" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M133.022 41.1669L133.059 40.6265C133.064 40.4068 133.113 40.1905 133.204 39.9904C133.295 39.7904 133.426 39.6109 133.588 39.4628C133.75 39.3146 133.941 39.2009 134.148 39.1284C134.356 39.0559 134.576 39.0263 134.795 39.0411C135.014 39.056 135.228 39.1152 135.424 39.215C135.62 39.3148 135.793 39.4533 135.934 39.622C136.075 39.7908 136.18 39.9863 136.243 40.1967C136.306 40.4072 136.326 40.6283 136.301 40.8466L136.264 41.387" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M134.171 48.2323C132.387 48.1112 131.027 46.553 131.148 44.7696L131.259 43.1483C131.297 42.575 131.563 42.0406 131.995 41.6628C132.428 41.2849 132.994 41.0945 133.567 41.1334L135.729 41.2801C136.302 41.3191 136.836 41.5841 137.214 42.0171C137.592 42.45 137.783 43.0153 137.744 43.5886L137.634 45.2099C137.512 46.9932 135.954 48.3533 134.171 48.2323Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M134.171 48.2322L134.501 43.3684" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M131.618 42.0869C130.583 41.908 129.78 40.9306 129.857 39.7957" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M131.185 44.2292L129.023 44.0824" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M129.27 48.4425C129.347 47.3076 130.332 46.3972 131.471 46.4202" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M139.569 40.455C139.492 41.5899 138.565 42.4499 137.53 42.4883" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M139.832 44.8162L137.67 44.6694" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M137.091 46.8018C138.222 46.9328 139.075 47.968 138.998 49.1029" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip33_532_2318)">
+<path d="M157.612 40.0511L158.559 41.1361" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M160.851 41.2917L161.936 40.3447" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M157.964 42.8602L158.001 42.3198C158.006 42.1001 158.056 41.8837 158.147 41.6837C158.238 41.4837 158.368 41.3042 158.531 41.1561C158.693 41.0079 158.883 40.8942 159.091 40.8217C159.298 40.7492 159.518 40.7196 159.738 40.7344C159.957 40.7493 160.171 40.8085 160.366 40.9083C160.562 41.0081 160.736 41.1466 160.876 41.3153C161.017 41.4841 161.122 41.6796 161.185 41.89C161.249 42.1005 161.268 42.3216 161.244 42.5399L161.207 43.0803" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M159.113 49.9256C157.33 49.8045 155.97 48.2463 156.091 46.4629L156.201 44.8416C156.24 44.2683 156.505 43.7339 156.938 43.3561C157.371 42.9782 157.936 42.7878 158.51 42.8267L160.671 42.9734C161.245 43.0124 161.779 43.2774 162.157 43.7104C162.535 44.1433 162.725 44.7086 162.686 45.2819L162.576 46.9032C162.455 48.6865 160.897 50.0466 159.113 49.9256Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M159.114 49.9255L159.444 45.0617" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M156.561 43.7802C155.525 43.6013 154.723 42.6239 154.8 41.489" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M156.128 45.9224L153.966 45.7757" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M154.213 50.1358C154.29 49.0009 155.275 48.0905 156.413 48.1135" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M164.511 42.1483C164.434 43.2832 163.507 44.1432 162.473 44.1816" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M164.775 46.5095L162.613 46.3627" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M162.034 48.4951C163.165 48.6261 164.018 49.6613 163.941 50.7962" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip34_532_2318)">
+<path d="M182.555 41.7444L183.502 42.8294" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M185.793 42.985L186.878 42.038" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M182.907 44.5535L182.944 44.0131C182.949 43.7934 182.998 43.577 183.089 43.377C183.18 43.177 183.311 42.9975 183.473 42.8494C183.635 42.7012 183.826 42.5875 184.033 42.515C184.241 42.4425 184.461 42.4129 184.68 42.4277C184.899 42.4426 185.113 42.5018 185.309 42.6016C185.505 42.7014 185.678 42.8399 185.819 43.0086C185.96 43.1774 186.065 43.3729 186.128 43.5833C186.191 43.7938 186.211 44.0149 186.186 44.2332L186.149 44.7736" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M184.056 51.6189C182.273 51.4978 180.913 49.9396 181.034 48.1562L181.144 46.5349C181.183 45.9616 181.448 45.4272 181.881 45.0494C182.314 44.6715 182.879 44.4811 183.452 44.52L185.614 44.6667C186.187 44.7057 186.722 44.9707 187.099 45.4037C187.477 45.8366 187.668 46.4019 187.629 46.9752L187.519 48.5964C187.398 50.3798 185.839 51.7399 184.056 51.6189Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M184.056 51.6188L184.386 46.755" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M181.503 45.4735C180.468 45.2946 179.666 44.3172 179.743 43.1823" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M181.07 47.6157L178.909 47.469" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M179.156 51.829C179.233 50.6942 180.217 49.7838 181.356 49.8068" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M189.454 43.8416C189.377 44.9765 188.45 45.8365 187.416 45.8749" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M189.717 48.2028L187.555 48.056" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M186.976 50.1884C188.108 50.3194 188.96 51.3546 188.883 52.4895" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip35_532_2318)">
+<path d="M207.497 43.4377L208.444 44.5227" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M210.736 44.6783L211.821 43.7313" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M207.85 46.2468L207.886 45.7064C207.891 45.4867 207.941 45.2703 208.032 45.0703C208.123 44.8703 208.253 44.6908 208.416 44.5427C208.578 44.3945 208.769 44.2808 208.976 44.2083C209.183 44.1358 209.403 44.1062 209.623 44.121C209.842 44.1359 210.056 44.1951 210.251 44.2949C210.447 44.3947 210.621 44.5332 210.761 44.7019C210.902 44.8707 211.007 45.0662 211.07 45.2766C211.134 45.4871 211.153 45.7082 211.129 45.9265L211.092 46.4669" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M208.999 53.3122C207.215 53.1911 205.855 51.6329 205.976 49.8495L206.086 48.2282C206.125 47.6549 206.39 47.1205 206.823 46.7427C207.256 46.3648 207.821 46.1744 208.395 46.2133L210.556 46.36C211.13 46.399 211.664 46.664 212.042 47.097C212.42 47.5299 212.61 48.0952 212.571 48.6685L212.461 50.2897C212.34 52.0731 210.782 53.4332 208.999 53.3122Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M208.999 53.3121L209.329 48.4483" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M206.446 47.1668C205.41 46.9879 204.608 46.0105 204.685 44.8756" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M206.013 49.309L203.851 49.1623" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M204.098 53.5223C204.175 52.3875 205.16 51.4771 206.298 51.5001" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M214.397 45.5349C214.32 46.6698 213.392 47.5298 212.358 47.5682" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M214.66 49.8961L212.498 49.7493" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M211.919 51.8817C213.05 52.0127 213.903 53.0479 213.826 54.1828" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip36_532_2318)">
+<path d="M232.44 45.131L233.387 46.216" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M235.678 46.3716L236.763 45.4246" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M232.792 47.9401L232.829 47.3997C232.834 47.18 232.883 46.9636 232.974 46.7636C233.065 46.5636 233.196 46.3841 233.358 46.236C233.521 46.0878 233.711 45.9741 233.919 45.9016C234.126 45.8291 234.346 45.7995 234.565 45.8143C234.784 45.8292 234.998 45.8884 235.194 45.9882C235.39 46.088 235.563 46.2265 235.704 46.3952C235.845 46.564 235.95 46.7595 236.013 46.9699C236.076 47.1804 236.096 47.4015 236.071 47.6198L236.035 48.1602" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M233.941 55.0054C232.158 54.8844 230.798 53.3262 230.919 51.5428L231.029 49.9215C231.068 49.3482 231.333 48.8138 231.766 48.4359C232.199 48.058 232.764 47.8676 233.337 47.9065L235.499 48.0533C236.072 48.0922 236.607 48.3573 236.985 48.7902C237.362 49.2231 237.553 49.7884 237.514 50.3617L237.404 51.983C237.283 53.7664 235.725 55.1265 233.941 55.0054Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M233.941 55.0054L234.271 50.1416" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M231.389 48.8601C230.353 48.6812 229.551 47.7038 229.628 46.5689" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M230.956 51.0023L228.794 50.8556" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M229.041 55.2156C229.118 54.0808 230.103 53.1704 231.241 53.1934" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M239.339 47.2282C239.262 48.3631 238.335 49.2231 237.301 49.2615" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M239.602 51.5894L237.441 51.4426" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M236.862 53.575C237.993 53.706 238.845 54.7412 238.768 55.8761" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip37_532_2318)">
+<path d="M257.383 46.8244L258.33 47.9094" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M260.621 48.0649L261.706 47.1179" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M257.735 49.6335L257.771 49.093C257.776 48.8734 257.826 48.657 257.917 48.457C258.008 48.257 258.139 48.0775 258.301 47.9293C258.463 47.7812 258.654 47.6674 258.861 47.595C259.069 47.5225 259.289 47.4928 259.508 47.5077C259.727 47.5226 259.941 47.5817 260.137 47.6816C260.332 47.7814 260.506 47.9199 260.647 48.0886C260.787 48.2573 260.893 48.4528 260.956 48.6633C261.019 48.8738 261.039 49.0948 261.014 49.3132L260.977 49.8536" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M258.884 56.6988C257.1 56.5777 255.74 55.0195 255.861 53.2361L255.971 51.6148C256.01 51.0415 256.275 50.5072 256.708 50.1293C257.141 49.7514 257.707 49.561 258.28 49.5999L260.442 49.7467C261.015 49.7856 261.549 50.0507 261.927 50.4836C262.305 50.9165 262.495 51.4818 262.457 52.0551L262.346 53.6764C262.225 55.4598 260.667 56.8199 258.884 56.6988Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M258.884 56.6988L259.214 51.835" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M256.331 50.5534C255.296 50.3745 254.493 49.3971 254.57 48.2622" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M255.898 52.6957L253.736 52.549" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M253.983 56.909C254.06 55.7741 255.045 54.8637 256.184 54.8867" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M264.282 48.9215C264.205 50.0564 263.278 50.9164 262.244 50.9548" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M264.545 53.2827L262.383 53.136" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M261.804 55.2683C262.935 55.3994 263.788 56.4345 263.711 57.5694" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip38_532_2318)">
+<path d="M282.325 48.5177L283.272 49.6027" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M285.563 49.7582L286.648 48.8112" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M282.677 51.3268L282.714 50.7863C282.719 50.5667 282.769 50.3503 282.86 50.1503C282.951 49.9503 283.081 49.7708 283.243 49.6226C283.406 49.4745 283.596 49.3607 283.804 49.2883C284.011 49.2158 284.231 49.1861 284.45 49.201C284.67 49.2159 284.884 49.275 285.079 49.3749C285.275 49.4747 285.448 49.6132 285.589 49.7819C285.73 49.9506 285.835 50.1461 285.898 50.3566C285.961 50.5671 285.981 50.7881 285.957 51.0065L285.92 51.5469" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M283.826 58.3921C282.043 58.271 280.683 56.7128 280.804 54.9294L280.914 53.3081C280.953 52.7348 281.218 52.2005 281.651 51.8226C282.084 51.4447 282.649 51.2543 283.222 51.2932L285.384 51.44C285.957 51.4789 286.492 51.744 286.87 52.1769C287.248 52.6098 287.438 53.1751 287.399 53.7484L287.289 55.3697C287.168 57.1531 285.61 58.5132 283.826 58.3921Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M283.826 58.3921L284.157 53.5283" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M281.274 52.2467C280.238 52.0678 279.436 51.0904 279.513 49.9555" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M280.841 54.389L278.679 54.2422" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M278.926 58.6023C279.003 57.4674 279.988 56.557 281.126 56.58" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M289.224 50.6148C289.147 51.7497 288.22 52.6097 287.186 52.6481" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M289.487 54.976L287.326 54.8293" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M286.747 56.9616C287.878 57.0927 288.731 58.1278 288.653 59.2627" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip39_532_2318)">
+<path d="M307.268 50.211L308.215 51.296" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M310.506 51.4515L311.591 50.5045" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M307.62 53.0201L307.657 52.4796C307.662 52.26 307.711 52.0436 307.802 51.8436C307.893 51.6436 308.024 51.4641 308.186 51.3159C308.348 51.1678 308.539 51.054 308.746 50.9816C308.954 50.9091 309.174 50.8794 309.393 50.8943C309.612 50.9092 309.826 50.9683 310.022 51.0682C310.218 51.168 310.391 51.3065 310.532 51.4752C310.673 51.6439 310.778 51.8394 310.841 52.0499C310.904 52.2604 310.924 52.4814 310.899 52.6998L310.862 53.2402" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M308.769 60.0854C306.986 59.9643 305.626 58.4061 305.747 56.6227L305.857 55.0014C305.896 54.4281 306.161 53.8938 306.594 53.5159C307.026 53.138 307.592 52.9476 308.165 52.9865L310.327 53.1333C310.9 53.1722 311.434 53.4373 311.812 53.8702C312.19 54.3031 312.381 54.8684 312.342 55.4417L312.232 57.063C312.111 58.8464 310.552 60.2064 308.769 60.0854Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M308.769 60.0854L309.099 55.2216" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M306.216 53.94C305.181 53.7611 304.378 52.7837 304.456 51.6488" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M305.783 56.0823L303.622 55.9355" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M303.869 60.2956C303.946 59.1607 304.93 58.2503 306.069 58.2733" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M314.167 52.3081C314.09 53.443 313.163 54.303 312.129 54.3414" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M314.43 56.6693L312.268 56.5226" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M311.689 58.6549C312.82 58.786 313.673 59.8211 313.596 60.956" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip40_532_2318)">
+<path d="M332.21 51.9043L333.157 52.9893" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M335.449 53.1448L336.534 52.1978" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M332.562 54.7134L332.599 54.1729C332.604 53.9533 332.654 53.7369 332.745 53.5369C332.836 53.3369 332.966 53.1574 333.129 53.0092C333.291 52.8611 333.481 52.7473 333.689 52.6749C333.896 52.6024 334.116 52.5727 334.336 52.5876C334.555 52.6025 334.769 52.6616 334.964 52.7615C335.16 52.8613 335.334 52.9998 335.474 53.1685C335.615 53.3372 335.72 53.5327 335.783 53.7432C335.847 53.9537 335.866 54.1747 335.842 54.3931L335.805 54.9335" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M333.712 61.7787C331.928 61.6576 330.568 60.0994 330.689 58.316L330.799 56.6947C330.838 56.1214 331.103 55.5871 331.536 55.2092C331.969 54.8313 332.534 54.6409 333.108 54.6798L335.269 54.8266C335.843 54.8655 336.377 55.1306 336.755 55.5635C337.133 55.9964 337.323 56.5617 337.284 57.135L337.174 58.7563C337.053 60.5397 335.495 61.8997 333.712 61.7787Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M333.712 61.7787L334.042 56.9149" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M331.159 55.6333C330.123 55.4544 329.321 54.477 329.398 53.3421" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M330.726 57.7756L328.564 57.6288" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M328.811 61.9889C328.888 60.854 329.873 59.9436 331.011 59.9666" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M339.109 54.0014C339.032 55.1363 338.105 55.9963 337.071 56.0347" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M339.373 58.3626L337.211 58.2159" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M336.632 60.3482C337.763 60.4793 338.616 61.5144 338.539 62.6493" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip41_532_2318)">
+<path d="M357.153 53.5977L358.1 54.6826" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M360.391 54.8382L361.476 53.8911" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M357.505 56.4067L357.542 55.8663C357.547 55.6466 357.596 55.4302 357.687 55.2302C357.778 55.0302 357.909 54.8507 358.071 54.7026C358.233 54.5544 358.424 54.4407 358.631 54.3682C358.839 54.2957 359.059 54.266 359.278 54.2809C359.497 54.2958 359.711 54.3549 359.907 54.4548C360.103 54.5546 360.276 54.6931 360.417 54.8618C360.558 55.0305 360.663 55.226 360.726 55.4365C360.789 55.647 360.809 55.8681 360.784 56.0864L360.748 56.6268" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M358.654 63.472C356.871 63.3509 355.511 61.7927 355.632 60.0093L355.742 58.3881C355.781 57.8148 356.046 57.2804 356.479 56.9025C356.912 56.5246 357.477 56.3342 358.05 56.3731L360.212 56.5199C360.785 56.5588 361.32 56.8239 361.697 57.2568C362.075 57.6897 362.266 58.255 362.227 58.8283L362.117 60.4496C361.996 62.233 360.438 63.5931 358.654 63.472Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M358.654 63.472L358.984 58.6082" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M356.102 57.3267C355.066 57.1478 354.264 56.1703 354.341 55.0355" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M355.668 59.4689L353.507 59.3222" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M353.754 63.6822C353.831 62.5473 354.815 61.637 355.954 61.66" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M364.052 55.6948C363.975 56.8296 363.048 57.6897 362.014 57.728" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M364.315 60.0559L362.154 59.9092" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M361.574 62.0415C362.706 62.1726 363.558 63.2078 363.481 64.3426" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip42_532_2318)">
+<path d="M382.095 55.291L383.042 56.3759" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M385.334 56.5315L386.419 55.5844" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M382.448 58.1L382.484 57.5596C382.489 57.3399 382.539 57.1235 382.63 56.9235C382.721 56.7235 382.852 56.544 383.014 56.3959C383.176 56.2477 383.367 56.134 383.574 56.0615C383.782 55.989 384.002 55.9593 384.221 55.9742C384.44 55.9891 384.654 56.0482 384.85 56.1481C385.045 56.2479 385.219 56.3864 385.36 56.5551C385.5 56.7238 385.605 56.9193 385.669 57.1298C385.732 57.3403 385.752 57.5614 385.727 57.7797L385.69 58.3201" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M383.597 65.1653C381.813 65.0442 380.453 63.486 380.574 61.7026L380.684 60.0814C380.723 59.5081 380.988 58.9737 381.421 58.5958C381.854 58.2179 382.42 58.0275 382.993 58.0664L385.155 58.2132C385.728 58.2521 386.262 58.5172 386.64 58.9501C387.018 59.383 387.208 59.9483 387.169 60.5216L387.059 62.1429C386.938 63.9263 385.38 65.2864 383.597 65.1653Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M383.597 65.1653L383.927 60.3015" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M381.044 59.02C380.009 58.8411 379.206 57.8636 379.283 56.7288" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M380.611 61.1622L378.449 61.0155" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M378.696 65.3755C378.773 64.2406 379.758 63.3302 380.897 63.3533" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M388.995 57.3881C388.918 58.5229 387.991 59.383 386.956 59.4213" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M389.258 61.7492L387.096 61.6025" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M386.517 63.7348C387.648 63.8659 388.501 64.9011 388.424 66.0359" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip43_532_2318)">
+<path d="M56.4194 54.2297L57.3665 55.3146" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M59.6578 55.4702L60.7428 54.5232" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M56.7717 57.0387L56.8084 56.4983C56.8135 56.2786 56.863 56.0622 56.954 55.8622C57.045 55.6622 57.1755 55.4827 57.3378 55.3346C57.5001 55.1864 57.6907 55.0727 57.8981 55.0002C58.1056 54.9277 58.3255 54.8981 58.5448 54.9129C58.764 54.9278 58.9779 54.987 59.1737 55.0868C59.3694 55.1866 59.5429 55.3251 59.6837 55.4938C59.8244 55.6626 59.9295 55.8581 59.9926 56.0685C60.0558 56.279 60.0756 56.5001 60.0509 56.7184L60.0142 57.2588" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M57.9207 64.104C56.1373 63.983 54.7773 62.4248 54.8983 60.6414L55.0084 59.0201C55.0473 58.4468 55.3124 57.9124 55.7453 57.5345C56.1782 57.1567 56.7435 56.9662 57.3168 57.0052L59.4785 57.1519C60.0518 57.1908 60.5862 57.4559 60.9641 57.8888C61.342 58.3217 61.5324 58.887 61.4935 59.4604L61.3834 61.0816C61.2623 62.865 59.7041 64.2251 57.9207 64.104Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M57.9208 64.104L58.251 59.2402" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M55.3682 57.9587C54.3326 57.7798 53.5303 56.8024 53.6073 55.6675" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M54.935 60.1009L52.7733 59.9542" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M53.0202 64.3142C53.0973 63.1794 54.082 62.269 55.2206 62.292" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M63.3186 56.3268C63.2416 57.4617 62.3145 58.3217 61.2804 58.3601" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M63.5817 60.688L61.42 60.5412" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M60.8409 62.6736C61.9722 62.8046 62.8248 63.8398 62.7478 64.9747" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip44_532_2318)">
+<path d="M81.3621 55.923L82.3091 57.0079" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M84.6005 57.1635L85.6854 56.2165" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M81.7143 58.732L81.751 58.1916C81.7561 57.9719 81.8056 57.7555 81.8966 57.5555C81.9876 57.3555 82.1182 57.176 82.2804 57.0279C82.4427 56.8797 82.6333 56.766 82.8408 56.6935C83.0482 56.621 83.2682 56.5914 83.4874 56.6062C83.7066 56.6211 83.9206 56.6803 84.1163 56.7801C84.312 56.8799 84.4855 57.0184 84.6263 57.1871C84.767 57.3559 84.8721 57.5514 84.9353 57.7618C84.9984 57.9723 85.0182 58.1934 84.9936 58.4117L84.9569 58.9521" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M82.8634 65.7973C81.08 65.6763 79.7199 64.1181 79.841 62.3347L79.951 60.7134C79.9899 60.1401 80.255 59.6057 80.6879 59.2278C81.1209 58.85 81.6862 58.6595 82.2595 58.6985L84.4212 58.8452C84.9945 58.8841 85.5288 59.1492 85.9067 59.5821C86.2846 60.015 86.475 60.5803 86.4361 61.1536L86.326 62.7749C86.205 64.5583 84.6468 65.9184 82.8634 65.7973Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M82.8634 65.7973L83.1936 60.9335" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M80.3109 59.652C79.2752 59.4731 78.4729 58.4957 78.5499 57.3608" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M79.8776 61.7942L77.7159 61.6475" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M77.9629 66.0075C78.0399 64.8727 79.0247 63.9623 80.1632 63.9853" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M88.2612 58.0201C88.1842 59.155 87.2571 60.015 86.223 60.0534" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M88.5244 62.3813L86.3627 62.2345" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M85.7836 64.3669C86.9148 64.4979 87.7675 65.5331 87.6904 66.668" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip45_532_2318)">
+<path d="M106.305 57.6163L107.252 58.7013" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M109.543 58.8568L110.628 57.9098" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M106.657 60.4253L106.694 59.8849C106.699 59.6652 106.748 59.4489 106.839 59.2489C106.93 59.0489 107.061 58.8694 107.223 58.7212C107.385 58.5731 107.576 58.4593 107.783 58.3868C107.991 58.3144 108.211 58.2847 108.43 58.2996C108.649 58.3145 108.863 58.3736 109.059 58.4734C109.255 58.5733 109.428 58.7117 109.569 58.8805C109.71 59.0492 109.815 59.2447 109.878 59.4552C109.941 59.6656 109.961 59.8867 109.936 60.105L109.899 60.6455" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M107.806 67.4907C106.022 67.3696 104.662 65.8114 104.783 64.028L104.894 62.4067C104.932 61.8334 105.198 61.299 105.63 60.9212C106.063 60.5433 106.629 60.3529 107.202 60.3918L109.364 60.5385C109.937 60.5775 110.471 60.8425 110.849 61.2754C111.227 61.7084 111.418 62.2737 111.379 62.847L111.269 64.4682C111.147 66.2516 109.589 67.6117 107.806 67.4907Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M107.806 67.4907L108.136 62.6269" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M105.253 61.3453C104.218 61.1664 103.415 60.189 103.492 59.0541" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M104.82 63.4876L102.658 63.3408" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M102.905 67.7009C102.982 66.566 103.967 65.6556 105.106 65.6786" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M113.204 59.7134C113.127 60.8483 112.2 61.7083 111.166 61.7467" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M113.467 64.0746L111.305 63.9278" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M110.726 66.0602C111.857 66.1913 112.71 67.2264 112.633 68.3613" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip46_532_2318)">
+<path d="M131.247 59.3096L132.194 60.3946" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M134.486 60.5501L135.571 59.6031" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M131.599 62.1186L131.636 61.5782C131.641 61.3585 131.691 61.1422 131.782 60.9422C131.873 60.7422 132.003 60.5627 132.166 60.4145C132.328 60.2663 132.518 60.1526 132.726 60.0801C132.933 60.0077 133.153 59.978 133.373 59.9929C133.592 60.0077 133.806 60.0669 134.001 60.1667C134.197 60.2666 134.371 60.405 134.511 60.5738C134.652 60.7425 134.757 60.938 134.82 61.1485C134.884 61.3589 134.903 61.58 134.879 61.7983L134.842 62.3388" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M132.748 69.1839C130.965 69.0629 129.605 67.5047 129.726 65.7213L129.836 64.1C129.875 63.5267 130.14 62.9923 130.573 62.6145C131.006 62.2366 131.571 62.0462 132.145 62.0851L134.306 62.2318C134.88 62.2708 135.414 62.5358 135.792 62.9687C136.17 63.4017 136.36 63.967 136.321 64.5403L136.211 66.1615C136.09 67.9449 134.532 69.305 132.748 69.1839Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M132.749 69.184L133.079 64.3202" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M130.196 63.0386C129.16 62.8597 128.358 61.8823 128.435 60.7474" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M129.763 65.1809L127.601 65.0341" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M127.848 69.3942C127.925 68.2593 128.91 67.3489 130.048 67.3719" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M138.146 61.4067C138.069 62.5416 137.142 63.4016 136.108 63.44" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M138.409 65.7679L136.248 65.6211" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M135.669 67.7535C136.8 67.8846 137.653 68.9197 137.576 70.0546" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip47_532_2318)">
+<path d="M156.19 61.0029L157.137 62.0879" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M159.428 62.2434L160.513 61.2964" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M156.542 63.812L156.579 63.2715C156.584 63.0519 156.633 62.8355 156.724 62.6355C156.815 62.4355 156.946 62.256 157.108 62.1078C157.27 61.9597 157.461 61.8459 157.669 61.7735C157.876 61.701 158.096 61.6713 158.315 61.6862C158.534 61.7011 158.748 61.7602 158.944 61.8601C159.14 61.9599 159.313 62.0984 159.454 62.2671C159.595 62.4358 159.7 62.6313 159.763 62.8418C159.826 63.0523 159.846 63.2733 159.821 63.4917L159.785 64.0321" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M157.691 70.8773C155.908 70.7562 154.548 69.198 154.669 67.4146L154.779 65.7933C154.818 65.22 155.083 64.6857 155.516 64.3078C155.949 63.9299 156.514 63.7395 157.087 63.7784L159.249 63.9252C159.822 63.9641 160.357 64.2292 160.734 64.6621C161.112 65.095 161.303 65.6603 161.264 66.2336L161.154 67.8549C161.033 69.6383 159.475 70.9984 157.691 70.8773Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M157.691 70.8773L158.021 66.0135" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M155.139 64.7319C154.103 64.553 153.301 63.5756 153.378 62.4407" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M154.705 66.8742L152.544 66.7274" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M152.791 71.0875C152.868 69.9526 153.852 69.0422 154.991 69.0652" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M163.089 63.1C163.012 64.2349 162.085 65.0949 161.051 65.1333" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M163.352 67.4612L161.19 67.3145" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M160.611 69.4468C161.743 69.5779 162.595 70.613 162.518 71.7479" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip48_532_2318)">
+<path d="M181.132 62.6962L182.079 63.7812" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M184.371 63.9367L185.456 62.9897" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M181.485 65.5053L181.521 64.9648C181.526 64.7452 181.576 64.5288 181.667 64.3288C181.758 64.1288 181.888 63.9493 182.051 63.8011C182.213 63.653 182.404 63.5392 182.611 63.4668C182.818 63.3943 183.038 63.3646 183.258 63.3795C183.477 63.3944 183.691 63.4535 183.887 63.5534C184.082 63.6532 184.256 63.7917 184.397 63.9604C184.537 64.1291 184.642 64.3246 184.706 64.5351C184.769 64.7456 184.788 64.9666 184.764 65.185L184.727 65.7254" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M182.634 72.5706C180.85 72.4495 179.49 70.8913 179.611 69.1079L179.721 67.4866C179.76 66.9133 180.025 66.379 180.458 66.0011C180.891 65.6232 181.456 65.4328 182.03 65.4717L184.191 65.6185C184.765 65.6574 185.299 65.9225 185.677 66.3554C186.055 66.7883 186.245 67.3536 186.206 67.9269L186.096 69.5482C185.975 71.3316 184.417 72.6916 182.634 72.5706Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M182.634 72.5706L182.964 67.7068" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M180.081 66.4252C179.045 66.2463 178.243 65.2689 178.32 64.134" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M179.648 68.5675L177.486 68.4207" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M177.733 72.7808C177.81 71.6459 178.795 70.7355 179.934 70.7585" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M188.032 64.7933C187.955 65.9282 187.028 66.7882 185.993 66.8266" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M188.295 69.1545L186.133 69.0078" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M185.554 71.1401C186.685 71.2712 187.538 72.3063 187.461 73.4412" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip49_532_2318)">
+<path d="M206.075 64.3895L207.022 65.4745" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M209.313 65.63L210.398 64.683" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M206.427 67.1986L206.464 66.6581C206.469 66.4385 206.518 66.2221 206.609 66.0221C206.7 65.8221 206.831 65.6426 206.993 65.4944C207.156 65.3463 207.346 65.2325 207.554 65.1601C207.761 65.0876 207.981 65.0579 208.2 65.0728C208.419 65.0877 208.633 65.1468 208.829 65.2467C209.025 65.3465 209.198 65.485 209.339 65.6537C209.48 65.8224 209.585 66.0179 209.648 66.2284C209.711 66.4389 209.731 66.6599 209.706 66.8783L209.67 67.4187" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M207.576 74.2639C205.793 74.1428 204.433 72.5846 204.554 70.8012L204.664 69.1799C204.703 68.6066 204.968 68.0723 205.401 67.6944C205.834 67.3165 206.399 67.1261 206.972 67.165L209.134 67.3118C209.707 67.3507 210.242 67.6158 210.62 68.0487C210.997 68.4816 211.188 69.0469 211.149 69.6202L211.039 71.2415C210.918 73.0249 209.36 74.3849 207.576 74.2639Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M207.576 74.2639L207.906 69.4001" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M205.024 68.1185C203.988 67.9396 203.186 66.9622 203.263 65.8273" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M204.591 70.2608L202.429 70.114" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M202.676 74.4741C202.753 73.3392 203.738 72.4288 204.876 72.4518" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M212.974 66.4866C212.897 67.6215 211.97 68.4815 210.936 68.5199" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M213.237 70.8478L211.076 70.701" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M210.497 72.8334C211.628 72.9645 212.48 73.9996 212.403 75.1345" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip50_532_2318)">
+<path d="M231.018 66.0829L231.965 67.1678" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M234.256 67.3234L235.341 66.3763" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M231.37 68.8919L231.406 68.3515C231.412 68.1318 231.461 67.9154 231.552 67.7154C231.643 67.5154 231.774 67.3359 231.936 67.1878C232.098 67.0396 232.289 66.9259 232.496 66.8534C232.704 66.7809 232.924 66.7512 233.143 66.7661C233.362 66.781 233.576 66.8401 233.772 66.94C233.967 67.0398 234.141 67.1783 234.282 67.347C234.422 67.5157 234.528 67.7112 234.591 67.9217C234.654 68.1322 234.674 68.3533 234.649 68.5716L234.612 69.112" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M232.519 75.9572C230.735 75.8361 229.375 74.2779 229.496 72.4945L229.607 70.8733C229.645 70.3 229.911 69.7656 230.343 69.3877C230.776 69.0098 231.342 68.8194 231.915 68.8583L234.077 69.0051C234.65 69.044 235.184 69.3091 235.562 69.742C235.94 70.1749 236.131 70.7402 236.092 71.3135L235.982 72.9348C235.86 74.7182 234.302 76.0783 232.519 75.9572Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M232.519 75.9572L232.849 71.0934" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M229.966 69.8119C228.931 69.633 228.128 68.6555 228.205 67.5207" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M229.533 71.9541L227.371 71.8074" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M227.618 76.1674C227.695 75.0325 228.68 74.1221 229.819 74.1452" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M237.917 68.18C237.84 69.3148 236.913 70.1749 235.879 70.2132" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M238.18 72.5411L236.018 72.3944" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M235.439 74.5267C236.57 74.6578 237.423 75.693 237.346 76.8278" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip51_532_2318)">
+<path d="M255.96 67.7762L256.907 68.8611" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M259.199 69.0167L260.284 68.0696" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M256.312 70.5852L256.349 70.0448C256.354 69.8251 256.404 69.6087 256.495 69.4087C256.586 69.2087 256.716 69.0292 256.878 68.8811C257.041 68.7329 257.231 68.6192 257.439 68.5467C257.646 68.4742 257.866 68.4445 258.085 68.4594C258.305 68.4743 258.519 68.5334 258.714 68.6333C258.91 68.7331 259.084 68.8716 259.224 69.0403C259.365 69.209 259.47 69.4045 259.533 69.615C259.596 69.8255 259.616 70.0466 259.592 70.2649L259.555 70.8053" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M257.461 77.6505C255.678 77.5294 254.318 75.9712 254.439 74.1878L254.549 72.5666C254.588 71.9933 254.853 71.4589 255.286 71.081C255.719 70.7031 256.284 70.5127 256.858 70.5516L259.019 70.6984C259.593 70.7373 260.127 71.0024 260.505 71.4353C260.883 71.8682 261.073 72.4335 261.034 73.0068L260.924 74.6281C260.803 76.4115 259.245 77.7716 257.461 77.6505Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M257.461 77.6505L257.792 72.7867" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M254.909 71.5052C253.873 71.3263 253.071 70.3488 253.148 69.214" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M254.476 73.6474L252.314 73.5007" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M252.561 77.8607C252.638 76.7258 253.623 75.8154 254.761 75.8385" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M262.859 69.8733C262.782 71.0081 261.855 71.8682 260.821 71.9065" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M263.123 74.2344L260.961 74.0877" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M260.382 76.22C261.513 76.3511 262.366 77.3863 262.289 78.5211" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip52_532_2318)">
+<path d="M280.903 69.4695L281.85 70.5545" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M284.141 70.71L285.226 69.763" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M281.255 72.2785L281.292 71.7381C281.297 71.5184 281.346 71.3021 281.437 71.102C281.528 70.902 281.659 70.7225 281.821 70.5744C281.983 70.4262 282.174 70.3125 282.381 70.24C282.589 70.1676 282.809 70.1379 283.028 70.1527C283.247 70.1676 283.461 70.2268 283.657 70.3266C283.853 70.4265 284.026 70.5649 284.167 70.7336C284.308 70.9024 284.413 71.0979 284.476 71.3083C284.539 71.5188 284.559 71.7399 284.534 71.9582L284.497 72.4986" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M282.404 79.3438C280.621 79.2228 279.261 77.6646 279.382 75.8812L279.492 74.2599C279.531 73.6866 279.796 73.1522 280.229 72.7743C280.662 72.3965 281.227 72.206 281.8 72.245L283.962 72.3917C284.535 72.4306 285.069 72.6957 285.447 73.1286C285.825 73.5615 286.016 74.1268 285.977 74.7002L285.867 76.3214C285.746 78.1048 284.187 79.4649 282.404 79.3438Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M282.404 79.3438L282.734 74.48" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M279.851 73.1985C278.816 73.0196 278.013 72.0422 278.091 70.9073" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M279.418 75.3408L277.257 75.194" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M277.504 79.5541C277.581 78.4192 278.565 77.5088 279.704 77.5318" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M287.802 71.5666C287.725 72.7015 286.798 73.5615 285.764 73.5999" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M288.065 75.9278L285.903 75.781" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M285.324 77.9134C286.455 78.0444 287.308 79.0796 287.231 80.2145" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip53_532_2318)">
+<path d="M305.845 71.1628L306.792 72.2477" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M309.084 72.4033L310.169 71.4563" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M306.198 73.9718L306.234 73.4314C306.239 73.2117 306.289 72.9954 306.38 72.7953C306.471 72.5953 306.601 72.4158 306.764 72.2677C306.926 72.1195 307.117 72.0058 307.324 71.9333C307.531 71.8609 307.751 71.8312 307.971 71.846C308.19 71.8609 308.404 71.9201 308.6 72.0199C308.795 72.1198 308.969 72.2582 309.109 72.4269C309.25 72.5957 309.355 72.7912 309.418 73.0016C309.482 73.2121 309.501 73.4332 309.477 73.6515L309.44 74.1919" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M307.347 81.0371C305.563 80.9161 304.203 79.3579 304.324 77.5745L304.434 75.9532C304.473 75.3799 304.738 74.8455 305.171 74.4676C305.604 74.0898 306.169 73.8993 306.743 73.9383L308.904 74.085C309.478 74.1239 310.012 74.389 310.39 74.8219C310.768 75.2548 310.958 75.8201 310.919 76.3935L310.809 78.0147C310.688 79.7981 309.13 81.1582 307.347 81.0371Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M307.347 81.0371L307.677 76.1733" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M304.794 74.8918C303.758 74.7129 302.956 73.7355 303.033 72.6006" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M304.361 77.0341L302.199 76.8873" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M302.446 81.2474C302.523 80.1125 303.508 79.2021 304.647 79.2251" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M312.745 73.2599C312.668 74.3948 311.74 75.2548 310.706 75.2932" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M313.008 77.6211L310.846 77.4743" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M310.267 79.6067C311.398 79.7377 312.251 80.7729 312.174 81.9078" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip54_532_2318)">
+<path d="M330.788 72.8561L331.735 73.941" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M334.026 74.0966L335.111 73.1496" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M331.14 75.6651L331.177 75.1247C331.182 74.905 331.231 74.6887 331.322 74.4886C331.413 74.2886 331.544 74.1091 331.706 73.961C331.868 73.8128 332.059 73.6991 332.267 73.6266C332.474 73.5541 332.694 73.5245 332.913 73.5393C333.132 73.5542 333.346 73.6134 333.542 73.7132C333.738 73.813 333.911 73.9515 334.052 74.1202C334.193 74.289 334.298 74.4845 334.361 74.6949C334.424 74.9054 334.444 75.1265 334.419 75.3448L334.383 75.8852" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M332.289 82.7304C330.506 82.6094 329.146 81.0512 329.267 79.2678L329.377 77.6465C329.416 77.0732 329.681 76.5388 330.114 76.1609C330.547 75.7831 331.112 75.5926 331.685 75.6316L333.847 75.7783C334.42 75.8172 334.955 76.0823 335.333 76.5152C335.71 76.9481 335.901 77.5134 335.862 78.0868L335.752 79.708C335.631 81.4914 334.073 82.8515 332.289 82.7304Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M332.289 82.7304L332.619 77.8666" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M329.737 76.5851C328.701 76.4062 327.899 75.4288 327.976 74.2939" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M329.303 78.7274L327.142 78.5806" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M327.389 82.9407C327.466 81.8058 328.451 80.8954 329.589 80.9184" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M337.687 74.9532C337.61 76.0881 336.683 76.9481 335.649 76.9865" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M337.95 79.3144L335.789 79.1676" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M335.209 81.3C336.341 81.431 337.193 82.4662 337.116 83.6011" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g clip-path="url(#clip55_532_2318)">
+<path d="M355.73 74.5494L356.677 75.6344" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M358.969 75.7899L360.054 74.8429" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M356.083 77.3584L356.119 76.818C356.124 76.5983 356.174 76.382 356.265 76.182C356.356 75.982 356.487 75.8025 356.649 75.6543C356.811 75.5062 357.002 75.3924 357.209 75.3199C357.417 75.2475 357.637 75.2178 357.856 75.2327C358.075 75.2476 358.289 75.3067 358.485 75.4065C358.68 75.5064 358.854 75.6448 358.995 75.8136C359.135 75.9823 359.24 76.1778 359.304 76.3883C359.367 76.5987 359.387 76.8198 359.362 77.0382L359.325 77.5786" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M357.232 84.4238C355.448 84.3027 354.088 82.7445 354.209 80.9611L354.319 79.3398C354.358 78.7665 354.623 78.2321 355.056 77.8543C355.489 77.4764 356.055 77.286 356.628 77.3249L358.79 77.4716C359.363 77.5106 359.897 77.7756 360.275 78.2086C360.653 78.6415 360.843 79.2068 360.805 79.7801L360.694 81.4014C360.573 83.1847 359.015 84.5448 357.232 84.4238Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M357.232 84.4238L357.562 79.56" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M354.679 78.2784C353.644 78.0995 352.841 77.1221 352.918 75.9872" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M354.246 80.4207L352.084 80.2739" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M352.331 84.634C352.408 83.4991 353.393 82.5887 354.532 82.6117" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M362.63 76.6465C362.553 77.7814 361.626 78.6414 360.591 78.6798" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M362.893 81.0077L360.731 80.8609" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M360.152 82.9933C361.283 83.1244 362.136 84.1595 362.059 85.2944" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<g opacity="0.6" clip-path="url(#clip56_532_2318)">
+<path d="M380.673 76.2427L381.62 77.3277" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M383.911 77.4832L384.996 76.5362" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M381.025 79.0517L381.062 78.5113C381.067 78.2916 381.117 78.0753 381.208 77.8753C381.299 77.6753 381.429 77.4958 381.591 77.3476C381.754 77.1995 381.944 77.0857 382.152 77.0132C382.359 76.9408 382.579 76.9111 382.798 76.926C383.018 76.9409 383.232 77 383.427 77.0998C383.623 77.1997 383.796 77.3381 383.937 77.5069C384.078 77.6756 384.183 77.8711 384.246 78.0816C384.309 78.292 384.329 78.5131 384.305 78.7314L384.268 79.2719" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M382.174 86.1171C380.391 85.996 379.031 84.4378 379.152 82.6544L379.262 81.0331C379.301 80.4598 379.566 79.9254 379.999 79.5476C380.432 79.1697 380.997 78.9793 381.571 79.0182L383.732 79.1649C384.306 79.2039 384.84 79.4689 385.218 79.9019C385.596 80.3348 385.786 80.9001 385.747 81.4734L385.637 83.0946C385.516 84.878 383.958 86.2381 382.174 86.1171Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M382.174 86.1171L382.505 81.2533" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M379.622 79.9717C378.586 79.7928 377.784 78.8154 377.861 77.6805" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M379.189 82.114L377.027 81.9672" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M377.274 86.3273C377.351 85.1924 378.336 84.282 379.474 84.305" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M387.572 78.3398C387.495 79.4747 386.568 80.3347 385.534 80.3731" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M387.835 82.701L385.674 82.5542" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M385.095 84.6866C386.226 84.8177 387.079 85.8528 387.002 86.9877" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+</g>
+</g>
+<defs>
+<clipPath id="clip0_532_2318">
+<rect width="400" height="92" fill="white"/>
+</clipPath>
+<clipPath id="clip1_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(56.4365 -10) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip2_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(81.3792 -8.3067) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip3_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(106.322 -6.6134) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip4_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(131.264 -4.92004) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip5_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(156.207 -3.22675) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip6_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(181.149 -1.53345) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip7_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(206.092 0.159851) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip8_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(231.035 1.85315) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip9_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(255.977 3.54651) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip10_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(280.92 5.23981) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip11_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(305.862 6.93311) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip12_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(330.805 8.6264) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip13_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(355.748 10.3197) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip14_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(380.69 12.0131) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip15_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(55.0142 10.9518) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip16_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(79.9568 12.6451) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip17_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(104.899 14.3384) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip18_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(129.842 16.0317) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip19_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(154.785 17.725) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip20_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(179.727 19.4183) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip21_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(204.67 21.1116) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip22_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(229.612 22.8049) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip23_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(254.555 24.4983) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip24_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(279.497 26.1916) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip25_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(304.44 27.8849) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip26_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(329.383 29.5782) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip27_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(354.325 31.2715) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip28_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(379.268 32.9648) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip29_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(53.5918 31.9036) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip30_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(78.5344 33.5969) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip31_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(103.477 35.2902) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip32_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(128.42 36.9835) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip33_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(153.362 38.6768) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip34_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(178.305 40.3701) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip35_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(203.247 42.0634) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip36_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(228.19 43.7567) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip37_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(253.133 45.4501) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip38_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(278.075 47.1434) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip39_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(303.018 48.8367) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip40_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(327.96 50.53) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip41_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(352.903 52.2233) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip42_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(377.845 53.9166) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip43_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(52.1694 52.8553) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip44_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(77.1121 54.5486) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip45_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(102.055 56.2419) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip46_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(126.997 57.9352) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip47_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(151.94 59.6286) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip48_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(176.882 61.3219) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip49_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(201.825 63.0152) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip50_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(226.768 64.7085) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip51_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(251.71 66.4018) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip52_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(276.653 68.0951) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip53_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(301.595 69.7884) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip54_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(326.538 71.4817) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip55_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(351.48 73.175) rotate(3.88375)"/>
+</clipPath>
+<clipPath id="clip56_532_2318">
+<rect width="13" height="13" fill="white" transform="translate(376.423 74.8683) rotate(3.88375)"/>
+</clipPath>
+</defs>
+</svg>

assets/keymaps/default-linux.json 🔗

@@ -10,8 +10,10 @@
       "pagedown": "menu::SelectLast",
       "ctrl-n": "menu::SelectNext",
       "tab": "menu::SelectNext",
+      "down": "menu::SelectNext",
       "ctrl-p": "menu::SelectPrevious",
       "shift-tab": "menu::SelectPrevious",
+      "up": "menu::SelectPrevious",
       "enter": "menu::Confirm",
       "ctrl-enter": "menu::SecondaryConfirm",
       "ctrl-escape": "menu::Cancel",
@@ -31,15 +33,16 @@
       "ctrl-,": "zed::OpenSettings",
       "ctrl-q": "zed::Quit",
       "f4": "debugger::Start",
-      "f5": "debugger::Continue",
       "shift-f5": "debugger::Stop",
+      "ctrl-shift-f5": "debugger::Restart",
       "f6": "debugger::Pause",
       "f7": "debugger::StepOver",
-      "cmd-f11": "debugger::StepInto",
+      "ctrl-f11": "debugger::StepInto",
       "shift-f11": "debugger::StepOut",
       "f11": "zed::ToggleFullScreen",
       "ctrl-alt-z": "edit_prediction::RateCompletions",
-      "ctrl-shift-i": "edit_prediction::ToggleMenu"
+      "ctrl-shift-i": "edit_prediction::ToggleMenu",
+      "ctrl-alt-l": "lsp_tool::ToggleMenu"
     }
   },
   {
@@ -59,7 +62,6 @@
       "tab": "editor::Tab",
       "shift-tab": "editor::Backtab",
       "ctrl-k": "editor::CutToEndOfLine",
-      // "ctrl-t": "editor::Transpose",
       "ctrl-k ctrl-q": "editor::Rewrap",
       "ctrl-k q": "editor::Rewrap",
       "ctrl-backspace": "editor::DeleteToPreviousWordStart",
@@ -100,34 +102,28 @@
       "shift-down": "editor::SelectDown",
       "shift-left": "editor::SelectLeft",
       "shift-right": "editor::SelectRight",
-      "ctrl-shift-left": "editor::SelectToPreviousWordStart", // cursorWordLeftSelect
-      "ctrl-shift-right": "editor::SelectToNextWordEnd", // cursorWordRightSelect
+      "ctrl-shift-left": "editor::SelectToPreviousWordStart",
+      "ctrl-shift-right": "editor::SelectToNextWordEnd",
       "ctrl-shift-home": "editor::SelectToBeginning",
       "ctrl-shift-end": "editor::SelectToEnd",
       "ctrl-a": "editor::SelectAll",
       "ctrl-l": "editor::SelectLine",
       "ctrl-shift-i": "editor::Format",
       "alt-shift-o": "editor::OrganizeImports",
-      // "cmd-shift-left": ["editor::SelectToBeginningOfLine", {"stop_at_soft_wraps": true, "stop_at_indent": true }],
-      // "ctrl-shift-a": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": true, "stop_at_indent": true }],
       "shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": true, "stop_at_indent": true }],
-      // "cmd-shift-right": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }],
-      // "ctrl-shift-e": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }],
       "shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }],
-      // "alt-v": ["editor::MovePageUp", { "center_cursor": true }],
       "ctrl-alt-space": "editor::ShowCharacterPalette",
       "ctrl-;": "editor::ToggleLineNumbers",
       "ctrl-'": "editor::ToggleSelectedDiffHunks",
       "ctrl-\"": "editor::ExpandAllDiffHunks",
       "ctrl-i": "editor::ShowSignatureHelp",
-      "alt-g b": "editor::ToggleGitBlame",
+      "alt-g b": "git::Blame",
+      "alt-g m": "git::OpenModifiedFiles",
       "menu": "editor::OpenContextMenu",
       "shift-f10": "editor::OpenContextMenu",
       "ctrl-shift-e": "editor::ToggleEditPrediction",
       "f9": "editor::ToggleBreakpoint",
-      "shift-f9": "editor::EditLogBreakpoint",
-      "ctrl-shift-backspace": "editor::GoToPreviousChange",
-      "ctrl-shift-alt-backspace": "editor::GoToNextChange"
+      "shift-f9": "editor::EditLogBreakpoint"
     }
   },
   {
@@ -142,10 +138,11 @@
       "find": "buffer_search::Deploy",
       "ctrl-f": "buffer_search::Deploy",
       "ctrl-h": "buffer_search::DeployReplace",
-      // "cmd-e": ["buffer_search::Deploy", { "focus": false }],
       "ctrl->": "assistant::QuoteSelection",
       "ctrl-<": "assistant::InsertIntoEditor",
       "ctrl-alt-e": "editor::SelectEnclosingSymbol",
+      "ctrl-shift-backspace": "editor::GoToPreviousChange",
+      "ctrl-shift-alt-backspace": "editor::GoToNextChange",
       "alt-enter": "editor::OpenSelectionsInMultibuffer"
     }
   },
@@ -153,8 +150,7 @@
     "context": "Editor && mode == full && edit_prediction",
     "bindings": {
       "alt-]": "editor::NextEditPrediction",
-      "alt-[": "editor::PreviousEditPrediction",
-      "alt-right": "editor::AcceptPartialEditPrediction"
+      "alt-[": "editor::PreviousEditPrediction"
     }
   },
   {
@@ -219,7 +215,6 @@
       "ctrl-enter": "assistant::Assist",
       "ctrl-s": "workspace::Save",
       "save": "workspace::Save",
-      "ctrl->": "assistant::QuoteSelection",
       "ctrl-<": "assistant::InsertIntoEditor",
       "shift-enter": "assistant::Split",
       "ctrl-r": "assistant::CycleMessageRole",
@@ -242,11 +237,15 @@
       "ctrl-i": "agent::ToggleProfileSelector",
       "ctrl-alt-/": "agent::ToggleModelSelector",
       "ctrl-shift-a": "agent::ToggleContextPicker",
-      "ctrl-shift-o": "agent::ToggleNavigationMenu",
+      "ctrl-shift-j": "agent::ToggleNavigationMenu",
       "ctrl-shift-i": "agent::ToggleOptionsMenu",
-      "shift-escape": "agent::ExpandMessageEditor",
+      "shift-alt-escape": "agent::ExpandMessageEditor",
+      "ctrl->": "assistant::QuoteSelection",
       "ctrl-alt-e": "agent::RemoveAllContext",
-      "ctrl-shift-e": "project_panel::ToggleFocus"
+      "ctrl-shift-e": "project_panel::ToggleFocus",
+      "ctrl-shift-enter": "agent::ContinueThread",
+      "super-ctrl-b": "agent::ToggleBurnMode",
+      "alt-enter": "agent::ContinueWithBurnMode"
     }
   },
   {
@@ -265,16 +264,19 @@
   {
     "context": "AgentPanel && prompt_editor",
     "bindings": {
-      "cmd-n": "agent::NewTextThread",
-      "cmd-alt-t": "agent::NewThread"
+      "ctrl-n": "agent::NewTextThread",
+      "ctrl-alt-t": "agent::NewThread"
     }
   },
   {
     "context": "MessageEditor > Editor",
     "bindings": {
       "enter": "agent::Chat",
+      "ctrl-enter": "agent::ChatWithFollow",
       "ctrl-i": "agent::ToggleProfileSelector",
-      "shift-ctrl-r": "agent::OpenAgentDiff"
+      "shift-ctrl-r": "agent::OpenAgentDiff",
+      "ctrl-shift-y": "agent::KeepAll",
+      "ctrl-shift-n": "agent::RejectAll"
     }
   },
   {
@@ -489,13 +491,27 @@
       "ctrl-k r": "editor::RevealInFileManager",
       "ctrl-k p": "editor::CopyPath",
       "ctrl-\\": "pane::SplitRight",
-      "ctrl-k v": "markdown::OpenPreviewToTheSide",
-      "ctrl-shift-v": "markdown::OpenPreview",
       "ctrl-alt-shift-c": "editor::DisplayCursorNames",
       "alt-.": "editor::GoToHunk",
       "alt-,": "editor::GoToPreviousHunk"
     }
   },
+  {
+    "context": "Editor && extension == md",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-k v": "markdown::OpenPreviewToTheSide",
+      "ctrl-shift-v": "markdown::OpenPreview"
+    }
+  },
+  {
+    "context": "Editor && extension == svg",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-k v": "svg::OpenPreviewToTheSide",
+      "ctrl-shift-v": "svg::OpenPreview"
+    }
+  },
   {
     "context": "Editor && mode == full",
     "bindings": {
@@ -506,12 +522,14 @@
   {
     "context": "Workspace",
     "bindings": {
+      "alt-open": ["projects::OpenRecent", { "create_new_window": false }],
       // Change the default action on `menu::Confirm` by setting the parameter
       // "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": true }],
-      "alt-open": "projects::OpenRecent",
-      "alt-ctrl-o": "projects::OpenRecent",
-      "alt-shift-open": "projects::OpenRemote",
-      "alt-ctrl-shift-o": "projects::OpenRemote",
+      "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": false }],
+      "alt-shift-open": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
+      // Change to open path modal for existing remote connection by setting the parameter
+      // "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]",
+      "alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
       "alt-ctrl-shift-b": "branches::OpenRecent",
       "alt-shift-enter": "toast::RunAction",
       "ctrl-~": "workspace::NewTerminal",
@@ -556,6 +574,7 @@
       "ctrl-shift-e": "project_panel::ToggleFocus",
       "ctrl-shift-b": "outline_panel::ToggleFocus",
       "ctrl-shift-g": "git_panel::ToggleFocus",
+      "ctrl-shift-d": "debug_panel::ToggleFocus",
       "ctrl-?": "agent::ToggleFocus",
       "alt-save": "workspace::SaveAll",
       "ctrl-alt-s": "workspace::SaveAll",
@@ -574,11 +593,24 @@
       "ctrl-alt-r": "task::Rerun",
       "alt-t": "task::Rerun",
       "alt-shift-t": "task::Spawn",
-      "alt-shift-r": ["task::Spawn", { "reveal_target": "center" }]
+      "alt-shift-r": ["task::Spawn", { "reveal_target": "center" }],
       // also possible to spawn tasks by name:
       // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }]
       // or by tag:
       // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }],
+      "f5": "debugger::RerunLastSession"
+    }
+  },
+  {
+    "context": "Workspace && debugger_running",
+    "bindings": {
+      "f5": "zed::NoAction"
+    }
+  },
+  {
+    "context": "Workspace && debugger_stopped",
+    "bindings": {
+      "f5": "debugger::Continue"
     }
   },
   {
@@ -593,7 +625,6 @@
   {
     "context": "Editor",
     "bindings": {
-      "ctrl-shift-d": "editor::DuplicateLineDown",
       "ctrl-shift-j": "editor::JoinLines",
       "ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart",
       "ctrl-alt-h": "editor::DeleteToPreviousSubwordStart",
@@ -641,14 +672,16 @@
     "bindings": {
       "alt-tab": "editor::AcceptEditPrediction",
       "alt-l": "editor::AcceptEditPrediction",
-      "tab": "editor::AcceptEditPrediction"
+      "tab": "editor::AcceptEditPrediction",
+      "alt-right": "editor::AcceptPartialEditPrediction"
     }
   },
   {
     "context": "Editor && edit_prediction_conflict",
     "bindings": {
       "alt-tab": "editor::AcceptEditPrediction",
-      "alt-l": "editor::AcceptEditPrediction"
+      "alt-l": "editor::AcceptEditPrediction",
+      "alt-right": "editor::AcceptPartialEditPrediction"
     }
   },
   {
@@ -672,7 +705,8 @@
   {
     "bindings": {
       "ctrl-alt-shift-f": "workspace::FollowNextCollaborator",
-      "ctrl-alt-i": "zed::DebugElements"
+      // Only available in debug builds: opens an element inspector for development.
+      "ctrl-alt-i": "dev::ToggleInspector"
     }
   },
   {
@@ -766,7 +800,7 @@
       "alt-ctrl-r": "project_panel::RevealInFileManager",
       "ctrl-shift-enter": "project_panel::OpenWithSystem",
       "shift-find": "project_panel::NewSearchInDirectory",
-      "ctrl-shift-f": "project_panel::NewSearchInDirectory",
+      "ctrl-alt-shift-f": "project_panel::NewSearchInDirectory",
       "shift-down": "menu::SelectNext",
       "shift-up": "menu::SelectPrevious",
       "escape": "menu::Cancel"
@@ -860,11 +894,43 @@
       "alt-l": "git::GenerateCommitMessage"
     }
   },
+  {
+    "context": "DebugPanel",
+    "bindings": {
+      "ctrl-t": "debugger::ToggleThreadPicker",
+      "ctrl-i": "debugger::ToggleSessionPicker",
+      "shift-alt-escape": "debugger::ToggleExpandItem"
+    }
+  },
+  {
+    "context": "VariableList",
+    "bindings": {
+      "left": "variable_list::CollapseSelectedEntry",
+      "right": "variable_list::ExpandSelectedEntry",
+      "enter": "variable_list::EditVariable",
+      "ctrl-c": "variable_list::CopyVariableValue",
+      "ctrl-alt-c": "variable_list::CopyVariableName",
+      "delete": "variable_list::RemoveWatch",
+      "backspace": "variable_list::RemoveWatch",
+      "alt-enter": "variable_list::AddWatch"
+    }
+  },
+  {
+    "context": "BreakpointList",
+    "bindings": {
+      "space": "debugger::ToggleEnableBreakpoint",
+      "backspace": "debugger::UnsetBreakpoint",
+      "left": "debugger::PreviousBreakpointProperty",
+      "right": "debugger::NextBreakpointProperty"
+    }
+  },
   {
     "context": "CollabPanel && not_editing",
     "bindings": {
       "ctrl-backspace": "collab_panel::Remove",
-      "space": "menu::Confirm"
+      "space": "menu::Confirm",
+      "ctrl-up": "collab_panel::MoveChannelUp",
+      "ctrl-down": "collab_panel::MoveChannelDown"
     }
   },
   {
@@ -895,6 +961,13 @@
       "tab": "channel_modal::ToggleMode"
     }
   },
+  {
+    "context": "FileFinder || (FileFinder > Picker > Editor)",
+    "bindings": {
+      "ctrl-shift-a": "file_finder::ToggleSplitMenu",
+      "ctrl-shift-i": "file_finder::ToggleFilterMenu"
+    }
+  },
   {
     "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)",
     "bindings": {
@@ -928,6 +1001,7 @@
       "alt-b": ["terminal::SendText", "\u001bb"],
       "alt-f": ["terminal::SendText", "\u001bf"],
       "alt-.": ["terminal::SendText", "\u001b."],
+      "ctrl-delete": ["terminal::SendText", "\u001bd"],
       // Overrides for conflicting keybindings
       "ctrl-b": ["terminal::SendKeystroke", "ctrl-b"],
       "ctrl-c": ["terminal::SendKeystroke", "ctrl-c"],
@@ -978,5 +1052,20 @@
     "bindings": {
       "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh"
     }
+  },
+  {
+    "context": "DebugConsole > Editor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "enter": "menu::Confirm",
+      "alt-enter": "console::WatchExpression"
+    }
+  },
+  {
+    "context": "RunModal",
+    "bindings": {
+      "ctrl-tab": "pane::ActivateNextItem",
+      "ctrl-shift-tab": "pane::ActivatePreviousItem"
+    }
   }
 ]

assets/keymaps/default-macos.json 🔗

@@ -1,22 +1,11 @@
 [
-  // Moved before Standard macOS bindings so that `cmd-w` is not the last binding for
-  // `workspace::CloseWindow` and displayed/intercepted by macOS
-  {
-    "context": "PromptLibrary",
-    "use_key_equivalents": true,
-    "bindings": {
-      "cmd-n": "rules_library::NewRule",
-      "cmd-shift-s": "rules_library::ToggleDefaultRule",
-      "cmd-w": "workspace::CloseWindow"
-    }
-  },
   // Standard macOS bindings
   {
     "use_key_equivalents": true,
     "bindings": {
       "f4": "debugger::Start",
-      "f5": "debugger::Continue",
       "shift-f5": "debugger::Stop",
+      "shift-cmd-f5": "debugger::Restart",
       "f6": "debugger::Pause",
       "f7": "debugger::StepOver",
       "f11": "debugger::StepInto",
@@ -58,7 +47,8 @@
       "fn-f": "zed::ToggleFullScreen",
       "ctrl-cmd-f": "zed::ToggleFullScreen",
       "ctrl-cmd-z": "edit_prediction::RateCompletions",
-      "ctrl-cmd-i": "edit_prediction::ToggleMenu"
+      "ctrl-cmd-i": "edit_prediction::ToggleMenu",
+      "ctrl-cmd-l": "lsp_tool::ToggleMenu"
     }
   },
   {
@@ -149,7 +139,8 @@
       "cmd-;": "editor::ToggleLineNumbers",
       "cmd-'": "editor::ToggleSelectedDiffHunks",
       "cmd-\"": "editor::ExpandAllDiffHunks",
-      "cmd-alt-g b": "editor::ToggleGitBlame",
+      "cmd-alt-g b": "git::Blame",
+      "cmd-alt-g m": "git::OpenModifiedFiles",
       "cmd-i": "editor::ShowSignatureHelp",
       "f9": "editor::ToggleBreakpoint",
       "shift-f9": "editor::EditLogBreakpoint",
@@ -192,8 +183,7 @@
     "use_key_equivalents": true,
     "bindings": {
       "alt-tab": "editor::NextEditPrediction",
-      "alt-shift-tab": "editor::PreviousEditPrediction",
-      "ctrl-cmd-right": "editor::AcceptPartialEditPrediction"
+      "alt-shift-tab": "editor::PreviousEditPrediction"
     }
   },
   {
@@ -264,7 +254,6 @@
     "bindings": {
       "cmd-enter": "assistant::Assist",
       "cmd-s": "workspace::Save",
-      "cmd->": "assistant::QuoteSelection",
       "cmd-<": "assistant::InsertIntoEditor",
       "shift-enter": "assistant::Split",
       "ctrl-r": "assistant::CycleMessageRole",
@@ -288,11 +277,15 @@
       "cmd-i": "agent::ToggleProfileSelector",
       "cmd-alt-/": "agent::ToggleModelSelector",
       "cmd-shift-a": "agent::ToggleContextPicker",
-      "cmd-shift-o": "agent::ToggleNavigationMenu",
+      "cmd-shift-j": "agent::ToggleNavigationMenu",
       "cmd-shift-i": "agent::ToggleOptionsMenu",
-      "shift-escape": "agent::ExpandMessageEditor",
+      "shift-alt-escape": "agent::ExpandMessageEditor",
+      "cmd->": "assistant::QuoteSelection",
       "cmd-alt-e": "agent::RemoveAllContext",
-      "cmd-shift-e": "project_panel::ToggleFocus"
+      "cmd-shift-e": "project_panel::ToggleFocus",
+      "cmd-ctrl-b": "agent::ToggleBurnMode",
+      "cmd-shift-enter": "agent::ContinueThread",
+      "alt-enter": "agent::ContinueWithBurnMode"
     }
   },
   {
@@ -321,8 +314,11 @@
     "use_key_equivalents": true,
     "bindings": {
       "enter": "agent::Chat",
+      "cmd-enter": "agent::ChatWithFollow",
       "cmd-i": "agent::ToggleProfileSelector",
-      "shift-ctrl-r": "agent::OpenAgentDiff"
+      "shift-ctrl-r": "agent::OpenAgentDiff",
+      "cmd-shift-y": "agent::KeepAll",
+      "cmd-shift-n": "agent::RejectAll"
     }
   },
   {
@@ -368,15 +364,18 @@
     }
   },
   {
-    "context": "ThreadHistory",
+    "context": "ThreadHistory > Editor",
     "bindings": {
-      "ctrl--": "pane::GoBack"
+      "shift-backspace": "agent::RemoveSelectedThread"
     }
   },
   {
-    "context": "ThreadHistory > Editor",
+    "context": "PromptLibrary",
+    "use_key_equivalents": true,
     "bindings": {
-      "shift-backspace": "agent::RemoveSelectedThread"
+      "cmd-n": "rules_library::NewRule",
+      "cmd-shift-s": "rules_library::ToggleDefaultRule",
+      "cmd-w": "workspace::CloseWindow"
     }
   },
   {
@@ -546,11 +545,23 @@
       "cmd-k r": "editor::RevealInFileManager",
       "cmd-k p": "editor::CopyPath",
       "cmd-\\": "pane::SplitRight",
+      "ctrl-cmd-c": "editor::DisplayCursorNames"
+    }
+  },
+  {
+    "context": "Editor && extension == md",
+    "use_key_equivalents": true,
+    "bindings": {
       "cmd-k v": "markdown::OpenPreviewToTheSide",
-      "cmd-shift-v": "markdown::OpenPreview",
-      "ctrl-cmd-c": "editor::DisplayCursorNames",
-      "cmd-shift-backspace": "editor::GoToPreviousChange",
-      "cmd-shift-alt-backspace": "editor::GoToNextChange"
+      "cmd-shift-v": "markdown::OpenPreview"
+    }
+  },
+  {
+    "context": "Editor && extension == svg",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-k v": "svg::OpenPreviewToTheSide",
+      "cmd-shift-v": "svg::OpenPreview"
     }
   },
   {
@@ -558,7 +569,9 @@
     "use_key_equivalents": true,
     "bindings": {
       "cmd-shift-o": "outline::Toggle",
-      "ctrl-g": "go_to_line::Toggle"
+      "ctrl-g": "go_to_line::Toggle",
+      "cmd-shift-backspace": "editor::GoToPreviousChange",
+      "cmd-shift-alt-backspace": "editor::GoToNextChange"
     }
   },
   {
@@ -586,9 +599,10 @@
     "bindings": {
       // Change the default action on `menu::Confirm` by setting the parameter
       // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }],
-      "alt-cmd-o": "projects::OpenRecent",
-      "ctrl-cmd-o": "projects::OpenRemote",
-      "alt-cmd-b": "branches::OpenRecent",
+      "alt-cmd-o": ["projects::OpenRecent", { "create_new_window": false }],
+      "ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
+      "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }],
+      "cmd-ctrl-b": "branches::OpenRecent",
       "ctrl-~": "workspace::NewTerminal",
       "cmd-s": "workspace::Save",
       "cmd-k s": "workspace::SaveWithoutFormat",
@@ -606,6 +620,7 @@
       "cmd-8": ["workspace::ActivatePane", 7],
       "cmd-9": ["workspace::ActivatePane", 8],
       "cmd-b": "workspace::ToggleLeftDock",
+      "cmd-alt-b": "workspace::ToggleRightDock",
       "cmd-r": "workspace::ToggleRightDock",
       "cmd-j": "workspace::ToggleBottomDock",
       "alt-cmd-y": "workspace::CloseAllDocks",
@@ -623,6 +638,7 @@
       "cmd-shift-e": "project_panel::ToggleFocus",
       "cmd-shift-b": "outline_panel::ToggleFocus",
       "ctrl-shift-g": "git_panel::ToggleFocus",
+      "cmd-shift-d": "debug_panel::ToggleFocus",
       "cmd-?": "agent::ToggleFocus",
       "cmd-alt-s": "workspace::SaveAll",
       "cmd-k m": "language_selector::Toggle",
@@ -635,7 +651,8 @@
       "cmd-k shift-right": "workspace::SwapPaneRight",
       "cmd-k shift-up": "workspace::SwapPaneUp",
       "cmd-k shift-down": "workspace::SwapPaneDown",
-      "cmd-shift-x": "zed::Extensions"
+      "cmd-shift-x": "zed::Extensions",
+      "f5": "debugger::RerunLastSession"
     }
   },
   {
@@ -652,6 +669,20 @@
       // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }],
     }
   },
+  {
+    "context": "Workspace && debugger_running",
+    "use_key_equivalents": true,
+    "bindings": {
+      "f5": "zed::NoAction"
+    }
+  },
+  {
+    "context": "Workspace && debugger_stopped",
+    "use_key_equivalents": true,
+    "bindings": {
+      "f5": "debugger::Continue"
+    }
+  },
   // Bindings from Sublime Text
   {
     "context": "Editor",
@@ -704,14 +735,16 @@
     "context": "Editor && edit_prediction",
     "bindings": {
       "alt-tab": "editor::AcceptEditPrediction",
-      "tab": "editor::AcceptEditPrediction"
+      "tab": "editor::AcceptEditPrediction",
+      "ctrl-cmd-right": "editor::AcceptPartialEditPrediction"
     }
   },
   {
     "context": "Editor && edit_prediction_conflict",
     "use_key_equivalents": true,
     "bindings": {
-      "alt-tab": "editor::AcceptEditPrediction"
+      "alt-tab": "editor::AcceptEditPrediction",
+      "ctrl-cmd-right": "editor::AcceptPartialEditPrediction"
     }
   },
   {
@@ -740,7 +773,8 @@
       "ctrl-alt-cmd-f": "workspace::FollowNextCollaborator",
       // TODO: Move this to a dock open action
       "cmd-shift-c": "collab_panel::ToggleFocus",
-      "cmd-alt-i": "zed::DebugElements"
+      // Only available in debug builds: opens an element inspector for development.
+      "cmd-alt-i": "dev::ToggleInspector"
     }
   },
   {
@@ -825,7 +859,7 @@
       "alt-cmd-r": "project_panel::RevealInFileManager",
       "ctrl-shift-enter": "project_panel::OpenWithSystem",
       "cmd-alt-backspace": ["project_panel::Delete", { "skip_prompt": false }],
-      "cmd-shift-f": "project_panel::NewSearchInDirectory",
+      "cmd-alt-shift-f": "project_panel::NewSearchInDirectory",
       "shift-down": "menu::SelectNext",
       "shift-up": "menu::SelectPrevious",
       "escape": "menu::Cancel"
@@ -843,7 +877,13 @@
     "use_key_equivalents": true,
     "bindings": {
       "left": "variable_list::CollapseSelectedEntry",
-      "right": "variable_list::ExpandSelectedEntry"
+      "right": "variable_list::ExpandSelectedEntry",
+      "enter": "variable_list::EditVariable",
+      "cmd-c": "variable_list::CopyVariableValue",
+      "cmd-alt-c": "variable_list::CopyVariableName",
+      "delete": "variable_list::RemoveWatch",
+      "backspace": "variable_list::RemoveWatch",
+      "alt-enter": "variable_list::AddWatch"
     }
   },
   {
@@ -928,12 +968,31 @@
       "alt-tab": "git::GenerateCommitMessage"
     }
   },
+  {
+    "context": "DebugPanel",
+    "bindings": {
+      "cmd-t": "debugger::ToggleThreadPicker",
+      "cmd-i": "debugger::ToggleSessionPicker",
+      "shift-alt-escape": "debugger::ToggleExpandItem"
+    }
+  },
+  {
+    "context": "BreakpointList",
+    "bindings": {
+      "space": "debugger::ToggleEnableBreakpoint",
+      "backspace": "debugger::UnsetBreakpoint",
+      "left": "debugger::PreviousBreakpointProperty",
+      "right": "debugger::NextBreakpointProperty"
+    }
+  },
   {
     "context": "CollabPanel && not_editing",
     "use_key_equivalents": true,
     "bindings": {
       "ctrl-backspace": "collab_panel::Remove",
-      "space": "menu::Confirm"
+      "space": "menu::Confirm",
+      "cmd-up": "collab_panel::MoveChannelUp",
+      "cmd-down": "collab_panel::MoveChannelDown"
     }
   },
   {
@@ -969,6 +1028,14 @@
       "tab": "channel_modal::ToggleMode"
     }
   },
+  {
+    "context": "FileFinder || (FileFinder > Picker > Editor)",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-shift-a": "file_finder::ToggleSplitMenu",
+      "cmd-shift-i": "file_finder::ToggleFilterMenu"
+    }
+  },
   {
     "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)",
     "use_key_equivalents": true,
@@ -1011,7 +1078,7 @@
       "alt-right": ["terminal::SendText", "\u001bf"],
       "alt-b": ["terminal::SendText", "\u001bb"],
       "alt-f": ["terminal::SendText", "\u001bf"],
-      "alt-.": ["terminal::SendText", "\u001b."],
+      "ctrl-delete": ["terminal::SendText", "\u001bd"],
       // There are conflicting bindings for these keys in the global context.
       // these bindings override them, remove at your own risk:
       "up": ["terminal::SendKeystroke", "up"],
@@ -1084,5 +1151,21 @@
     "bindings": {
       "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh"
     }
+  },
+  {
+    "context": "DebugConsole > Editor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "enter": "menu::Confirm",
+      "alt-enter": "console::WatchExpression"
+    }
+  },
+  {
+    "context": "RunModal",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-tab": "pane::ActivateNextItem",
+      "ctrl-shift-tab": "pane::ActivatePreviousItem"
+    }
   }
 ]

assets/keymaps/initial.json 🔗

@@ -13,9 +13,9 @@
     }
   },
   {
-    "context": "Editor",
+    "context": "Editor && vim_mode == insert && !menu",
     "bindings": {
-      // "j k": ["workspace::SendKeystrokes", "escape"]
+      // "j k": "vim::SwitchToNormalMode"
     }
   }
 ]

assets/keymaps/linux/atom.json 🔗

@@ -9,6 +9,13 @@
   },
   {
     "context": "Editor",
+    "bindings": {
+      "ctrl-k ctrl-u": "editor::ConvertToUpperCase", // editor:upper-case
+      "ctrl-k ctrl-l": "editor::ConvertToLowerCase" // editor:lower-case
+    }
+  },
+  {
+    "context": "Editor && mode == full",
     "bindings": {
       "ctrl-shift-l": "language_selector::Toggle", // grammar-selector:show
       "ctrl-|": "pane::RevealInProjectPanel", // tree-view:reveal-active-file
@@ -19,25 +26,20 @@
       "shift-f3": ["editor::SelectPrevious", { "replace_newest": true }], //find-and-replace:find-previous
       "alt-shift-down": "editor::AddSelectionBelow", // editor:add-selection-below
       "alt-shift-up": "editor::AddSelectionAbove", // editor:add-selection-above
-      "ctrl-k ctrl-u": "editor::ConvertToUpperCase", // editor:upper-case
-      "ctrl-k ctrl-l": "editor::ConvertToLowerCase", // editor:lower-case
       "ctrl-j": "editor::JoinLines", // editor:join-lines
       "ctrl-shift-d": "editor::DuplicateLineDown", // editor:duplicate-lines
       "ctrl-up": "editor::MoveLineUp", // editor:move-line-up
       "ctrl-down": "editor::MoveLineDown", // editor:move-line-down
       "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle
-      "ctrl-shift-m": "markdown::OpenPreviewToTheSide" // markdown-preview:toggle
-    }
-  },
-  {
-    "context": "Editor && mode == full",
-    "bindings": {
+      "ctrl-shift-m": "markdown::OpenPreviewToTheSide", // markdown-preview:toggle
       "ctrl-r": "outline::Toggle" // symbols-view:toggle-project-symbols
     }
   },
   {
     "context": "BufferSearchBar",
     "bindings": {
+      "f3": ["editor::SelectNext", { "replace_newest": true }], // find-and-replace:find-next
+      "shift-f3": ["editor::SelectPrevious", { "replace_newest": true }], //find-and-replace:find-previous
       "ctrl-f3": "search::SelectNextMatch", // find-and-replace:find-next-selected
       "ctrl-shift-f3": "search::SelectPreviousMatch" // find-and-replace:find-previous-selected
     }

assets/keymaps/linux/cursor.json 🔗

@@ -0,0 +1,83 @@
+[
+  // Cursor for MacOS. See: https://docs.cursor.com/kbd
+  {
+    "context": "Workspace",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-i": "agent::ToggleFocus",
+      "ctrl-shift-i": "agent::ToggleFocus",
+      "ctrl-l": "agent::ToggleFocus",
+      "ctrl-shift-l": "agent::ToggleFocus",
+      "ctrl-shift-j": "agent::OpenConfiguration"
+    }
+  },
+  {
+    "context": "Editor && mode == full",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-i": "agent::ToggleFocus",
+      "ctrl-shift-i": "agent::ToggleFocus",
+      "ctrl-shift-l": "assistant::QuoteSelection", // In cursor uses "Ask" mode
+      "ctrl-l": "assistant::QuoteSelection", // In cursor uses "Agent" mode
+      "ctrl-k": "assistant::InlineAssist",
+      "ctrl-shift-k": "assistant::InsertIntoEditor"
+    }
+  },
+  {
+    "context": "InlineAssistEditor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-shift-backspace": "editor::Cancel"
+      // "alt-enter": // Quick Question
+      // "ctrl-shift-enter": // Full File Context
+      // "ctrl-shift-k": // Toggle input focus (editor <> inline assist)
+    }
+  },
+  {
+    "context": "AgentPanel || ContextEditor || (MessageEditor > Editor)",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-i": "workspace::ToggleRightDock",
+      "ctrl-shift-i": "workspace::ToggleRightDock",
+      "ctrl-l": "workspace::ToggleRightDock",
+      "ctrl-shift-l": "workspace::ToggleRightDock",
+      "ctrl-w": "workspace::ToggleRightDock", // technically should close chat
+      "ctrl-.": "agent::ToggleProfileSelector",
+      "ctrl-/": "agent::ToggleModelSelector",
+      "ctrl-shift-backspace": "editor::Cancel",
+      "ctrl-r": "agent::NewThread",
+      "ctrl-shift-v": "editor::Paste",
+      "ctrl-shift-k": "assistant::InsertIntoEditor"
+      // "escape": "agent::ToggleFocus"
+      ///// Enable when Zed supports multiple thread tabs
+      // "ctrl-t": // new thread tab
+      // "ctrl-[": // next thread tab
+      // "ctrl-]": // next thread tab
+      ///// Enable if Zed adds support for keyboard navigation of thread elements
+      // "tab": // cycle to next message
+      // "shift-tab": // cycle to previous message
+    }
+  },
+  {
+    "context": "Editor && editor_agent_diff",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-enter": "agent::KeepAll",
+      "ctrl-backspace": "agent::RejectAll"
+    }
+  },
+  {
+    "context": "Editor && mode == full && edit_prediction",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-right": "editor::AcceptPartialEditPrediction"
+    }
+  },
+  {
+    "context": "Terminal",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-k": "assistant::InlineAssist"
+    }
+  }
+]

assets/keymaps/linux/emacs.json 🔗

@@ -59,7 +59,8 @@
       "alt->": "editor::MoveToEnd", // end-of-buffer
       "ctrl-l": "editor::ScrollCursorCenterTopBottom", // recenter-top-bottom
       "ctrl-s": "buffer_search::Deploy", // isearch-forward
-      "alt-^": "editor::JoinLines" // join-line
+      "alt-^": "editor::JoinLines", // join-line
+      "alt-q": "editor::Rewrap" // fill-paragraph
     }
   },
   {
@@ -72,7 +73,9 @@
       "alt-left": "editor::SelectToPreviousWordStart",
       "alt-right": "editor::SelectToNextWordEnd",
       "pagedown": "editor::SelectPageDown",
+      "ctrl-v": "editor::SelectPageDown",
       "pageup": "editor::SelectPageUp",
+      "alt-v": "editor::SelectPageUp",
       "ctrl-f": "editor::SelectRight",
       "ctrl-b": "editor::SelectLeft",
       "ctrl-n": "editor::SelectDown",
@@ -88,6 +91,13 @@
       "ctrl-g": "editor::Cancel"
     }
   },
+  {
+    "context": "Editor && (showing_code_actions || showing_completions)",
+    "bindings": {
+      "ctrl-p": "editor::ContextMenuPrevious",
+      "ctrl-n": "editor::ContextMenuNext"
+    }
+  },
   {
     "context": "Workspace",
     "bindings": {

assets/keymaps/linux/sublime_text.json 🔗

@@ -38,7 +38,7 @@
       "ctrl-shift-d": "editor::DuplicateSelection",
       "alt-f3": "editor::SelectAllMatches", // find_all_under
       // "ctrl-f3": "", // find_under (cancels any selections)
-      // "cmd-alt-shift-g": "" // find_under_prev (cancels any selections)
+      // "ctrl-alt-shift-g": "" // find_under_prev (cancels any selections)
       "f9": "editor::SortLinesCaseSensitive",
       "ctrl-f9": "editor::SortLinesCaseInsensitive",
       "f12": "editor::GoToDefinition",
@@ -52,8 +52,10 @@
       "shift-alt-m": "markdown::OpenPreviewToTheSide",
       "ctrl-backspace": "editor::DeleteToPreviousWordStart",
       "ctrl-delete": "editor::DeleteToNextWordEnd",
-      "f3": "editor::FindNextMatch",
-      "shift-f3": "editor::FindPreviousMatch"
+      "alt-right": "editor::MoveToNextSubwordEnd",
+      "alt-left": "editor::MoveToPreviousSubwordStart",
+      "alt-shift-right": "editor::SelectToNextSubwordEnd",
+      "alt-shift-left": "editor::SelectToPreviousSubwordStart"
     }
   },
   {

assets/keymaps/macos/atom.json 🔗

@@ -9,6 +9,14 @@
   },
   {
     "context": "Editor",
+    "bindings": {
+      "cmd-shift-backspace": "editor::DeleteToBeginningOfLine",
+      "cmd-k cmd-u": "editor::ConvertToUpperCase",
+      "cmd-k cmd-l": "editor::ConvertToLowerCase"
+    }
+  },
+  {
+    "context": "Editor && mode == full",
     "bindings": {
       "ctrl-shift-l": "language_selector::Toggle",
       "cmd-|": "pane::RevealInProjectPanel",
@@ -19,26 +27,20 @@
       "cmd-shift-g": ["editor::SelectPrevious", { "replace_newest": true }],
       "ctrl-shift-down": "editor::AddSelectionBelow",
       "ctrl-shift-up": "editor::AddSelectionAbove",
-      "cmd-shift-backspace": "editor::DeleteToBeginningOfLine",
-      "cmd-k cmd-u": "editor::ConvertToUpperCase",
-      "cmd-k cmd-l": "editor::ConvertToLowerCase",
       "alt-enter": "editor::Newline",
       "cmd-shift-d": "editor::DuplicateLineDown",
       "ctrl-cmd-up": "editor::MoveLineUp",
       "ctrl-cmd-down": "editor::MoveLineDown",
       "cmd-\\": "workspace::ToggleLeftDock",
-      "ctrl-shift-m": "markdown::OpenPreviewToTheSide"
-    }
-  },
-  {
-    "context": "Editor && mode == full",
-    "bindings": {
+      "ctrl-shift-m": "markdown::OpenPreviewToTheSide",
       "cmd-r": "outline::Toggle"
     }
   },
   {
     "context": "BufferSearchBar",
     "bindings": {
+      "cmd-g": ["editor::SelectNext", { "replace_newest": true }],
+      "cmd-shift-g": ["editor::SelectPrevious", { "replace_newest": true }],
       "cmd-f3": "search::SelectNextMatch",
       "cmd-shift-f3": "search::SelectPreviousMatch"
     }

assets/keymaps/macos/cursor.json 🔗

@@ -0,0 +1,84 @@
+[
+  // Cursor for MacOS. See: https://docs.cursor.com/kbd
+  {
+    "context": "Workspace",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-i": "agent::ToggleFocus",
+      "cmd-shift-i": "agent::ToggleFocus",
+      "cmd-l": "agent::ToggleFocus",
+      "cmd-shift-l": "agent::ToggleFocus",
+      "cmd-shift-j": "agent::OpenConfiguration"
+    }
+  },
+  {
+    "context": "Editor && mode == full",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-i": "agent::ToggleFocus",
+      "cmd-shift-i": "agent::ToggleFocus",
+      "cmd-shift-l": "assistant::QuoteSelection", // In cursor uses "Ask" mode
+      "cmd-l": "assistant::QuoteSelection", // In cursor uses "Agent" mode
+      "cmd-k": "assistant::InlineAssist",
+      "cmd-shift-k": "assistant::InsertIntoEditor"
+    }
+  },
+  {
+    "context": "InlineAssistEditor",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-shift-backspace": "editor::Cancel",
+      "cmd-enter": "menu::Confirm"
+      // "alt-enter": // Quick Question
+      // "cmd-shift-enter": // Full File Context
+      // "cmd-shift-k": // Toggle input focus (editor <> inline assist)
+    }
+  },
+  {
+    "context": "AgentPanel || ContextEditor || (MessageEditor > Editor)",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-i": "workspace::ToggleRightDock",
+      "cmd-shift-i": "workspace::ToggleRightDock",
+      "cmd-l": "workspace::ToggleRightDock",
+      "cmd-shift-l": "workspace::ToggleRightDock",
+      "cmd-w": "workspace::ToggleRightDock", // technically should close chat
+      "cmd-.": "agent::ToggleProfileSelector",
+      "cmd-/": "agent::ToggleModelSelector",
+      "cmd-shift-backspace": "editor::Cancel",
+      "cmd-r": "agent::NewThread",
+      "cmd-shift-v": "editor::Paste",
+      "cmd-shift-k": "assistant::InsertIntoEditor"
+      // "escape": "agent::ToggleFocus"
+      ///// Enable when Zed supports multiple thread tabs
+      // "cmd-t": // new thread tab
+      // "cmd-[": // next thread tab
+      // "cmd-]": // next thread tab
+      ///// Enable if Zed adds support for keyboard navigation of thread elements
+      // "tab": // cycle to next message
+      // "shift-tab": // cycle to previous message
+    }
+  },
+  {
+    "context": "Editor && editor_agent_diff",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-enter": "agent::KeepAll",
+      "cmd-backspace": "agent::RejectAll"
+    }
+  },
+  {
+    "context": "Editor && mode == full && edit_prediction",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-right": "editor::AcceptPartialEditPrediction"
+    }
+  },
+  {
+    "context": "Terminal",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-k": "assistant::InlineAssist"
+    }
+  }
+]

assets/keymaps/macos/emacs.json 🔗

@@ -59,7 +59,8 @@
       "alt->": "editor::MoveToEnd", // end-of-buffer
       "ctrl-l": "editor::ScrollCursorCenterTopBottom", // recenter-top-bottom
       "ctrl-s": "buffer_search::Deploy", // isearch-forward
-      "alt-^": "editor::JoinLines" // join-line
+      "alt-^": "editor::JoinLines", // join-line
+      "alt-q": "editor::Rewrap" // fill-paragraph
     }
   },
   {
@@ -72,7 +73,9 @@
       "alt-left": "editor::SelectToPreviousWordStart",
       "alt-right": "editor::SelectToNextWordEnd",
       "pagedown": "editor::SelectPageDown",
+      "ctrl-v": "editor::SelectPageDown",
       "pageup": "editor::SelectPageUp",
+      "alt-v": "editor::SelectPageUp",
       "ctrl-f": "editor::SelectRight",
       "ctrl-b": "editor::SelectLeft",
       "ctrl-n": "editor::SelectDown",
@@ -88,6 +91,13 @@
       "ctrl-g": "editor::Cancel"
     }
   },
+  {
+    "context": "Editor && (showing_code_actions || showing_completions)",
+    "bindings": {
+      "ctrl-p": "editor::ContextMenuPrevious",
+      "ctrl-n": "editor::ContextMenuNext"
+    }
+  },
   {
     "context": "Workspace",
     "bindings": {

assets/keymaps/macos/sublime_text.json 🔗

@@ -54,8 +54,10 @@
       "shift-alt-m": "markdown::OpenPreviewToTheSide",
       "ctrl-backspace": "editor::DeleteToPreviousWordStart",
       "ctrl-delete": "editor::DeleteToNextWordEnd",
-      "cmd-g": "editor::FindNextMatch",
-      "cmd-shift-g": "editor::FindPreviousMatch"
+      "ctrl-right": "editor::MoveToNextSubwordEnd",
+      "ctrl-left": "editor::MoveToPreviousSubwordStart",
+      "ctrl-shift-right": "editor::SelectToNextSubwordEnd",
+      "ctrl-shift-left": "editor::SelectToPreviousSubwordStart"
     }
   },
   {

assets/keymaps/vim.json 🔗

@@ -56,6 +56,9 @@
       "[ shift-b": ["pane::ActivateItem", 0],
       "] space": "vim::InsertEmptyLineBelow",
       "[ space": "vim::InsertEmptyLineAbove",
+      "[ e": "editor::MoveLineUp",
+      "] e": "editor::MoveLineDown",
+
       // Word motions
       "w": "vim::NextWordStart",
       "e": "vim::NextWordEnd",
@@ -82,10 +85,10 @@
       "[ {": ["vim::UnmatchedBackward", { "char": "{" }],
       "] )": ["vim::UnmatchedForward", { "char": ")" }],
       "[ (": ["vim::UnmatchedBackward", { "char": "(" }],
-      "f": ["vim::PushFindForward", { "before": false }],
-      "t": ["vim::PushFindForward", { "before": true }],
-      "shift-f": ["vim::PushFindBackward", { "after": false }],
-      "shift-t": ["vim::PushFindBackward", { "after": true }],
+      "f": ["vim::PushFindForward", { "before": false, "multiline": false }],
+      "t": ["vim::PushFindForward", { "before": true, "multiline": false }],
+      "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": false }],
+      "shift-t": ["vim::PushFindBackward", { "after": true, "multiline": false }],
       "m": "vim::PushMark",
       "'": ["vim::PushJump", { "line": true }],
       "`": ["vim::PushJump", { "line": false }],
@@ -152,6 +155,7 @@
       "g end": ["vim::EndOfLine", { "display_lines": true }],
       "g 0": ["vim::StartOfLine", { "display_lines": true }],
       "g home": ["vim::StartOfLine", { "display_lines": true }],
+      "g shift-m": ["vim::MiddleOfLine", { "display_lines": true }],
       "g ^": ["vim::FirstNonWhitespace", { "display_lines": true }],
       "g v": "vim::RestoreVisualSelection",
       "g ]": "editor::GoToDiagnostic",
@@ -183,6 +187,8 @@
       "z f": "editor::FoldSelectedRanges",
       "z shift-m": "editor::FoldAll",
       "z shift-r": "editor::UnfoldAll",
+      "z l": "vim::ColumnRight",
+      "z h": "vim::ColumnLeft",
       "shift-z shift-q": ["pane::CloseActiveItem", { "save_intent": "skip" }],
       "shift-z shift-z": ["pane::CloseActiveItem", { "save_intent": "save_all" }],
       // Count support
@@ -197,6 +203,8 @@
       "9": ["vim::Number", 9],
       "ctrl-w d": "editor::GoToDefinitionSplit",
       "ctrl-w g d": "editor::GoToDefinitionSplit",
+      "ctrl-w ]": "editor::GoToDefinitionSplit",
+      "ctrl-w ctrl-]": "editor::GoToDefinitionSplit",
       "ctrl-w shift-d": "editor::GoToTypeDefinitionSplit",
       "ctrl-w g shift-d": "editor::GoToTypeDefinitionSplit",
       "ctrl-w space": "editor::OpenExcerptsSplit",
@@ -360,6 +368,10 @@
       "escape": "editor::Cancel",
       "ctrl-[": "editor::Cancel",
       ":": "command_palette::Toggle",
+      "left": "vim::WrappingLeft",
+      "right": "vim::WrappingRight",
+      "h": "vim::WrappingLeft",
+      "l": "vim::WrappingRight",
       "shift-d": "vim::DeleteToEndOfLine",
       "shift-j": "vim::JoinLines",
       "y": "editor::Copy",
@@ -377,6 +389,10 @@
       "shift-p": ["vim::Paste", { "before": true }],
       "u": "vim::Undo",
       "ctrl-r": "vim::Redo",
+      "f": ["vim::PushFindForward", { "before": false, "multiline": true }],
+      "t": ["vim::PushFindForward", { "before": true, "multiline": true }],
+      "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": true }],
+      "shift-t": ["vim::PushFindBackward", { "after": true, "multiline": true }],
       "r": "vim::PushReplace",
       "s": "vim::Substitute",
       "shift-s": "vim::SubstituteLine",
@@ -392,6 +408,8 @@
       "ctrl-pagedown": "pane::ActivateNextItem",
       "ctrl-pageup": "pane::ActivatePreviousItem",
       "insert": "vim::InsertBefore",
+      ".": "vim::Repeat",
+      "alt-.": "vim::RepeatFind",
       // tree-sitter related commands
       "[ x": "editor::SelectLargerSyntaxNode",
       "] x": "editor::SelectSmallerSyntaxNode",
@@ -418,6 +436,7 @@
 
       "x": "editor::SelectLine",
       "shift-x": "editor::SelectLine",
+      "%": "editor::SelectAll",
       // Window mode
       "space w h": "workspace::ActivatePaneLeft",
       "space w l": "workspace::ActivatePaneRight",
@@ -447,7 +466,8 @@
       "ctrl-c": "editor::ToggleComments",
       "d": "vim::HelixDelete",
       "c": "vim::Substitute",
-      "shift-c": "editor::AddSelectionBelow"
+      "shift-c": "editor::AddSelectionBelow",
+      "alt-shift-c": "editor::AddSelectionAbove"
     }
   },
   {
@@ -708,7 +728,7 @@
     }
   },
   {
-    "context": "GitPanel || ProjectPanel || CollabPanel || OutlinePanel || ChatPanel || VimControl || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || DebugPanel",
+    "context": "AgentPanel || GitPanel || ProjectPanel || CollabPanel || OutlinePanel || ChatPanel || VimControl || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || DebugPanel",
     "bindings": {
       // window related commands (ctrl-w X)
       "ctrl-w": null,
@@ -837,6 +857,19 @@
       "tab": "editor::AcceptEditPrediction"
     }
   },
+  {
+    "context": "MessageEditor > Editor && VimControl",
+    "bindings": {
+      "enter": "agent::Chat",
+      // TODO: Implement search
+      "/": null,
+      "?": null,
+      "#": null,
+      "*": null,
+      "n": null,
+      "shift-n": null
+    }
+  },
   {
     "context": "os != macos && Editor && edit_prediction_conflict",
     "bindings": {
@@ -845,13 +878,5 @@
       // and Windows.
       "alt-l": "editor::AcceptEditPrediction"
     }
-  },
-  {
-    // Fixes https://github.com/zed-industries/zed/issues/29095 by ensuring that
-    // the last binding for editor::ToggleComments is not ctrl-c.
-    "context": "hack_to_fix_ctrl-c",
-    "bindings": {
-      "g c": "editor::ToggleComments"
-    }
   }
 ]

assets/prompts/assistant_system_prompt.hbs 🔗

@@ -17,28 +17,27 @@ You are a highly skilled software engineer with extensive knowledge in many prog
 4. Use only the tools that are currently available.
 5. DO NOT use a tool that is not available just because it appears in the conversation. This means the user turned it off.
 6. NEVER run commands that don't terminate on their own such as web servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers.
+7. Avoid HTML entity escaping - use plain characters instead.
 
 ## Searching and Reading
 
 If you are unsure how to fulfill the user's request, gather more information with tool calls and/or clarifying questions.
 
 {{! TODO: If there are files, we should mention it but otherwise omit that fact }}
-{{#if has_tools}}
 If appropriate, use tool calls to explore the current project, which contains the following root directories:
 
 {{#each worktrees}}
-- `{{root_name}}`
+- `{{abs_path}}`
 {{/each}}
 
 - Bias towards not asking the user for help if you can find the answer yourself.
-- When providing paths to tools, the path should always begin with a path that starts with a project root directory listed above.
+- When providing paths to tools, the path should always start with the name of a project root directory listed above.
 - Before you read or edit a file, you must first find the full path. DO NOT ever guess a file path!
 {{# if (has_tool 'grep') }}
 - When looking for symbols in the project, prefer the `grep` tool.
 - As you learn about the structure of the project, use that information to scope `grep` searches to targeted subtrees of the project.
 - The user might specify a partial file path. If you don't know the full path, use `find_path` (not `grep`) before you read the file.
 {{/if}}
-{{/if}}
 {{else}}
 You are being tasked with providing a response, but you have no ability to use tools or to read or write any aspect of the user's system (other than any context the user might have provided to you).
 

assets/settings/default.json 🔗

@@ -73,9 +73,6 @@
   "unnecessary_code_fade": 0.3,
   // Active pane styling settings.
   "active_pane_modifiers": {
-    // The factor to grow the active pane by. Defaults to 1.0
-    // which gives the same size as all other panes.
-    "magnification": 1.0,
     // Inset border size of the active pane, in pixels.
     "border_size": 0.0,
     // Opacity of the inactive panes. 0 means transparent, 1 means opaque.
@@ -83,6 +80,7 @@
     "inactive_opacity": 1.0
   },
   // Layout mode of the bottom dock. Defaults to "contained"
+  //   choices: contained, full, left_aligned, right_aligned
   "bottom_dock_layout": "contained",
   // The direction that you want to split panes horizontally. Defaults to "up"
   "pane_split_direction_horizontal": "up",
@@ -97,27 +95,37 @@
     // workspace when the centered layout is used.
     "right_padding": 0.2
   },
-  // All settings related to the image viewer.
+  // Image viewer settings
   "image_viewer": {
-    // The unit for image file sizes.
-    // By default we're setting it to binary.
-    // The second option is decimal.
+    // The unit for image file sizes: "binary" (KiB, MiB) or decimal (KB, MB)
     "unit": "binary"
   },
-  // The key to use for adding multiple cursors
-  // Currently "alt" or "cmd_or_ctrl"  (also aliased as
-  // "cmd" and "ctrl") are supported.
+  // Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier.
+  //
+  // 1. Maps to `Alt` on Linux and Windows and to `Option` on MacOS:
+  //    "alt"
+  // 2. Maps `Control` on Linux and Windows and to `Command` on MacOS:
+  //    "cmd_or_ctrl" (alias: "cmd", "ctrl")
   "multi_cursor_modifier": "alt",
   // Whether to enable vim modes and key bindings.
   "vim_mode": false,
+  // Whether to enable helix mode and key bindings.
+  "helix_mode": false,
   // Whether to show the informational hover box when moving the mouse
   // over symbols in the editor.
   "hover_popover_enabled": true,
-  // Time to wait before showing the informational hover box
-  "hover_popover_delay": 350,
+  // Time to wait in milliseconds before showing the informational hover box.
+  "hover_popover_delay": 300,
   // Whether to confirm before quitting Zed.
   "confirm_quit": false,
-  // Whether to restore last closed project when fresh Zed instance is opened.
+  // Whether to restore last closed project when fresh Zed instance is opened
+  // May take 3 values:
+  //  1. All workspaces open during last session
+  //         "restore_on_startup": "last_session"
+  //  2. The workspace opened
+  //         "restore_on_startup": "last_workspace",
+  //  3. Do not restore previous workspaces
+  //         "restore_on_startup": "none",
   "restore_on_startup": "last_session",
   // Whether to attempt to restore previous file's state when opening it again.
   // The state is stored per pane.
@@ -128,7 +136,11 @@
   //
   // Default: true
   "restore_on_file_reopen": true,
-  // Size of the drop target in the editor.
+  // Whether to automatically close files that have been deleted on disk.
+  "close_on_file_delete": false,
+  // Relative size of the drop target in the editor that will open dropped file as a split pane (0-0.5)
+  // E.g. 0.25 == If you drop onto the top/bottom quarter of the pane a new vertical split will be used
+  //              If you drop onto the left/right quarter of the pane a new horizontal split will be used
   "drop_target_size": 0.2,
   // Whether the window should be closed when using 'close active item' on a window with no tabs.
   // May take 3 values:
@@ -213,6 +225,10 @@
   // Whether to show the signature help after completion or a bracket pair inserted.
   // If `auto_signature_help` is enabled, this setting will be treated as enabled also.
   "show_signature_help_after_edits": false,
+  // Whether to show code action button at start of buffer line.
+  "inline_code_actions": true,
+  // Whether to allow drag and drop text selection in buffer.
+  "drag_and_drop_selection": true,
   // What to do when go to definition yields no results.
   //
   // 1. Do nothing: `none`
@@ -230,11 +246,11 @@
   // Possible values:
   //  - "off" — no diagnostics are allowed
   //  - "error"
-  //  - "warning" (default)
+  //  - "warning"
   //  - "info"
   //  - "hint"
-  //  - null — allow all diagnostics
-  "diagnostics_max_severity": "warning",
+  //  - null — allow all diagnostics (default)
+  "diagnostics_max_severity": null,
   // Whether to show wrap guides (vertical rulers) in the editor.
   // Setting this to true will show a guide at the 'preferred_line_length' value
   // if 'soft_wrap' is set to 'preferred_line_length', and will show any
@@ -301,6 +317,8 @@
   //    "all"
   // 4. Draw whitespaces at boundaries only:
   //    "boundary"
+  // 5. Draw whitespaces only after non-whitespace characters:
+  //    "trailing"
   // For a whitespace to be on a boundary, any of the following conditions need to be met:
   // - It is a tab
   // - It is adjacent to an edge (start or end)
@@ -322,16 +340,24 @@
     // Whether to show the Selections menu in the editor toolbar.
     "selections_menu": true,
     // Whether to show agent review buttons in the editor toolbar.
-    "agent_review": true
+    "agent_review": true,
+    // Whether to show code action buttons in the editor toolbar.
+    "code_actions": false
   },
   // Titlebar related settings
   "title_bar": {
     // Whether to show the branch icon beside branch switcher in the titlebar.
     "show_branch_icon": false,
+    // Whether to show the branch name button in the titlebar.
+    "show_branch_name": true,
+    // Whether to show the project host and name in the titlebar.
+    "show_project_items": true,
     // Whether to show onboarding banners in the titlebar.
     "show_onboarding_banner": true,
     // Whether to show user picture in the titlebar.
-    "show_user_picture": true
+    "show_user_picture": true,
+    // Whether to show the sign in button in the titlebar.
+    "show_sign_in": true
   },
   // Scrollbar related settings
   "scrollbar": {
@@ -384,6 +410,13 @@
     // 3. Never show the minimap:
     //    "never" (default)
     "show": "never",
+    // Where to show the minimap in the editor.
+    // This setting can take two values:
+    // 1. Show the minimap on the focused editor only:
+    //    "active_editor" (default)
+    // 2. Show the minimap on all open editors:
+    //    "all_editors"
+    "display_in": "active_editor",
     // When to show the minimap thumb.
     // This setting can take two values:
     // 1. Show the minimap thumb if the mouse is over the minimap:
@@ -410,7 +443,9 @@
     // 1. `null` to inherit the editor `current_line_highlight` setting (default)
     // 2. "line" or "all" to highlight the current line in the minimap.
     // 3. "gutter" or "none" to not highlight the current line in the minimap.
-    "current_line_highlight": null
+    "current_line_highlight": null,
+    // Maximum number of columns to display in the minimap.
+    "max_width_columns": 80
   },
   // Enable middle-click paste on Linux.
   "middle_click_paste": true,
@@ -431,7 +466,9 @@
     // Whether to show breakpoints in the gutter.
     "breakpoints": true,
     // Whether to show fold buttons in the gutter.
-    "folds": true
+    "folds": true,
+    // Minimum number of characters to reserve space for in the gutter.
+    "min_line_number_digits": 4
   },
   "indent_guides": {
     // Whether to show indent guides in the editor.
@@ -456,7 +493,7 @@
   },
   // Whether the editor will scroll beyond the last line.
   "scroll_beyond_last_line": "one_page",
-  // The number of lines to keep above/below the cursor when scrolling.
+  // The number of lines to keep above/below the cursor when scrolling with the keyboard
   "vertical_scroll_margin": 3,
   // Whether to scroll when clicking near the edge of the visible text area.
   "autoscroll_on_clicks": false,
@@ -465,11 +502,17 @@
   // Scroll sensitivity multiplier. This multiplier is applied
   // to both the horizontal and vertical delta values while scrolling.
   "scroll_sensitivity": 1.0,
+  // Scroll sensitivity multiplier for fast scrolling. This multiplier is applied
+  // to both the horizontal and vertical delta values while scrolling. Fast scrolling
+  // happens when a user holds the alt or option key while scrolling.
+  "fast_scroll_sensitivity": 4.0,
   "relative_line_numbers": false,
   // If 'search_wrap' is disabled, search result do not wrap around the end of the file.
   "search_wrap": true,
   // Search options to enable by default when opening new project and buffer searches.
   "search": {
+    // Whether to show the project search button in the status bar.
+    "button": true,
     "whole_word": false,
     "case_sensitive": false,
     "include_ignored": false,
@@ -518,6 +561,9 @@
       "function": false
     }
   },
+  // Whether to resize all the panels in a dock when resizing the dock.
+  // Can be a combination of "left", "right" and "bottom".
+  "resize_all_panels_in_dock": ["left"],
   "project_panel": {
     // Whether to show the project panel button in the status bar
     "button": true,
@@ -581,7 +627,9 @@
       // 2. Never show indent guides:
       //    "never"
       "show": "always"
-    }
+    },
+    // Whether to hide the root entry when only one folder is open in the window.
+    "hide_root": false
   },
   "outline_panel": {
     // Whether to show the outline panel button in the status bar
@@ -661,23 +709,27 @@
     "default_width": 360,
     // Style of the git status indicator in the panel.
     //
+    // Choices: label_color, icon
     // Default: icon
     "status_style": "icon",
-    // What branch name to use if init.defaultBranch
-    // is not set
+    // What branch name to use if `init.defaultBranch` is not set
     //
     // Default: main
     "fallback_branch_name": "main",
-    // Whether to sort entries in the panel by path
-    // or by status (the default).
+    // Whether to sort entries in the panel by path or by status (the default).
     //
     // Default: false
     "sort_by_path": false,
+    // Whether to collapse untracked files in the diff panel.
+    //
+    // Default: false
+    "collapse_untracked_diff": false,
     "scrollbar": {
       // When to show the scrollbar in the git panel.
       //
+      // Choices: always, auto, never, system
       // Default: inherits editor scrollbar settings
-      "show": null
+      // "show": null
     }
   },
   "message_editor": {
@@ -698,7 +750,7 @@
     "version": "2",
     // Whether the agent is enabled.
     "enabled": true,
-    /// What completion mode to start new threads in, if available. Can be 'normal' or 'max'.
+    /// What completion mode to start new threads in, if available. Can be 'normal' or 'burn'.
     "preferred_completion_mode": "normal",
     // Whether to show the agent panel button in the status bar.
     "button": true,
@@ -713,14 +765,7 @@
       // The provider to use.
       "provider": "zed.dev",
       // The model to use.
-      "model": "claude-3-7-sonnet-latest"
-    },
-    // The model to use when applying edits from the agent.
-    "editor_model": {
-      // The provider to use.
-      "provider": "zed.dev",
-      // The model to use.
-      "model": "claude-3-7-sonnet-latest"
+      "model": "claude-sonnet-4"
     },
     // Additional parameters for language model requests. When making a request to a model, parameters will be taken
     // from the last entry in this list that matches the model's provider and name. In each entry, both provider
@@ -740,7 +785,7 @@
       // To set parameters for a specific provider and model:
       // {
       //   "provider": "zed.dev",
-      //   "model": "claude-3-7-sonnet-latest",
+      //   "model": "claude-sonnet-4",
       //   "temperature": 1.0
       // }
     ],
@@ -750,6 +795,8 @@
     "stream_edits": false,
     // When enabled, agent edits will be displayed in single-file editors for review
     "single_file_review": true,
+    // When enabled, show voting thumbs for feedback on agent edits.
+    "enable_feedback": true,
     "default_profile": "write",
     "profiles": {
       "write": {
@@ -758,7 +805,6 @@
         "tools": {
           "copy_path": true,
           "create_directory": true,
-          "create_file": true,
           "delete_path": true,
           "diagnostics": true,
           "edit_file": true,
@@ -804,7 +850,12 @@
     // "primary_screen" - Show the notification only on your primary screen (default)
     // "all_screens" - Show these notifications on all screens
     // "never" - Never show these notifications
-    "notify_when_agent_waiting": "primary_screen"
+    "notify_when_agent_waiting": "primary_screen",
+    // Whether to play a sound when the agent has either completed
+    // its response, or needs user input.
+
+    // Default: false
+    "play_sound_when_agent_done": false
   },
   // The settings for slash commands.
   "slash_commands": {
@@ -936,7 +987,17 @@
     //    "skip_focus_for_active_in_search": false
     //
     // Default: true
-    "skip_focus_for_active_in_search": true
+    "skip_focus_for_active_in_search": true,
+    // Whether to show the git status in the file finder.
+    "git_status": true,
+    // Whether to use gitignored files when searching.
+    // Only the file Zed had indexed will be used, not necessary all the gitignored files.
+    //
+    // Can accept 3 values:
+    //   * `true`: Use all gitignored files
+    //   * `false`: Use only the files Zed had indexed
+    //   * `null`: Be smart and search for ignored when called from a gitignored worktree
+    "include_ignored": null
   },
   // Whether or not to remove any trailing whitespace from lines of a buffer
   // before saving it.
@@ -946,8 +1007,7 @@
   // 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,
-  // Whether or not to perform a buffer format before saving
-  //
+  // Whether or not to perform a buffer format before saving: [on, off, prettier, language_server]
   // Keep in mind, if the autosave with delay is enabled, format_on_save will be ignored
   "format_on_save": "on",
   // How to perform a buffer format. This setting can take 4 values:
@@ -1000,10 +1060,33 @@
   // Automatically update Zed. This setting may be ignored on Linux if
   // installed through a package manager.
   "auto_update": true,
+  // How to render LSP `textDocument/documentColor` colors in the editor.
+  //
+  // Possible values:
+  //
+  // 1. Do not query and render document colors.
+  //      "lsp_document_colors": "none",
+  // 2. Render document colors as inlay hints near the color text (default).
+  //      "lsp_document_colors": "inlay",
+  // 3. Draw a border around the color text.
+  //      "lsp_document_colors": "border",
+  // 4. Draw a background behind the color text..
+  //      "lsp_document_colors": "background",
+  "lsp_document_colors": "inlay",
   // Diagnostics configuration.
   "diagnostics": {
+    // Whether to show the project diagnostics button in the status bar.
+    "button": true,
     // Whether to show warnings or not by default.
     "include_warnings": true,
+    // Settings for using LSP pull diagnostics mechanism in Zed.
+    "lsp_pull_diagnostics": {
+      // Whether to pull for diagnostics or not.
+      "enabled": true,
+      // Minimum time to wait before pulling diagnostics from the language server(s).
+      // 0 turns the debounce off.
+      "debounce_ms": 50
+    },
     // Settings for inline diagnostics
     "inline": {
       // Whether to show diagnostics inline or not
@@ -1105,6 +1188,12 @@
     // 2. Display predictions inline only when holding a modifier key (alt by default).
     //     "mode": "subtle"
     "mode": "eager",
+    // Copilot-specific settings
+    // "copilot": {
+    //   "enterprise_uri": "",
+    //   "proxy": "",
+    //   "proxy_no_verify": false
+    // },
     // Whether edit predictions are enabled when editing text threads.
     // This setting has no effect if globally disabled.
     "enabled_in_text_threads": true
@@ -1270,6 +1359,8 @@
     // the terminal will default to matching the buffer's font fallbacks.
     // This will be merged with the platform's default font fallbacks
     // "font_fallbacks": ["FiraCode Nerd Fonts"],
+    // The weight of the editor font in standard CSS units from 100 to 900.
+    // "font_weight": 400
     // Sets the maximum number of lines in the terminal's scrollback buffer.
     // Default: 10_000, maximum: 100_000 (all bigger values set will be treated as 100_000), 0 disables the scrolling.
     // Existing terminals will not pick up this change until they are recreated.
@@ -1279,7 +1370,17 @@
   // Settings related to running tasks.
   "tasks": {
     "variables": {},
-    "enabled": true
+    "enabled": true,
+    // Use LSP tasks over Zed language extension ones.
+    // If no LSP tasks are returned due to error/timeout or regular execution,
+    // Zed language extension tasks will be used instead.
+    //
+    // Other Zed tasks will still be shown:
+    // * Zed task from either of the task config file
+    // * Zed task from history (e.g. one-off task was spawned before)
+    //
+    // Default: true
+    "prefer_lsp": true
   },
   // An object whose keys are language names, and whose values
   // are arrays of filenames or extensions of files that should
@@ -1417,12 +1518,15 @@
       "language_servers": ["erlang-ls", "!elp", "..."]
     },
     "Git Commit": {
-      "allow_rewrap": "anywhere"
+      "allow_rewrap": "anywhere",
+      "soft_wrap": "editor_width",
+      "preferred_line_length": 72
     },
     "Go": {
       "code_actions_on_format": {
         "source.organizeImports": true
-      }
+      },
+      "debuggers": ["Delve"]
     },
     "GraphQL": {
       "prettier": {
@@ -1460,11 +1564,11 @@
       }
     },
     "LaTeX": {
-      "format_on_save": "on",
       "formatter": "language_server",
       "language_servers": ["texlab", "..."],
       "prettier": {
-        "allowed": false
+        "allowed": true,
+        "plugins": ["prettier-plugin-latex"]
       }
     },
     "Markdown": {
@@ -1487,20 +1591,20 @@
     "Plain Text": {
       "allow_rewrap": "anywhere"
     },
+    "Python": {
+      "debuggers": ["Debugpy"]
+    },
     "Ruby": {
-      "language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "..."]
+      "language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "!sorbet", "!steep", "..."]
+    },
+    "Rust": {
+      "debuggers": ["CodeLLDB"]
     },
     "SCSS": {
       "prettier": {
         "allowed": true
       }
     },
-    "SQL": {
-      "prettier": {
-        "allowed": true,
-        "plugins": ["prettier-plugin-sql"]
-      }
-    },
     "Starlark": {
       "language_servers": ["starpls", "!buck2-lsp", "..."]
     },
@@ -1565,6 +1669,9 @@
       "version": "1",
       "api_url": "https://api.openai.com/v1"
     },
+    "open_router": {
+      "api_url": "https://openrouter.ai/api/v1"
+    },
     "lmstudio": {
       "api_url": "http://localhost:1234/api/v0"
     },
@@ -1613,6 +1720,11 @@
     //     }
     // }
   },
+  // Common language server settings.
+  "global_lsp_settings": {
+    // Whether to show the LSP servers button in the status bar.
+    "button": true
+  },
   // Jupyter settings
   "jupyter": {
     "enabled": true
@@ -1627,7 +1739,6 @@
     "default_mode": "normal",
     "toggle_relative_line_numbers": false,
     "use_system_clipboard": "always",
-    "use_multiline_find": false,
     "use_smartcase_find": false,
     "highlight_on_yank_duration": 200,
     "custom_digraphs": {},
@@ -1703,11 +1814,14 @@
   //   }
   // ]
   "ssh_connections": [],
+  // Whether to read ~/.ssh/config for ssh connection sources.
+  "read_ssh_config": true,
   // Configures context servers for use by the agent.
   "context_servers": {},
   "debugger": {
     "stepping_granularity": "line",
     "save_breakpoints": true,
+    "dock": "bottom",
     "button": true
   }
 }

assets/settings/initial_debug_tasks.json 🔗

@@ -1,32 +1,34 @@
+// Some example tasks for common languages.
+//
+// For more documentation on how to configure debug tasks,
+// see: https://zed.dev/docs/debugger
 [
   {
     "label": "Debug active PHP file",
-    "adapter": "php",
+    "adapter": "PHP",
     "program": "$ZED_FILE",
     "request": "launch",
     "cwd": "$ZED_WORKTREE_ROOT"
   },
   {
     "label": "Debug active Python file",
-    "adapter": "python",
+    "adapter": "Debugpy",
     "program": "$ZED_FILE",
     "request": "launch",
     "cwd": "$ZED_WORKTREE_ROOT"
   },
   {
     "label": "Debug active JavaScript file",
-    "adapter": "javascript",
+    "adapter": "JavaScript",
     "program": "$ZED_FILE",
     "request": "launch",
     "cwd": "$ZED_WORKTREE_ROOT"
   },
   {
     "label": "JavaScript debug terminal",
-    "adapter": "javascript",
+    "adapter": "JavaScript",
     "request": "launch",
     "cwd": "$ZED_WORKTREE_ROOT",
-    "initialize_args": {
-      "console": "integratedTerminal"
-    }
+    "console": "integratedTerminal"
   }
 ]

assets/themes/ayu/ayu.json 🔗

@@ -261,6 +261,11 @@
             "font_style": null,
             "font_weight": null
           },
+          "namespace": {
+            "color": "#bfbdb6ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "number": {
             "color": "#d2a6ffff",
             "font_style": null,
@@ -316,6 +321,16 @@
             "font_style": null,
             "font_weight": null
           },
+          "selector": {
+            "color": "#d2a6ffff",
+            "font_style": null,
+            "font_weight": null
+          },
+          "selector.pseudo": {
+            "color": "#5ac1feff",
+            "font_style": null,
+            "font_weight": null
+          },
           "string": {
             "color": "#a9d94bff",
             "font_style": null,
@@ -442,9 +457,9 @@
         "terminal.foreground": "#5c6166ff",
         "terminal.bright_foreground": "#5c6166ff",
         "terminal.dim_foreground": "#fcfcfcff",
-        "terminal.ansi.black": "#fcfcfcff",
-        "terminal.ansi.bright_black": "#bcbec0ff",
-        "terminal.ansi.dim_black": "#5c6166ff",
+        "terminal.ansi.black": "#5c6166ff",
+        "terminal.ansi.bright_black": "#3b9ee5ff",
+        "terminal.ansi.dim_black": "#9c9fa2ff",
         "terminal.ansi.red": "#ef7271ff",
         "terminal.ansi.bright_red": "#febab6ff",
         "terminal.ansi.dim_red": "#833538ff",
@@ -463,9 +478,9 @@
         "terminal.ansi.cyan": "#4dbf99ff",
         "terminal.ansi.bright_cyan": "#ace0cbff",
         "terminal.ansi.dim_cyan": "#2a5f4aff",
-        "terminal.ansi.white": "#5c6166ff",
-        "terminal.ansi.bright_white": "#5c6166ff",
-        "terminal.ansi.dim_white": "#9c9fa2ff",
+        "terminal.ansi.white": "#fcfcfcff",
+        "terminal.ansi.bright_white": "#fcfcfcff",
+        "terminal.ansi.dim_white": "#bcbec0ff",
         "link_text.hover": "#3b9ee5ff",
         "conflict": "#f1ad49ff",
         "conflict.background": "#ffeedaff",
@@ -632,6 +647,11 @@
             "font_style": null,
             "font_weight": null
           },
+          "namespace": {
+            "color": "#5c6166ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "number": {
             "color": "#a37accff",
             "font_style": null,
@@ -687,6 +707,16 @@
             "font_style": null,
             "font_weight": null
           },
+          "selector": {
+            "color": "#a37accff",
+            "font_style": null,
+            "font_weight": null
+          },
+          "selector.pseudo": {
+            "color": "#3b9ee5ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "string": {
             "color": "#86b300ff",
             "font_style": null,
@@ -1003,6 +1033,11 @@
             "font_style": null,
             "font_weight": null
           },
+          "namespace": {
+            "color": "#cccac2ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "number": {
             "color": "#dfbfffff",
             "font_style": null,
@@ -1058,6 +1093,16 @@
             "font_style": null,
             "font_weight": null
           },
+          "selector": {
+            "color": "#dfbfffff",
+            "font_style": null,
+            "font_weight": null
+          },
+          "selector.pseudo": {
+            "color": "#72cffeff",
+            "font_style": null,
+            "font_weight": null
+          },
           "string": {
             "color": "#d4fe7fff",
             "font_style": null,

assets/themes/gruvbox/gruvbox.json 🔗

@@ -270,6 +270,11 @@
             "font_style": null,
             "font_weight": null
           },
+          "namespace": {
+            "color": "#83a598ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "number": {
             "color": "#d3869bff",
             "font_style": null,
@@ -325,6 +330,16 @@
             "font_style": null,
             "font_weight": null
           },
+          "selector": {
+            "color": "#fabd2eff",
+            "font_style": null,
+            "font_weight": null
+          },
+          "selector.pseudo": {
+            "color": "#83a598ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "string": {
             "color": "#b8bb25ff",
             "font_style": null,
@@ -655,6 +670,11 @@
             "font_style": null,
             "font_weight": null
           },
+          "namespace": {
+            "color": "#83a598ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "number": {
             "color": "#d3869bff",
             "font_style": null,
@@ -710,6 +730,16 @@
             "font_style": null,
             "font_weight": null
           },
+          "selector": {
+            "color": "#fabd2eff",
+            "font_style": null,
+            "font_weight": null
+          },
+          "selector.pseudo": {
+            "color": "#83a598ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "string": {
             "color": "#b8bb25ff",
             "font_style": null,
@@ -1040,6 +1070,11 @@
             "font_style": null,
             "font_weight": null
           },
+          "namespace": {
+            "color": "#83a598ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "number": {
             "color": "#d3869bff",
             "font_style": null,
@@ -1095,6 +1130,16 @@
             "font_style": null,
             "font_weight": null
           },
+          "selector": {
+            "color": "#fabd2eff",
+            "font_style": null,
+            "font_weight": null
+          },
+          "selector.pseudo": {
+            "color": "#83a598ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "string": {
             "color": "#b8bb25ff",
             "font_style": null,
@@ -1227,9 +1272,9 @@
         "terminal.foreground": "#282828ff",
         "terminal.bright_foreground": "#282828ff",
         "terminal.dim_foreground": "#fbf1c7ff",
-        "terminal.ansi.black": "#fbf1c7ff",
-        "terminal.ansi.bright_black": "#b0a189ff",
-        "terminal.ansi.dim_black": "#282828ff",
+        "terminal.ansi.black": "#282828ff",
+        "terminal.ansi.bright_black": "#0b6678ff",
+        "terminal.ansi.dim_black": "#5f5650ff",
         "terminal.ansi.red": "#9d0308ff",
         "terminal.ansi.bright_red": "#db8b7aff",
         "terminal.ansi.dim_red": "#4e1207ff",
@@ -1248,9 +1293,9 @@
         "terminal.ansi.cyan": "#437b59ff",
         "terminal.ansi.bright_cyan": "#9fbca8ff",
         "terminal.ansi.dim_cyan": "#253e2eff",
-        "terminal.ansi.white": "#282828ff",
-        "terminal.ansi.bright_white": "#282828ff",
-        "terminal.ansi.dim_white": "#73675eff",
+        "terminal.ansi.white": "#fbf1c7ff",
+        "terminal.ansi.bright_white": "#fbf1c7ff",
+        "terminal.ansi.dim_white": "#b0a189ff",
         "link_text.hover": "#0b6678ff",
         "version_control.added": "#797410ff",
         "version_control.modified": "#b57615ff",
@@ -1425,6 +1470,11 @@
             "font_style": null,
             "font_weight": null
           },
+          "namespace": {
+            "color": "#066578ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "number": {
             "color": "#8f3e71ff",
             "font_style": null,
@@ -1480,6 +1530,16 @@
             "font_style": null,
             "font_weight": null
           },
+          "selector": {
+            "color": "#b57613ff",
+            "font_style": null,
+            "font_weight": null
+          },
+          "selector.pseudo": {
+            "color": "#0b6678ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "string": {
             "color": "#79740eff",
             "font_style": null,
@@ -1612,9 +1672,9 @@
         "terminal.foreground": "#282828ff",
         "terminal.bright_foreground": "#282828ff",
         "terminal.dim_foreground": "#f9f5d7ff",
-        "terminal.ansi.black": "#f9f5d7ff",
-        "terminal.ansi.bright_black": "#b0a189ff",
-        "terminal.ansi.dim_black": "#282828ff",
+        "terminal.ansi.black": "#282828ff",
+        "terminal.ansi.bright_black": "#73675eff",
+        "terminal.ansi.dim_black": "#f9f5d7ff",
         "terminal.ansi.red": "#9d0308ff",
         "terminal.ansi.bright_red": "#db8b7aff",
         "terminal.ansi.dim_red": "#4e1207ff",
@@ -1633,9 +1693,9 @@
         "terminal.ansi.cyan": "#437b59ff",
         "terminal.ansi.bright_cyan": "#9fbca8ff",
         "terminal.ansi.dim_cyan": "#253e2eff",
-        "terminal.ansi.white": "#282828ff",
-        "terminal.ansi.bright_white": "#282828ff",
-        "terminal.ansi.dim_white": "#73675eff",
+        "terminal.ansi.white": "#f9f5d7ff",
+        "terminal.ansi.bright_white": "#f9f5d7ff",
+        "terminal.ansi.dim_white": "#b0a189ff",
         "link_text.hover": "#0b6678ff",
         "version_control.added": "#797410ff",
         "version_control.modified": "#b57615ff",
@@ -1810,6 +1870,11 @@
             "font_style": null,
             "font_weight": null
           },
+          "namespace": {
+            "color": "#066578ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "number": {
             "color": "#8f3e71ff",
             "font_style": null,
@@ -1865,6 +1930,16 @@
             "font_style": null,
             "font_weight": null
           },
+          "selector": {
+            "color": "#b57613ff",
+            "font_style": null,
+            "font_weight": null
+          },
+          "selector.pseudo": {
+            "color": "#0b6678ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "string": {
             "color": "#79740eff",
             "font_style": null,
@@ -1997,9 +2072,9 @@
         "terminal.foreground": "#282828ff",
         "terminal.bright_foreground": "#282828ff",
         "terminal.dim_foreground": "#f2e5bcff",
-        "terminal.ansi.black": "#f2e5bcff",
-        "terminal.ansi.bright_black": "#b0a189ff",
-        "terminal.ansi.dim_black": "#282828ff",
+        "terminal.ansi.black": "#282828ff",
+        "terminal.ansi.bright_black": "#73675eff",
+        "terminal.ansi.dim_black": "#f2e5bcff",
         "terminal.ansi.red": "#9d0308ff",
         "terminal.ansi.bright_red": "#db8b7aff",
         "terminal.ansi.dim_red": "#4e1207ff",
@@ -2018,9 +2093,9 @@
         "terminal.ansi.cyan": "#437b59ff",
         "terminal.ansi.bright_cyan": "#9fbca8ff",
         "terminal.ansi.dim_cyan": "#253e2eff",
-        "terminal.ansi.white": "#282828ff",
-        "terminal.ansi.bright_white": "#282828ff",
-        "terminal.ansi.dim_white": "#73675eff",
+        "terminal.ansi.white": "#f2e5bcff",
+        "terminal.ansi.bright_white": "#f2e5bcff",
+        "terminal.ansi.dim_white": "#b0a189ff",
         "link_text.hover": "#0b6678ff",
         "version_control.added": "#797410ff",
         "version_control.modified": "#b57615ff",
@@ -2195,6 +2270,11 @@
             "font_style": null,
             "font_weight": null
           },
+          "namespace": {
+            "color": "#066578ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "number": {
             "color": "#8f3e71ff",
             "font_style": null,
@@ -2250,6 +2330,16 @@
             "font_style": null,
             "font_weight": null
           },
+          "selector": {
+            "color": "#b57613ff",
+            "font_style": null,
+            "font_weight": null
+          },
+          "selector.pseudo": {
+            "color": "#0b6678ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "string": {
             "color": "#79740eff",
             "font_style": null,

assets/themes/one/one.json 🔗

@@ -99,6 +99,8 @@
         "version_control.added": "#27a657ff",
         "version_control.modified": "#d3b020ff",
         "version_control.deleted": "#e06c76ff",
+        "version_control.conflict_marker.ours": "#a1c1811a",
+        "version_control.conflict_marker.theirs": "#74ade81a",
         "conflict": "#dec184ff",
         "conflict.background": "#dec1841a",
         "conflict.border": "#5d4c2fff",
@@ -264,6 +266,11 @@
             "font_style": null,
             "font_weight": null
           },
+          "namespace": {
+            "color": "#dce0e5ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "number": {
             "color": "#bf956aff",
             "font_style": null,
@@ -319,6 +326,16 @@
             "font_style": null,
             "font_weight": null
           },
+          "selector": {
+            "color": "#dfc184ff",
+            "font_style": null,
+            "font_weight": null
+          },
+          "selector.pseudo": {
+            "color": "#74ade8ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "string": {
             "color": "#a1c181ff",
             "font_style": null,
@@ -450,9 +467,9 @@
         "terminal.foreground": "#242529ff",
         "terminal.bright_foreground": "#242529ff",
         "terminal.dim_foreground": "#fafafaff",
-        "terminal.ansi.black": "#fafafaff",
-        "terminal.ansi.bright_black": "#aaaaaaff",
-        "terminal.ansi.dim_black": "#242529ff",
+        "terminal.ansi.black": "#242529ff",
+        "terminal.ansi.bright_black": "#242529ff",
+        "terminal.ansi.dim_black": "#97979aff",
         "terminal.ansi.red": "#d36151ff",
         "terminal.ansi.bright_red": "#f0b0a4ff",
         "terminal.ansi.dim_red": "#6f312aff",
@@ -471,9 +488,9 @@
         "terminal.ansi.cyan": "#3a82b7ff",
         "terminal.ansi.bright_cyan": "#a3bedaff",
         "terminal.ansi.dim_cyan": "#254058ff",
-        "terminal.ansi.white": "#242529ff",
-        "terminal.ansi.bright_white": "#242529ff",
-        "terminal.ansi.dim_white": "#97979aff",
+        "terminal.ansi.white": "#fafafaff",
+        "terminal.ansi.bright_white": "#fafafaff",
+        "terminal.ansi.dim_white": "#aaaaaaff",
         "link_text.hover": "#5c78e2ff",
         "version_control.added": "#27a657ff",
         "version_control.modified": "#d3b020ff",
@@ -584,7 +601,7 @@
             "font_weight": null
           },
           "constant": {
-            "color": "#669f59ff",
+            "color": "#c18401ff",
             "font_style": null,
             "font_weight": null
           },
@@ -643,6 +660,11 @@
             "font_style": null,
             "font_weight": null
           },
+          "namespace": {
+            "color": "#242529ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "number": {
             "color": "#ad6e25ff",
             "font_style": null,
@@ -698,6 +720,16 @@
             "font_style": null,
             "font_weight": null
           },
+          "selector": {
+            "color": "#669f59ff",
+            "font_style": null,
+            "font_weight": null
+          },
+          "selector.pseudo": {
+            "color": "#5c78e2ff",
+            "font_style": null,
+            "font_weight": null
+          },
           "string": {
             "color": "#649f57ff",
             "font_style": null,

crates/activity_indicator/Cargo.toml 🔗

@@ -21,11 +21,13 @@ futures.workspace = true
 gpui.workspace = true
 language.workspace = true
 project.workspace = true
+proto.workspace = true
 smallvec.workspace = true
 ui.workspace = true
 util.workspace = true
-workspace.workspace = true
 workspace-hack.workspace = true
+workspace.workspace = true
 
 [dev-dependencies]
 editor = { workspace = true, features = ["test-support"] }
+release_channel.workspace = true

crates/activity_indicator/src/activity_indicator.rs 🔗

@@ -1,4 +1,4 @@
-use auto_update::{AutoUpdateStatus, AutoUpdater, DismissErrorMessage};
+use auto_update::{AutoUpdateStatus, AutoUpdater, DismissErrorMessage, VersionCheckType};
 use editor::Editor;
 use extension_host::ExtensionStore;
 use futures::StreamExt;
@@ -7,7 +7,10 @@ use gpui::{
     InteractiveElement as _, ParentElement as _, Render, SharedString, StatefulInteractiveElement,
     Styled, Transformation, Window, actions, percentage,
 };
-use language::{BinaryStatus, LanguageRegistry, LanguageServerId};
+use language::{
+    BinaryStatus, LanguageRegistry, LanguageServerId, LanguageServerName,
+    LanguageServerStatusUpdate, ServerHealth,
+};
 use project::{
     EnvironmentErrorMessage, LanguageServerProgress, LspStoreEvent, Project,
     ProjectEnvironmentEvent,
@@ -16,6 +19,7 @@ use project::{
 use smallvec::SmallVec;
 use std::{
     cmp::Reverse,
+    collections::HashSet,
     fmt::Write,
     path::Path,
     sync::Arc,
@@ -30,9 +34,9 @@ const GIT_OPERATION_DELAY: Duration = Duration::from_millis(0);
 actions!(activity_indicator, [ShowErrorMessage]);
 
 pub enum Event {
-    ShowError {
-        server_name: SharedString,
-        error: String,
+    ShowStatus {
+        server_name: LanguageServerName,
+        status: SharedString,
     },
 }
 
@@ -45,8 +49,8 @@ pub struct ActivityIndicator {
 
 #[derive(Debug)]
 struct ServerStatus {
-    name: SharedString,
-    status: BinaryStatus,
+    name: LanguageServerName,
+    status: LanguageServerStatusUpdate,
 }
 
 struct PendingWork<'a> {
@@ -60,6 +64,7 @@ struct Content {
     message: String,
     on_click:
         Option<Arc<dyn Fn(&mut ActivityIndicator, &mut Window, &mut Context<ActivityIndicator>)>>,
+    tooltip_message: Option<String>,
 }
 
 impl ActivityIndicator {
@@ -75,10 +80,13 @@ impl ActivityIndicator {
         let this = cx.new(|cx| {
             let mut status_events = languages.language_server_binary_statuses();
             cx.spawn(async move |this, cx| {
-                while let Some((name, status)) = status_events.next().await {
+                while let Some((name, binary_status)) = status_events.next().await {
                     this.update(cx, |this: &mut ActivityIndicator, cx| {
                         this.statuses.retain(|s| s.name != name);
-                        this.statuses.push(ServerStatus { name, status });
+                        this.statuses.push(ServerStatus {
+                            name,
+                            status: LanguageServerStatusUpdate::Binary(binary_status),
+                        });
                         cx.notify();
                     })?;
                 }
@@ -107,8 +115,76 @@ impl ActivityIndicator {
 
             cx.subscribe(
                 &project.read(cx).lsp_store(),
-                |_, _, event, cx| match event {
-                    LspStoreEvent::LanguageServerUpdate { .. } => cx.notify(),
+                |activity_indicator, _, event, cx| match event {
+                    LspStoreEvent::LanguageServerUpdate { name, message, .. } => {
+                        if let proto::update_language_server::Variant::StatusUpdate(status_update) =
+                            message
+                        {
+                            let Some(name) = name.clone() else {
+                                return;
+                            };
+                            let status = match &status_update.status {
+                                Some(proto::status_update::Status::Binary(binary_status)) => {
+                                    if let Some(binary_status) =
+                                        proto::ServerBinaryStatus::from_i32(*binary_status)
+                                    {
+                                        let binary_status = match binary_status {
+                                            proto::ServerBinaryStatus::None => BinaryStatus::None,
+                                            proto::ServerBinaryStatus::CheckingForUpdate => {
+                                                BinaryStatus::CheckingForUpdate
+                                            }
+                                            proto::ServerBinaryStatus::Downloading => {
+                                                BinaryStatus::Downloading
+                                            }
+                                            proto::ServerBinaryStatus::Starting => {
+                                                BinaryStatus::Starting
+                                            }
+                                            proto::ServerBinaryStatus::Stopping => {
+                                                BinaryStatus::Stopping
+                                            }
+                                            proto::ServerBinaryStatus::Stopped => {
+                                                BinaryStatus::Stopped
+                                            }
+                                            proto::ServerBinaryStatus::Failed => {
+                                                let Some(error) = status_update.message.clone()
+                                                else {
+                                                    return;
+                                                };
+                                                BinaryStatus::Failed { error }
+                                            }
+                                        };
+                                        LanguageServerStatusUpdate::Binary(binary_status)
+                                    } else {
+                                        return;
+                                    }
+                                }
+                                Some(proto::status_update::Status::Health(health_status)) => {
+                                    if let Some(health) =
+                                        proto::ServerHealth::from_i32(*health_status)
+                                    {
+                                        let health = match health {
+                                            proto::ServerHealth::Ok => ServerHealth::Ok,
+                                            proto::ServerHealth::Warning => ServerHealth::Warning,
+                                            proto::ServerHealth::Error => ServerHealth::Error,
+                                        };
+                                        LanguageServerStatusUpdate::Health(
+                                            health,
+                                            status_update.message.clone().map(SharedString::from),
+                                        )
+                                    } else {
+                                        return;
+                                    }
+                                }
+                                None => return,
+                            };
+
+                            activity_indicator.statuses.retain(|s| s.name != name);
+                            activity_indicator
+                                .statuses
+                                .push(ServerStatus { name, status });
+                        }
+                        cx.notify()
+                    }
                     _ => {}
                 },
             )
@@ -144,19 +220,19 @@ impl ActivityIndicator {
         });
 
         cx.subscribe_in(&this, window, move |_, _, event, window, cx| match event {
-            Event::ShowError { server_name, error } => {
+            Event::ShowStatus {
+                server_name,
+                status,
+            } => {
                 let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx));
                 let project = project.clone();
-                let error = error.clone();
+                let status = status.clone();
                 let server_name = server_name.clone();
                 cx.spawn_in(window, async move |workspace, cx| {
                     let buffer = create_buffer.await?;
                     buffer.update(cx, |buffer, cx| {
                         buffer.edit(
-                            [(
-                                0..0,
-                                format!("Language server error: {}\n\n{}", server_name, error),
-                            )],
+                            [(0..0, format!("Language server {server_name}:\n\n{status}"))],
                             None,
                             cx,
                         );
@@ -165,7 +241,10 @@ impl ActivityIndicator {
                     workspace.update_in(cx, |workspace, window, cx| {
                         workspace.add_item_to_active_pane(
                             Box::new(cx.new(|cx| {
-                                Editor::for_buffer(buffer, Some(project.clone()), window, cx)
+                                let mut editor =
+                                    Editor::for_buffer(buffer, Some(project.clone()), window, cx);
+                                editor.set_read_only(true);
+                                editor
                             })),
                             None,
                             true,
@@ -184,19 +263,34 @@ impl ActivityIndicator {
     }
 
     fn show_error_message(&mut self, _: &ShowErrorMessage, _: &mut Window, cx: &mut Context<Self>) {
-        self.statuses.retain(|status| {
-            if let BinaryStatus::Failed { error } = &status.status {
-                cx.emit(Event::ShowError {
+        let mut status_message_shown = false;
+        self.statuses.retain(|status| match &status.status {
+            LanguageServerStatusUpdate::Binary(BinaryStatus::Failed { error })
+                if !status_message_shown =>
+            {
+                cx.emit(Event::ShowStatus {
                     server_name: status.name.clone(),
-                    error: error.clone(),
+                    status: SharedString::from(error),
                 });
+                status_message_shown = true;
                 false
-            } else {
-                true
             }
+            LanguageServerStatusUpdate::Health(
+                ServerHealth::Error | ServerHealth::Warning,
+                status_string,
+            ) if !status_message_shown => match status_string {
+                Some(error) => {
+                    cx.emit(Event::ShowStatus {
+                        server_name: status.name.clone(),
+                        status: error.clone(),
+                    });
+                    status_message_shown = true;
+                    false
+                }
+                None => false,
+            },
+            _ => true,
         });
-
-        cx.notify();
     }
 
     fn dismiss_error_message(
@@ -205,9 +299,23 @@ impl ActivityIndicator {
         _: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if let Some(updater) = &self.auto_updater {
-            updater.update(cx, |updater, cx| updater.dismiss_error(cx));
+        let error_dismissed = if let Some(updater) = &self.auto_updater {
+            updater.update(cx, |updater, cx| updater.dismiss_error(cx))
+        } else {
+            false
+        };
+        if error_dismissed {
+            return;
         }
+
+        self.project.update(cx, |project, cx| {
+            if project.last_formatting_failure(cx).is_some() {
+                project.reset_last_formatting_failure(cx);
+                true
+            } else {
+                false
+            }
+        });
     }
 
     fn pending_language_server_work<'a>(
@@ -262,36 +370,66 @@ impl ActivityIndicator {
                     });
                     window.dispatch_action(Box::new(workspace::OpenLog), cx);
                 })),
+                tooltip_message: None,
             });
         }
         // Show any language server has pending activity.
-        let mut pending_work = self.pending_language_server_work(cx);
-        if let Some(PendingWork {
-            progress_token,
-            progress,
-            ..
-        }) = pending_work.next()
         {
-            let mut message = progress
-                .title
-                .as_deref()
-                .unwrap_or(progress_token)
-                .to_string();
-
-            if let Some(percentage) = progress.percentage {
-                write!(&mut message, " ({}%)", percentage).unwrap();
-            }
+            let mut pending_work = self.pending_language_server_work(cx);
+            if let Some(PendingWork {
+                progress_token,
+                progress,
+                ..
+            }) = pending_work.next()
+            {
+                let mut message = progress
+                    .title
+                    .as_deref()
+                    .unwrap_or(progress_token)
+                    .to_string();
+
+                if let Some(percentage) = progress.percentage {
+                    write!(&mut message, " ({}%)", percentage).unwrap();
+                }
 
-            if let Some(progress_message) = progress.message.as_ref() {
-                message.push_str(": ");
-                message.push_str(progress_message);
-            }
+                if let Some(progress_message) = progress.message.as_ref() {
+                    message.push_str(": ");
+                    message.push_str(progress_message);
+                }
 
-            let additional_work_count = pending_work.count();
-            if additional_work_count > 0 {
-                write!(&mut message, " + {} more", additional_work_count).unwrap();
+                let additional_work_count = pending_work.count();
+                if additional_work_count > 0 {
+                    write!(&mut message, " + {} more", additional_work_count).unwrap();
+                }
+
+                return Some(Content {
+                    icon: Some(
+                        Icon::new(IconName::ArrowCircle)
+                            .size(IconSize::Small)
+                            .with_animation(
+                                "arrow-circle",
+                                Animation::new(Duration::from_secs(2)).repeat(),
+                                |icon, delta| {
+                                    icon.transform(Transformation::rotate(percentage(delta)))
+                                },
+                            )
+                            .into_any_element(),
+                    ),
+                    message,
+                    on_click: Some(Arc::new(Self::toggle_language_server_work_context_menu)),
+                    tooltip_message: None,
+                });
             }
+        }
 
+        if let Some(session) = self
+            .project
+            .read(cx)
+            .dap_store()
+            .read(cx)
+            .sessions()
+            .find(|s| !s.read(cx).is_started())
+        {
             return Some(Content {
                 icon: Some(
                     Icon::new(IconName::ArrowCircle)
@@ -303,8 +441,9 @@ impl ActivityIndicator {
                         )
                         .into_any_element(),
                 ),
-                message,
-                on_click: Some(Arc::new(Self::toggle_language_server_work_context_menu)),
+                message: format!("Debug: {}", session.read(cx).adapter()),
+                tooltip_message: Some(session.read(cx).label().to_string()),
+                on_click: None,
             });
         }
 
@@ -332,6 +471,7 @@ impl ActivityIndicator {
                     ),
                     message: job_info.message.into(),
                     on_click: None,
+                    tooltip_message: None,
                 });
             }
         }
@@ -340,14 +480,44 @@ impl ActivityIndicator {
         let mut downloading = SmallVec::<[_; 3]>::new();
         let mut checking_for_update = SmallVec::<[_; 3]>::new();
         let mut failed = SmallVec::<[_; 3]>::new();
+        let mut health_messages = SmallVec::<[_; 3]>::new();
+        let mut servers_to_clear_statuses = HashSet::<LanguageServerName>::default();
         for status in &self.statuses {
-            match status.status {
-                BinaryStatus::CheckingForUpdate => checking_for_update.push(status.name.clone()),
-                BinaryStatus::Downloading => downloading.push(status.name.clone()),
-                BinaryStatus::Failed { .. } => failed.push(status.name.clone()),
-                BinaryStatus::None => {}
+            match &status.status {
+                LanguageServerStatusUpdate::Binary(
+                    BinaryStatus::Starting | BinaryStatus::Stopping,
+                ) => {}
+                LanguageServerStatusUpdate::Binary(BinaryStatus::Stopped) => {
+                    servers_to_clear_statuses.insert(status.name.clone());
+                }
+                LanguageServerStatusUpdate::Binary(BinaryStatus::CheckingForUpdate) => {
+                    checking_for_update.push(status.name.clone());
+                }
+                LanguageServerStatusUpdate::Binary(BinaryStatus::Downloading) => {
+                    downloading.push(status.name.clone());
+                }
+                LanguageServerStatusUpdate::Binary(BinaryStatus::Failed { .. }) => {
+                    failed.push(status.name.clone());
+                }
+                LanguageServerStatusUpdate::Binary(BinaryStatus::None) => {}
+                LanguageServerStatusUpdate::Health(health, server_status) => match server_status {
+                    Some(server_status) => {
+                        health_messages.push((status.name.clone(), *health, server_status.clone()));
+                    }
+                    None => {
+                        servers_to_clear_statuses.insert(status.name.clone());
+                    }
+                },
             }
         }
+        self.statuses
+            .retain(|status| !servers_to_clear_statuses.contains(&status.name));
+
+        health_messages.sort_by_key(|(_, health, _)| match health {
+            ServerHealth::Error => 2,
+            ServerHealth::Warning => 1,
+            ServerHealth::Ok => 0,
+        });
 
         if !downloading.is_empty() {
             return Some(Content {
@@ -374,6 +544,7 @@ impl ActivityIndicator {
                         .retain(|status| !downloading.contains(&status.name));
                     this.dismiss_error_message(&DismissErrorMessage, window, cx)
                 })),
+                tooltip_message: None,
             });
         }
 
@@ -402,6 +573,7 @@ impl ActivityIndicator {
                         .retain(|status| !checking_for_update.contains(&status.name));
                     this.dismiss_error_message(&DismissErrorMessage, window, cx)
                 })),
+                tooltip_message: None,
             });
         }
 
@@ -426,8 +598,9 @@ impl ActivityIndicator {
                         }),
                 ),
                 on_click: Some(Arc::new(|this, window, cx| {
-                    this.show_error_message(&Default::default(), window, cx)
+                    this.show_error_message(&ShowErrorMessage, window, cx)
                 })),
+                tooltip_message: None,
             });
         }
 
@@ -439,13 +612,64 @@ impl ActivityIndicator {
                         .size(IconSize::Small)
                         .into_any_element(),
                 ),
-                message: format!("Formatting failed: {}. Click to see logs.", failure),
+                message: format!("Formatting failed: {failure}. Click to see logs."),
                 on_click: Some(Arc::new(|indicator, window, cx| {
                     indicator.project.update(cx, |project, cx| {
                         project.reset_last_formatting_failure(cx);
                     });
                     window.dispatch_action(Box::new(workspace::OpenLog), cx);
                 })),
+                tooltip_message: None,
+            });
+        }
+
+        // Show any health messages for the language servers
+        if let Some((server_name, health, message)) = health_messages.pop() {
+            let health_str = match health {
+                ServerHealth::Ok => format!("({server_name}) "),
+                ServerHealth::Warning => format!("({server_name}) Warning: "),
+                ServerHealth::Error => format!("({server_name}) Error: "),
+            };
+            let single_line_message = message
+                .lines()
+                .filter_map(|line| {
+                    let line = line.trim();
+                    if line.is_empty() { None } else { Some(line) }
+                })
+                .collect::<Vec<_>>()
+                .join(" ");
+            let mut altered_message = single_line_message != message;
+            let truncated_message = truncate_and_trailoff(
+                &single_line_message,
+                MAX_MESSAGE_LEN.saturating_sub(health_str.len()),
+            );
+            altered_message |= truncated_message != single_line_message;
+            let final_message = format!("{health_str}{truncated_message}");
+
+            let tooltip_message = if altered_message {
+                Some(format!("{health_str}{message}"))
+            } else {
+                None
+            };
+
+            return Some(Content {
+                icon: Some(
+                    Icon::new(IconName::Warning)
+                        .size(IconSize::Small)
+                        .into_any_element(),
+                ),
+                message: final_message,
+                tooltip_message,
+                on_click: Some(Arc::new(move |activity_indicator, window, cx| {
+                    if altered_message {
+                        activity_indicator.show_error_message(&ShowErrorMessage, window, cx)
+                    } else {
+                        activity_indicator
+                            .statuses
+                            .retain(|status| status.name != server_name);
+                        cx.notify();
+                    }
+                })),
             });
         }
 
@@ -462,8 +686,9 @@ impl ActivityIndicator {
                     on_click: Some(Arc::new(|this, window, cx| {
                         this.dismiss_error_message(&DismissErrorMessage, window, cx)
                     })),
+                    tooltip_message: None,
                 }),
-                AutoUpdateStatus::Downloading => Some(Content {
+                AutoUpdateStatus::Downloading { version } => Some(Content {
                     icon: Some(
                         Icon::new(IconName::Download)
                             .size(IconSize::Small)
@@ -473,8 +698,9 @@ impl ActivityIndicator {
                     on_click: Some(Arc::new(|this, window, cx| {
                         this.dismiss_error_message(&DismissErrorMessage, window, cx)
                     })),
+                    tooltip_message: Some(Self::version_tooltip_message(&version)),
                 }),
-                AutoUpdateStatus::Installing => Some(Content {
+                AutoUpdateStatus::Installing { version } => Some(Content {
                     icon: Some(
                         Icon::new(IconName::Download)
                             .size(IconSize::Small)
@@ -484,8 +710,12 @@ impl ActivityIndicator {
                     on_click: Some(Arc::new(|this, window, cx| {
                         this.dismiss_error_message(&DismissErrorMessage, window, cx)
                     })),
+                    tooltip_message: Some(Self::version_tooltip_message(&version)),
                 }),
-                AutoUpdateStatus::Updated { binary_path } => Some(Content {
+                AutoUpdateStatus::Updated {
+                    binary_path,
+                    version,
+                } => Some(Content {
                     icon: None,
                     message: "Click to restart and update Zed".to_string(),
                     on_click: Some(Arc::new({
@@ -494,6 +724,7 @@ impl ActivityIndicator {
                         };
                         move |_, _, cx| workspace::reload(&reload, cx)
                     })),
+                    tooltip_message: Some(Self::version_tooltip_message(&version)),
                 }),
                 AutoUpdateStatus::Errored => Some(Content {
                     icon: Some(
@@ -505,6 +736,7 @@ impl ActivityIndicator {
                     on_click: Some(Arc::new(|this, window, cx| {
                         this.dismiss_error_message(&DismissErrorMessage, window, cx)
                     })),
+                    tooltip_message: None,
                 }),
                 AutoUpdateStatus::Idle => None,
             };
@@ -524,6 +756,7 @@ impl ActivityIndicator {
                     on_click: Some(Arc::new(|this, window, cx| {
                         this.dismiss_error_message(&DismissErrorMessage, window, cx)
                     })),
+                    tooltip_message: None,
                 });
             }
         }
@@ -531,6 +764,17 @@ impl ActivityIndicator {
         None
     }
 
+    fn version_tooltip_message(version: &VersionCheckType) -> String {
+        format!("Version: {}", {
+            match version {
+                auto_update::VersionCheckType::Sha(sha) => format!("{}…", sha.short()),
+                auto_update::VersionCheckType::Semantic(semantic_version) => {
+                    semantic_version.to_string()
+                }
+            }
+        })
+    }
+
     fn toggle_language_server_work_context_menu(
         &mut self,
         window: &mut Window,
@@ -575,7 +819,14 @@ impl Render for ActivityIndicator {
                                         )
                                         .tooltip(Tooltip::text(content.message))
                                 } else {
-                                    button.child(Label::new(content.message).size(LabelSize::Small))
+                                    button
+                                        .child(Label::new(content.message).size(LabelSize::Small))
+                                        .when_some(
+                                            content.tooltip_message,
+                                            |this, tooltip_message| {
+                                                this.tooltip(Tooltip::text(tooltip_message))
+                                            },
+                                        )
                                 }
                             })
                             .when_some(content.on_click, |this, handler| {
@@ -655,3 +906,26 @@ impl StatusItemView for ActivityIndicator {
     ) {
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use gpui::SemanticVersion;
+    use release_channel::AppCommitSha;
+
+    use super::*;
+
+    #[test]
+    fn test_version_tooltip_message() {
+        let message = ActivityIndicator::version_tooltip_message(&VersionCheckType::Semantic(
+            SemanticVersion::new(1, 0, 0),
+        ));
+
+        assert_eq!(message, "Version: 1.0.0");
+
+        let message = ActivityIndicator::version_tooltip_message(&VersionCheckType::Sha(
+            AppCommitSha::new("14d9a4189f058d8736339b06ff2340101eaea5af".to_string()),
+        ));
+
+        assert_eq!(message, "Version: 14d9a41…");
+    }
+}

crates/agent/Cargo.toml 🔗

@@ -19,92 +19,61 @@ test-support = [
 ]
 
 [dependencies]
+agent_settings.workspace = true
 anyhow.workspace = true
-assistant_context_editor.workspace = true
-assistant_settings.workspace = true
-assistant_slash_command.workspace = true
-assistant_slash_commands.workspace = true
+assistant_context.workspace = true
 assistant_tool.workspace = true
-async-watch.workspace = true
-buffer_diff.workspace = true
 chrono.workspace = true
 client.workspace = true
 collections.workspace = true
 component.workspace = true
 context_server.workspace = true
 convert_case.workspace = true
-db.workspace = true
-editor.workspace = true
-extension.workspace = true
 feature_flags.workspace = true
-file_icons.workspace = true
 fs.workspace = true
 futures.workspace = true
-fuzzy.workspace = true
 git.workspace = true
 gpui.workspace = true
 heed.workspace = true
-html_to_markdown.workspace = true
+icons.workspace = true
+indoc.workspace = true
 http_client.workspace = true
-indexed_docs.workspace = true
 itertools.workspace = true
-jsonschema.workspace = true
 language.workspace = true
 language_model.workspace = true
-language_model_selector.workspace = true
-linkme.workspace = true
 log.workspace = true
-lsp.workspace = true
-markdown.workspace = true
-menu.workspace = true
-multi_buffer.workspace = true
-notifications.workspace = true
-ordered-float.workspace = true
-parking_lot.workspace = true
 paths.workspace = true
-picker.workspace = true
 postage.workspace = true
 project.workspace = true
 prompt_store.workspace = true
 proto.workspace = true
 ref-cast.workspace = true
-release_channel.workspace = true
 rope.workspace = true
-rules_library.workspace = true
 schemars.workspace = true
-search.workspace = true
 serde.workspace = true
 serde_json.workspace = true
-serde_json_lenient.workspace = true
 settings.workspace = true
-smallvec.workspace = true
 smol.workspace = true
-streaming_diff.workspace = true
+sqlez.workspace = true
 telemetry.workspace = true
-telemetry_events.workspace = true
-terminal.workspace = true
-terminal_view.workspace = true
 text.workspace = true
 theme.workspace = true
 thiserror.workspace = true
 time.workspace = true
-time_format.workspace = true
-ui.workspace = true
-ui_input.workspace = true
-urlencoding.workspace = true
 util.workspace = true
 uuid.workspace = true
 workspace-hack.workspace = true
-workspace.workspace = true
-zed_actions.workspace = true
 zed_llm_client.workspace = true
+zstd.workspace = true
 
 [dev-dependencies]
-buffer_diff = { workspace = true, features = ["test-support"] }
-editor = { workspace = true, features = ["test-support"] }
+assistant_tools.workspace = true
 gpui = { workspace = true, "features" = ["test-support"] }
 indoc.workspace = true
 language = { workspace = true, "features" = ["test-support"] }
 language_model = { workspace = true, "features" = ["test-support"] }
+parking_lot.workspace = true
+pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
+workspace = { workspace = true, features = ["test-support"] }
 rand.workspace = true

crates/agent/src/agent.rs 🔗

@@ -1,260 +1,20 @@
-mod active_thread;
-mod agent_configuration;
-mod agent_diff;
-mod agent_model_selector;
-mod agent_panel;
-mod buffer_codegen;
-mod context;
-mod context_picker;
-mod context_server_configuration;
-mod context_server_tool;
-mod context_store;
-mod context_strip;
-mod debug;
-mod history_store;
-mod inline_assistant;
-mod inline_prompt_editor;
-mod message_editor;
-mod profile_selector;
-mod slash_command_settings;
-mod terminal_codegen;
-mod terminal_inline_assistant;
-mod thread;
-mod thread_history;
-mod thread_store;
-mod tool_compatibility;
-mod tool_use;
-mod ui;
-
-use std::sync::Arc;
-
-use assistant_settings::{AgentProfileId, AssistantSettings, LanguageModelSelection};
-use assistant_slash_command::SlashCommandRegistry;
-use client::Client;
-use feature_flags::FeatureFlagAppExt as _;
-use fs::Fs;
-use gpui::{App, actions, impl_actions};
-use language::LanguageRegistry;
-use language_model::{LanguageModelId, LanguageModelProviderId, LanguageModelRegistry};
-use prompt_store::PromptBuilder;
-use schemars::JsonSchema;
-use serde::Deserialize;
-use settings::{Settings as _, SettingsStore};
-use thread::ThreadId;
-
-pub use crate::active_thread::ActiveThread;
-use crate::agent_configuration::{AddContextServerModal, ManageProfilesModal};
-pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate};
-pub use crate::context::{ContextLoadResult, LoadedContext};
-pub use crate::inline_assistant::InlineAssistant;
-use crate::slash_command_settings::SlashCommandSettings;
-pub use crate::thread::{Message, MessageSegment, Thread, ThreadEvent};
-pub use crate::thread_store::{TextThreadStore, ThreadStore};
-pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
+pub mod agent_profile;
+pub mod context;
+pub mod context_server_tool;
+pub mod context_store;
+pub mod history_store;
+pub mod thread;
+pub mod thread_store;
+pub mod tool_use;
+
+pub use context::{AgentContext, ContextId, ContextLoadResult};
 pub use context_store::ContextStore;
-pub use ui::preview::{all_agent_previews, get_agent_preview};
-
-actions!(
-    agent,
-    [
-        NewTextThread,
-        ToggleContextPicker,
-        ToggleNavigationMenu,
-        ToggleOptionsMenu,
-        DeleteRecentlyOpenThread,
-        ToggleProfileSelector,
-        RemoveAllContext,
-        ExpandMessageEditor,
-        OpenHistory,
-        AddContextServer,
-        RemoveSelectedThread,
-        Chat,
-        CycleNextInlineAssist,
-        CyclePreviousInlineAssist,
-        FocusUp,
-        FocusDown,
-        FocusLeft,
-        FocusRight,
-        RemoveFocusedContext,
-        AcceptSuggestedContext,
-        OpenActiveThreadAsMarkdown,
-        OpenAgentDiff,
-        Keep,
-        Reject,
-        RejectAll,
-        KeepAll,
-        Follow,
-        ResetTrialUpsell,
-    ]
-);
-
-#[derive(Default, Clone, PartialEq, Deserialize, JsonSchema)]
-pub struct NewThread {
-    #[serde(default)]
-    from_thread_id: Option<ThreadId>,
-}
-
-#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
-pub struct ManageProfiles {
-    #[serde(default)]
-    pub customize_tools: Option<AgentProfileId>,
-}
+pub use thread::{
+    LastRestoreCheckpoint, Message, MessageCrease, MessageId, MessageSegment, Thread, ThreadError,
+    ThreadEvent, ThreadFeedback, ThreadId, ThreadSummary, TokenUsageRatio,
+};
+pub use thread_store::{SerializedThread, TextThreadStore, ThreadStore};
 
-impl ManageProfiles {
-    pub fn customize_tools(profile_id: AgentProfileId) -> Self {
-        Self {
-            customize_tools: Some(profile_id),
-        }
-    }
-}
-
-impl_actions!(agent, [NewThread, ManageProfiles]);
-
-/// Initializes the `agent` crate.
-pub fn init(
-    fs: Arc<dyn Fs>,
-    client: Arc<Client>,
-    prompt_builder: Arc<PromptBuilder>,
-    language_registry: Arc<LanguageRegistry>,
-    cx: &mut App,
-) {
-    AssistantSettings::register(cx);
-    SlashCommandSettings::register(cx);
-
-    assistant_context_editor::init(client.clone(), cx);
-    rules_library::init(cx);
-    init_language_model_settings(cx);
-    assistant_slash_command::init(cx);
+pub fn init(cx: &mut gpui::App) {
     thread_store::init(cx);
-    agent_panel::init(cx);
-    context_server_configuration::init(language_registry, cx);
-
-    register_slash_commands(cx);
-    inline_assistant::init(
-        fs.clone(),
-        prompt_builder.clone(),
-        client.telemetry().clone(),
-        cx,
-    );
-    terminal_inline_assistant::init(
-        fs.clone(),
-        prompt_builder.clone(),
-        client.telemetry().clone(),
-        cx,
-    );
-    indexed_docs::init(cx);
-    cx.observe_new(AddContextServerModal::register).detach();
-    cx.observe_new(ManageProfilesModal::register).detach();
-}
-
-fn init_language_model_settings(cx: &mut App) {
-    update_active_language_model_from_settings(cx);
-
-    cx.observe_global::<SettingsStore>(update_active_language_model_from_settings)
-        .detach();
-    cx.subscribe(
-        &LanguageModelRegistry::global(cx),
-        |_, event: &language_model::Event, cx| match event {
-            language_model::Event::ProviderStateChanged
-            | language_model::Event::AddedProvider(_)
-            | language_model::Event::RemovedProvider(_) => {
-                update_active_language_model_from_settings(cx);
-            }
-            _ => {}
-        },
-    )
-    .detach();
-}
-
-fn update_active_language_model_from_settings(cx: &mut App) {
-    let settings = AssistantSettings::get_global(cx);
-
-    fn to_selected_model(selection: &LanguageModelSelection) -> language_model::SelectedModel {
-        language_model::SelectedModel {
-            provider: LanguageModelProviderId::from(selection.provider.0.clone()),
-            model: LanguageModelId::from(selection.model.clone()),
-        }
-    }
-
-    let default = to_selected_model(&settings.default_model);
-    let inline_assistant = settings
-        .inline_assistant_model
-        .as_ref()
-        .map(to_selected_model);
-    let commit_message = settings
-        .commit_message_model
-        .as_ref()
-        .map(to_selected_model);
-    let thread_summary = settings
-        .thread_summary_model
-        .as_ref()
-        .map(to_selected_model);
-    let inline_alternatives = settings
-        .inline_alternatives
-        .iter()
-        .map(to_selected_model)
-        .collect::<Vec<_>>();
-
-    LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
-        registry.select_default_model(Some(&default), cx);
-        registry.select_inline_assistant_model(inline_assistant.as_ref(), cx);
-        registry.select_commit_message_model(commit_message.as_ref(), cx);
-        registry.select_thread_summary_model(thread_summary.as_ref(), cx);
-        registry.select_inline_alternative_models(inline_alternatives, cx);
-    });
-}
-
-fn register_slash_commands(cx: &mut App) {
-    let slash_command_registry = SlashCommandRegistry::global(cx);
-
-    slash_command_registry.register_command(assistant_slash_commands::FileSlashCommand, true);
-    slash_command_registry.register_command(assistant_slash_commands::DeltaSlashCommand, true);
-    slash_command_registry.register_command(assistant_slash_commands::OutlineSlashCommand, true);
-    slash_command_registry.register_command(assistant_slash_commands::TabSlashCommand, true);
-    slash_command_registry
-        .register_command(assistant_slash_commands::CargoWorkspaceSlashCommand, true);
-    slash_command_registry.register_command(assistant_slash_commands::PromptSlashCommand, true);
-    slash_command_registry.register_command(assistant_slash_commands::SelectionCommand, true);
-    slash_command_registry.register_command(assistant_slash_commands::DefaultSlashCommand, false);
-    slash_command_registry.register_command(assistant_slash_commands::TerminalSlashCommand, true);
-    slash_command_registry.register_command(assistant_slash_commands::NowSlashCommand, false);
-    slash_command_registry
-        .register_command(assistant_slash_commands::DiagnosticsSlashCommand, true);
-    slash_command_registry.register_command(assistant_slash_commands::FetchSlashCommand, true);
-
-    cx.observe_flag::<assistant_slash_commands::StreamingExampleSlashCommandFeatureFlag, _>({
-        let slash_command_registry = slash_command_registry.clone();
-        move |is_enabled, _cx| {
-            if is_enabled {
-                slash_command_registry.register_command(
-                    assistant_slash_commands::StreamingExampleSlashCommand,
-                    false,
-                );
-            }
-        }
-    })
-    .detach();
-
-    update_slash_commands_from_settings(cx);
-    cx.observe_global::<SettingsStore>(update_slash_commands_from_settings)
-        .detach();
-}
-
-fn update_slash_commands_from_settings(cx: &mut App) {
-    let slash_command_registry = SlashCommandRegistry::global(cx);
-    let settings = SlashCommandSettings::get_global(cx);
-
-    if settings.docs.enabled {
-        slash_command_registry.register_command(assistant_slash_commands::DocsSlashCommand, true);
-    } else {
-        slash_command_registry.unregister_command(assistant_slash_commands::DocsSlashCommand);
-    }
-
-    if settings.cargo_workspace.enabled {
-        slash_command_registry
-            .register_command(assistant_slash_commands::CargoWorkspaceSlashCommand, true);
-    } else {
-        slash_command_registry
-            .unregister_command(assistant_slash_commands::CargoWorkspaceSlashCommand);
-    }
 }

crates/agent/src/agent_configuration.rs 🔗

@@ -1,623 +0,0 @@
-mod add_context_server_modal;
-mod configure_context_server_modal;
-mod manage_profiles_modal;
-mod tool_picker;
-
-use std::{sync::Arc, time::Duration};
-
-use assistant_settings::AssistantSettings;
-use assistant_tool::{ToolSource, ToolWorkingSet};
-use collections::HashMap;
-use context_server::ContextServerId;
-use fs::Fs;
-use gpui::{
-    Action, Animation, AnimationExt as _, AnyView, App, Entity, EventEmitter, FocusHandle,
-    Focusable, ScrollHandle, Subscription, pulsating_between,
-};
-use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry};
-use project::context_server_store::{ContextServerStatus, ContextServerStore};
-use settings::{Settings, update_settings_file};
-use ui::{
-    Disclosure, Divider, DividerColor, ElevationIndex, Indicator, Scrollbar, ScrollbarState,
-    Switch, SwitchColor, Tooltip, prelude::*,
-};
-use util::ResultExt as _;
-use zed_actions::ExtensionCategoryFilter;
-
-pub(crate) use add_context_server_modal::AddContextServerModal;
-pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
-pub(crate) use manage_profiles_modal::ManageProfilesModal;
-
-use crate::AddContextServer;
-
-pub struct AgentConfiguration {
-    fs: Arc<dyn Fs>,
-    focus_handle: FocusHandle,
-    configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
-    context_server_store: Entity<ContextServerStore>,
-    expanded_context_server_tools: HashMap<ContextServerId, bool>,
-    tools: Entity<ToolWorkingSet>,
-    _registry_subscription: Subscription,
-    scroll_handle: ScrollHandle,
-    scrollbar_state: ScrollbarState,
-}
-
-impl AgentConfiguration {
-    pub fn new(
-        fs: Arc<dyn Fs>,
-        context_server_store: Entity<ContextServerStore>,
-        tools: Entity<ToolWorkingSet>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Self {
-        let focus_handle = cx.focus_handle();
-
-        let registry_subscription = cx.subscribe_in(
-            &LanguageModelRegistry::global(cx),
-            window,
-            |this, _, event: &language_model::Event, window, cx| match event {
-                language_model::Event::AddedProvider(provider_id) => {
-                    let provider = LanguageModelRegistry::read_global(cx).provider(provider_id);
-                    if let Some(provider) = provider {
-                        this.add_provider_configuration_view(&provider, window, cx);
-                    }
-                }
-                language_model::Event::RemovedProvider(provider_id) => {
-                    this.remove_provider_configuration_view(provider_id);
-                }
-                _ => {}
-            },
-        );
-
-        let scroll_handle = ScrollHandle::new();
-        let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
-
-        let mut this = Self {
-            fs,
-            focus_handle,
-            configuration_views_by_provider: HashMap::default(),
-            context_server_store,
-            expanded_context_server_tools: HashMap::default(),
-            tools,
-            _registry_subscription: registry_subscription,
-            scroll_handle,
-            scrollbar_state,
-        };
-        this.build_provider_configuration_views(window, cx);
-        this
-    }
-
-    fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let providers = LanguageModelRegistry::read_global(cx).providers();
-        for provider in providers {
-            self.add_provider_configuration_view(&provider, window, cx);
-        }
-    }
-
-    fn remove_provider_configuration_view(&mut self, provider_id: &LanguageModelProviderId) {
-        self.configuration_views_by_provider.remove(provider_id);
-    }
-
-    fn add_provider_configuration_view(
-        &mut self,
-        provider: &Arc<dyn LanguageModelProvider>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let configuration_view = provider.configuration_view(window, cx);
-        self.configuration_views_by_provider
-            .insert(provider.id(), configuration_view);
-    }
-}
-
-impl Focusable for AgentConfiguration {
-    fn focus_handle(&self, _: &App) -> FocusHandle {
-        self.focus_handle.clone()
-    }
-}
-
-pub enum AssistantConfigurationEvent {
-    NewThread(Arc<dyn LanguageModelProvider>),
-}
-
-impl EventEmitter<AssistantConfigurationEvent> for AgentConfiguration {}
-
-impl AgentConfiguration {
-    fn render_provider_configuration_block(
-        &mut self,
-        provider: &Arc<dyn LanguageModelProvider>,
-        cx: &mut Context<Self>,
-    ) -> impl IntoElement + use<> {
-        let provider_id = provider.id().0.clone();
-        let provider_name = provider.name().0.clone();
-        let configuration_view = self
-            .configuration_views_by_provider
-            .get(&provider.id())
-            .cloned();
-
-        v_flex()
-            .pt_3()
-            .pb_1()
-            .gap_1p5()
-            .border_t_1()
-            .border_color(cx.theme().colors().border.opacity(0.6))
-            .child(
-                h_flex()
-                    .justify_between()
-                    .child(
-                        h_flex()
-                            .gap_2()
-                            .child(
-                                Icon::new(provider.icon())
-                                    .size(IconSize::Small)
-                                    .color(Color::Muted),
-                            )
-                            .child(Label::new(provider_name.clone()).size(LabelSize::Large)),
-                    )
-                    .when(provider.is_authenticated(cx), |parent| {
-                        parent.child(
-                            Button::new(
-                                SharedString::from(format!("new-thread-{provider_id}")),
-                                "Start New Thread",
-                            )
-                            .icon_position(IconPosition::Start)
-                            .icon(IconName::Plus)
-                            .icon_size(IconSize::Small)
-                            .style(ButtonStyle::Filled)
-                            .layer(ElevationIndex::ModalSurface)
-                            .label_size(LabelSize::Small)
-                            .on_click(cx.listener({
-                                let provider = provider.clone();
-                                move |_this, _event, _window, cx| {
-                                    cx.emit(AssistantConfigurationEvent::NewThread(
-                                        provider.clone(),
-                                    ))
-                                }
-                            })),
-                        )
-                    }),
-            )
-            .map(|parent| match configuration_view {
-                Some(configuration_view) => parent.child(configuration_view),
-                None => parent.child(div().child(Label::new(format!(
-                    "No configuration view for {provider_name}",
-                )))),
-            })
-    }
-
-    fn render_provider_configuration_section(
-        &mut self,
-        cx: &mut Context<Self>,
-    ) -> impl IntoElement {
-        let providers = LanguageModelRegistry::read_global(cx).providers();
-
-        v_flex()
-            .p(DynamicSpacing::Base16.rems(cx))
-            .pr(DynamicSpacing::Base20.rems(cx))
-            .gap_4()
-            .flex_1()
-            .child(
-                v_flex()
-                    .gap_0p5()
-                    .child(Headline::new("LLM Providers"))
-                    .child(
-                        Label::new("Add at least one provider to use AI-powered features.")
-                            .color(Color::Muted),
-                    ),
-            )
-            .children(
-                providers
-                    .into_iter()
-                    .map(|provider| self.render_provider_configuration_block(&provider, cx)),
-            )
-    }
-
-    fn render_command_permission(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
-        let always_allow_tool_actions = AssistantSettings::get_global(cx).always_allow_tool_actions;
-
-        h_flex()
-            .gap_4()
-            .justify_between()
-            .flex_wrap()
-            .child(
-                v_flex()
-                    .gap_0p5()
-                    .max_w_5_6()
-                    .child(Label::new("Allow running editing tools without asking for confirmation"))
-                    .child(
-                        Label::new(
-                            "The agent can perform potentially destructive actions without asking for your confirmation.",
-                        )
-                        .color(Color::Muted),
-                    ),
-            )
-            .child(
-                Switch::new(
-                    "always-allow-tool-actions-switch",
-                    always_allow_tool_actions.into(),
-                )
-                .color(SwitchColor::Accent)
-                .on_click({
-                    let fs = self.fs.clone();
-                    move |state, _window, cx| {
-                        let allow = state == &ToggleState::Selected;
-                        update_settings_file::<AssistantSettings>(
-                            fs.clone(),
-                            cx,
-                            move |settings, _| {
-                                settings.set_always_allow_tool_actions(allow);
-                            },
-                        );
-                    }
-                }),
-            )
-    }
-
-    fn render_single_file_review(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
-        let single_file_review = AssistantSettings::get_global(cx).single_file_review;
-
-        h_flex()
-            .gap_4()
-            .justify_between()
-            .flex_wrap()
-            .child(
-                v_flex()
-                    .gap_0p5()
-                    .max_w_5_6()
-                    .child(Label::new("Enable single-file agent reviews"))
-                    .child(
-                        Label::new(
-                            "Agent edits are also displayed in single-file editors for review.",
-                        )
-                        .color(Color::Muted),
-                    ),
-            )
-            .child(
-                Switch::new("single-file-review-switch", single_file_review.into())
-                    .color(SwitchColor::Accent)
-                    .on_click({
-                        let fs = self.fs.clone();
-                        move |state, _window, cx| {
-                            let allow = state == &ToggleState::Selected;
-                            update_settings_file::<AssistantSettings>(
-                                fs.clone(),
-                                cx,
-                                move |settings, _| {
-                                    settings.set_single_file_review(allow);
-                                },
-                            );
-                        }
-                    }),
-            )
-    }
-
-    fn render_general_settings_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
-        v_flex()
-            .p(DynamicSpacing::Base16.rems(cx))
-            .pr(DynamicSpacing::Base20.rems(cx))
-            .gap_2p5()
-            .flex_1()
-            .child(Headline::new("General Settings"))
-            .child(self.render_command_permission(cx))
-            .child(self.render_single_file_review(cx))
-    }
-
-    fn render_context_servers_section(
-        &mut self,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> impl IntoElement {
-        let context_server_ids = self.context_server_store.read(cx).all_server_ids().clone();
-
-        const SUBHEADING: &str = "Connect to context servers via the Model Context Protocol either via Zed extensions or directly.";
-
-        v_flex()
-            .p(DynamicSpacing::Base16.rems(cx))
-            .pr(DynamicSpacing::Base20.rems(cx))
-            .gap_2()
-            .flex_1()
-            .child(
-                v_flex()
-                    .gap_0p5()
-                    .child(Headline::new("Model Context Protocol (MCP) Servers"))
-                    .child(Label::new(SUBHEADING).color(Color::Muted)),
-            )
-            .children(
-                context_server_ids.into_iter().map(|context_server_id| {
-                    self.render_context_server(context_server_id, window, cx)
-                }),
-            )
-            .child(
-                h_flex()
-                    .justify_between()
-                    .gap_2()
-                    .child(
-                        h_flex().w_full().child(
-                            Button::new("add-context-server", "Add Custom Server")
-                                .style(ButtonStyle::Filled)
-                                .layer(ElevationIndex::ModalSurface)
-                                .full_width()
-                                .icon(IconName::Plus)
-                                .icon_size(IconSize::Small)
-                                .icon_position(IconPosition::Start)
-                                .on_click(|_event, window, cx| {
-                                    window.dispatch_action(AddContextServer.boxed_clone(), cx)
-                                }),
-                        ),
-                    )
-                    .child(
-                        h_flex().w_full().child(
-                            Button::new(
-                                "install-context-server-extensions",
-                                "Install MCP Extensions",
-                            )
-                            .style(ButtonStyle::Filled)
-                            .layer(ElevationIndex::ModalSurface)
-                            .full_width()
-                            .icon(IconName::Hammer)
-                            .icon_size(IconSize::Small)
-                            .icon_position(IconPosition::Start)
-                            .on_click(|_event, window, cx| {
-                                window.dispatch_action(
-                                    zed_actions::Extensions {
-                                        category_filter: Some(
-                                            ExtensionCategoryFilter::ContextServers,
-                                        ),
-                                    }
-                                    .boxed_clone(),
-                                    cx,
-                                )
-                            }),
-                        ),
-                    ),
-            )
-    }
-
-    fn render_context_server(
-        &self,
-        context_server_id: ContextServerId,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> impl use<> + IntoElement {
-        let tools_by_source = self.tools.read(cx).tools_by_source(cx);
-        let server_status = self
-            .context_server_store
-            .read(cx)
-            .status_for_server(&context_server_id)
-            .unwrap_or(ContextServerStatus::Stopped);
-
-        let is_running = matches!(server_status, ContextServerStatus::Running);
-
-        let error = if let ContextServerStatus::Error(error) = server_status.clone() {
-            Some(error)
-        } else {
-            None
-        };
-
-        let are_tools_expanded = self
-            .expanded_context_server_tools
-            .get(&context_server_id)
-            .copied()
-            .unwrap_or_default();
-
-        let tools = tools_by_source
-            .get(&ToolSource::ContextServer {
-                id: context_server_id.0.clone().into(),
-            })
-            .map_or([].as_slice(), |tools| tools.as_slice());
-        let tool_count = tools.len();
-
-        let border_color = cx.theme().colors().border.opacity(0.6);
-
-        v_flex()
-            .id(SharedString::from(context_server_id.0.clone()))
-            .border_1()
-            .rounded_md()
-            .border_color(border_color)
-            .bg(cx.theme().colors().background.opacity(0.2))
-            .overflow_hidden()
-            .child(
-                h_flex()
-                    .p_1()
-                    .justify_between()
-                    .when(
-                        error.is_some() || are_tools_expanded && tool_count > 1,
-                        |element| element.border_b_1().border_color(border_color),
-                    )
-                    .child(
-                        h_flex()
-                            .gap_1p5()
-                            .child(
-                                Disclosure::new(
-                                    "tool-list-disclosure",
-                                    are_tools_expanded || error.is_some(),
-                                )
-                                .disabled(tool_count == 0)
-                                .on_click(cx.listener({
-                                    let context_server_id = context_server_id.clone();
-                                    move |this, _event, _window, _cx| {
-                                        let is_open = this
-                                            .expanded_context_server_tools
-                                            .entry(context_server_id.clone())
-                                            .or_insert(false);
-
-                                        *is_open = !*is_open;
-                                    }
-                                })),
-                            )
-                            .child(match server_status {
-                                ContextServerStatus::Starting => {
-                                    let color = Color::Success.color(cx);
-                                    Indicator::dot()
-                                        .color(Color::Success)
-                                        .with_animation(
-                                            SharedString::from(format!(
-                                                "{}-starting",
-                                                context_server_id.0.clone(),
-                                            )),
-                                            Animation::new(Duration::from_secs(2))
-                                                .repeat()
-                                                .with_easing(pulsating_between(0.4, 1.)),
-                                            move |this, delta| {
-                                                this.color(color.alpha(delta).into())
-                                            },
-                                        )
-                                        .into_any_element()
-                                }
-                                ContextServerStatus::Running => {
-                                    Indicator::dot().color(Color::Success).into_any_element()
-                                }
-                                ContextServerStatus::Error(_) => {
-                                    Indicator::dot().color(Color::Error).into_any_element()
-                                }
-                                ContextServerStatus::Stopped => {
-                                    Indicator::dot().color(Color::Muted).into_any_element()
-                                }
-                            })
-                            .child(Label::new(context_server_id.0.clone()).ml_0p5())
-                            .when(is_running, |this| {
-                                this.child(
-                                    Label::new(if tool_count == 1 {
-                                        SharedString::from("1 tool")
-                                    } else {
-                                        SharedString::from(format!("{} tools", tool_count))
-                                    })
-                                    .color(Color::Muted)
-                                    .size(LabelSize::Small),
-                                )
-                            }),
-                    )
-                    .child(
-                        Switch::new("context-server-switch", is_running.into())
-                            .color(SwitchColor::Accent)
-                            .on_click({
-                                let context_server_manager = self.context_server_store.clone();
-                                let context_server_id = context_server_id.clone();
-                                move |state, _window, cx| match state {
-                                    ToggleState::Unselected | ToggleState::Indeterminate => {
-                                        context_server_manager.update(cx, |this, cx| {
-                                            this.stop_server(&context_server_id, cx).log_err();
-                                        });
-                                    }
-                                    ToggleState::Selected => {
-                                        context_server_manager.update(cx, |this, cx| {
-                                            if let Some(server) =
-                                                this.get_server(&context_server_id)
-                                            {
-                                                this.start_server(server, cx).log_err();
-                                            }
-                                        })
-                                    }
-                                }
-                            }),
-                    ),
-            )
-            .map(|parent| {
-                if let Some(error) = error {
-                    return parent.child(
-                        h_flex()
-                            .p_2()
-                            .gap_2()
-                            .items_start()
-                            .child(
-                                h_flex()
-                                    .flex_none()
-                                    .h(window.line_height() / 1.6_f32)
-                                    .justify_center()
-                                    .child(
-                                        Icon::new(IconName::XCircle)
-                                            .size(IconSize::XSmall)
-                                            .color(Color::Error),
-                                    ),
-                            )
-                            .child(
-                                div().w_full().child(
-                                    Label::new(error)
-                                        .buffer_font(cx)
-                                        .color(Color::Muted)
-                                        .size(LabelSize::Small),
-                                ),
-                            ),
-                    );
-                }
-
-                if !are_tools_expanded || tools.is_empty() {
-                    return parent;
-                }
-
-                parent.child(v_flex().py_1p5().px_1().gap_1().children(
-                    tools.into_iter().enumerate().map(|(ix, tool)| {
-                        h_flex()
-                            .id(("tool-item", ix))
-                            .px_1()
-                            .gap_2()
-                            .justify_between()
-                            .hover(|style| style.bg(cx.theme().colors().element_hover))
-                            .rounded_sm()
-                            .child(
-                                Label::new(tool.name())
-                                    .buffer_font(cx)
-                                    .size(LabelSize::Small),
-                            )
-                            .child(
-                                Icon::new(IconName::Info)
-                                    .size(IconSize::Small)
-                                    .color(Color::Ignored),
-                            )
-                            .tooltip(Tooltip::text(tool.description()))
-                    }),
-                ))
-            })
-    }
-}
-
-impl Render for AgentConfiguration {
-    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        v_flex()
-            .id("assistant-configuration")
-            .key_context("AgentConfiguration")
-            .track_focus(&self.focus_handle(cx))
-            .relative()
-            .size_full()
-            .pb_8()
-            .bg(cx.theme().colors().panel_background)
-            .child(
-                v_flex()
-                    .id("assistant-configuration-content")
-                    .track_scroll(&self.scroll_handle)
-                    .size_full()
-                    .overflow_y_scroll()
-                    .child(self.render_general_settings_section(cx))
-                    .child(Divider::horizontal().color(DividerColor::Border))
-                    .child(self.render_context_servers_section(window, cx))
-                    .child(Divider::horizontal().color(DividerColor::Border))
-                    .child(self.render_provider_configuration_section(cx)),
-            )
-            .child(
-                div()
-                    .id("assistant-configuration-scrollbar")
-                    .occlude()
-                    .absolute()
-                    .right(px(3.))
-                    .top_0()
-                    .bottom_0()
-                    .pb_6()
-                    .w(px(12.))
-                    .cursor_default()
-                    .on_mouse_move(cx.listener(|_, _, _window, cx| {
-                        cx.notify();
-                        cx.stop_propagation()
-                    }))
-                    .on_hover(|_, _window, cx| {
-                        cx.stop_propagation();
-                    })
-                    .on_any_mouse_down(|_, _window, cx| {
-                        cx.stop_propagation();
-                    })
-                    .on_scroll_wheel(cx.listener(|_, _, _window, cx| {
-                        cx.notify();
-                    }))
-                    .children(Scrollbar::vertical(self.scrollbar_state.clone())),
-            )
-    }
-}

crates/agent/src/agent_configuration/add_context_server_modal.rs 🔗

@@ -1,197 +0,0 @@
-use context_server::ContextServerCommand;
-use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity, prelude::*};
-use project::project_settings::{ContextServerConfiguration, ProjectSettings};
-use serde_json::json;
-use settings::update_settings_file;
-use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*};
-use ui_input::SingleLineInput;
-use workspace::{ModalView, Workspace};
-
-use crate::AddContextServer;
-
-pub struct AddContextServerModal {
-    workspace: WeakEntity<Workspace>,
-    name_editor: Entity<SingleLineInput>,
-    command_editor: Entity<SingleLineInput>,
-}
-
-impl AddContextServerModal {
-    pub fn register(
-        workspace: &mut Workspace,
-        _window: Option<&mut Window>,
-        _cx: &mut Context<Workspace>,
-    ) {
-        workspace.register_action(|workspace, _: &AddContextServer, window, cx| {
-            let workspace_handle = cx.entity().downgrade();
-            workspace.toggle_modal(window, cx, |window, cx| {
-                Self::new(workspace_handle, window, cx)
-            })
-        });
-    }
-
-    pub fn new(
-        workspace: WeakEntity<Workspace>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Self {
-        let name_editor =
-            cx.new(|cx| SingleLineInput::new(window, cx, "my-custom-server").label("Name"));
-        let command_editor = cx.new(|cx| {
-            SingleLineInput::new(window, cx, "Command").label("Command to run the MCP server")
-        });
-
-        Self {
-            name_editor,
-            command_editor,
-            workspace,
-        }
-    }
-
-    fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context<Self>) {
-        let name = self
-            .name_editor
-            .read(cx)
-            .editor()
-            .read(cx)
-            .text(cx)
-            .trim()
-            .to_string();
-        let command = self
-            .command_editor
-            .read(cx)
-            .editor()
-            .read(cx)
-            .text(cx)
-            .trim()
-            .to_string();
-
-        if name.is_empty() || command.is_empty() {
-            return;
-        }
-
-        let mut command_parts = command.split(' ').map(|part| part.trim().to_string());
-        let Some(path) = command_parts.next() else {
-            return;
-        };
-        let args = command_parts.collect::<Vec<_>>();
-
-        if let Some(workspace) = self.workspace.upgrade() {
-            workspace.update(cx, |workspace, cx| {
-                let fs = workspace.app_state().fs.clone();
-                update_settings_file::<ProjectSettings>(fs.clone(), cx, |settings, _| {
-                    settings.context_servers.insert(
-                        name.into(),
-                        ContextServerConfiguration {
-                            command: Some(ContextServerCommand {
-                                path,
-                                args,
-                                env: None,
-                            }),
-                            settings: Some(json!({})),
-                        },
-                    );
-                });
-            });
-        }
-
-        cx.emit(DismissEvent);
-    }
-
-    fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context<Self>) {
-        cx.emit(DismissEvent);
-    }
-}
-
-impl ModalView for AddContextServerModal {}
-
-impl Focusable for AddContextServerModal {
-    fn focus_handle(&self, cx: &App) -> FocusHandle {
-        self.name_editor.focus_handle(cx).clone()
-    }
-}
-
-impl EventEmitter<DismissEvent> for AddContextServerModal {}
-
-impl Render for AddContextServerModal {
-    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let is_name_empty = self.name_editor.read(cx).is_empty(cx);
-        let is_command_empty = self.command_editor.read(cx).is_empty(cx);
-
-        let focus_handle = self.focus_handle(cx);
-
-        div()
-            .elevation_3(cx)
-            .w(rems(34.))
-            .key_context("AddContextServerModal")
-            .on_action(
-                cx.listener(|this, _: &menu::Cancel, _window, cx| this.cancel(&menu::Cancel, cx)),
-            )
-            .on_action(
-                cx.listener(|this, _: &menu::Confirm, _window, cx| {
-                    this.confirm(&menu::Confirm, cx)
-                }),
-            )
-            .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
-                this.focus_handle(cx).focus(window);
-            }))
-            .on_mouse_down_out(cx.listener(|_this, _, _, cx| cx.emit(DismissEvent)))
-            .child(
-                Modal::new("add-context-server", None)
-                    .header(ModalHeader::new().headline("Add MCP Server"))
-                    .section(
-                        Section::new().child(
-                            v_flex()
-                                .gap_2()
-                                .child(self.name_editor.clone())
-                                .child(self.command_editor.clone()),
-                        ),
-                    )
-                    .footer(
-                        ModalFooter::new().end_slot(
-                            h_flex()
-                                .gap_2()
-                                .child(
-                                    Button::new("cancel", "Cancel")
-                                        .key_binding(
-                                            KeyBinding::for_action_in(
-                                                &menu::Cancel,
-                                                &focus_handle,
-                                                window,
-                                                cx,
-                                            )
-                                            .map(|kb| kb.size(rems_from_px(12.))),
-                                        )
-                                        .on_click(cx.listener(|this, _event, _window, cx| {
-                                            this.cancel(&menu::Cancel, cx)
-                                        })),
-                                )
-                                .child(
-                                    Button::new("add-server", "Add Server")
-                                        .disabled(is_name_empty || is_command_empty)
-                                        .key_binding(
-                                            KeyBinding::for_action_in(
-                                                &menu::Confirm,
-                                                &focus_handle,
-                                                window,
-                                                cx,
-                                            )
-                                            .map(|kb| kb.size(rems_from_px(12.))),
-                                        )
-                                        .map(|button| {
-                                            if is_name_empty {
-                                                button.tooltip(Tooltip::text("Name is required"))
-                                            } else if is_command_empty {
-                                                button.tooltip(Tooltip::text("Command is required"))
-                                            } else {
-                                                button
-                                            }
-                                        })
-                                        .on_click(cx.listener(|this, _event, _window, cx| {
-                                            this.confirm(&menu::Confirm, cx)
-                                        })),
-                                ),
-                        ),
-                    ),
-            )
-    }
-}

crates/agent/src/agent_configuration/configure_context_server_modal.rs 🔗

@@ -1,554 +0,0 @@
-use std::{
-    sync::{Arc, Mutex},
-    time::Duration,
-};
-
-use anyhow::Context as _;
-use context_server::ContextServerId;
-use editor::{Editor, EditorElement, EditorStyle};
-use gpui::{
-    Animation, AnimationExt, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task,
-    TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, percentage,
-};
-use language::{Language, LanguageRegistry};
-use markdown::{Markdown, MarkdownElement, MarkdownStyle};
-use notifications::status_toast::{StatusToast, ToastIcon};
-use project::{
-    context_server_store::{ContextServerStatus, ContextServerStore},
-    project_settings::{ContextServerConfiguration, ProjectSettings},
-};
-use settings::{Settings as _, update_settings_file};
-use theme::ThemeSettings;
-use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*};
-use util::ResultExt;
-use workspace::{ModalView, Workspace};
-
-pub(crate) struct ConfigureContextServerModal {
-    workspace: WeakEntity<Workspace>,
-    focus_handle: FocusHandle,
-    context_servers_to_setup: Vec<ContextServerSetup>,
-    context_server_store: Entity<ContextServerStore>,
-}
-
-#[allow(clippy::large_enum_variant)]
-enum Configuration {
-    NotAvailable,
-    Required(ConfigurationRequiredState),
-}
-
-struct ConfigurationRequiredState {
-    installation_instructions: Entity<markdown::Markdown>,
-    settings_validator: Option<jsonschema::Validator>,
-    settings_editor: Entity<Editor>,
-    last_error: Option<SharedString>,
-    waiting_for_context_server: bool,
-}
-
-struct ContextServerSetup {
-    id: ContextServerId,
-    repository_url: Option<SharedString>,
-    configuration: Configuration,
-}
-
-impl ConfigureContextServerModal {
-    pub fn new(
-        configurations: impl Iterator<Item = crate::context_server_configuration::Configuration>,
-        context_server_store: Entity<ContextServerStore>,
-        jsonc_language: Option<Arc<Language>>,
-        language_registry: Arc<LanguageRegistry>,
-        workspace: WeakEntity<Workspace>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Self {
-        let context_servers_to_setup = configurations
-            .map(|config| match config {
-                crate::context_server_configuration::Configuration::NotAvailable(
-                    context_server_id,
-                    repository_url,
-                ) => ContextServerSetup {
-                    id: context_server_id,
-                    repository_url,
-                    configuration: Configuration::NotAvailable,
-                },
-                crate::context_server_configuration::Configuration::Required(
-                    context_server_id,
-                    repository_url,
-                    config,
-                ) => {
-                    let jsonc_language = jsonc_language.clone();
-                    let settings_validator = jsonschema::validator_for(&config.settings_schema)
-                        .context("Failed to load JSON schema for context server settings")
-                        .log_err();
-                    let state = ConfigurationRequiredState {
-                        installation_instructions: cx.new(|cx| {
-                            Markdown::new(
-                                config.installation_instructions.clone().into(),
-                                Some(language_registry.clone()),
-                                None,
-                                cx,
-                            )
-                        }),
-                        settings_validator,
-                        settings_editor: cx.new(|cx| {
-                            let mut editor = Editor::auto_height(16, window, cx);
-                            editor.set_text(config.default_settings.trim(), window, cx);
-                            editor.set_show_gutter(false, cx);
-                            editor.set_soft_wrap_mode(
-                                language::language_settings::SoftWrap::None,
-                                cx,
-                            );
-                            if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
-                                buffer.update(cx, |buffer, cx| {
-                                    buffer.set_language(jsonc_language, cx)
-                                })
-                            }
-                            editor
-                        }),
-                        waiting_for_context_server: false,
-                        last_error: None,
-                    };
-                    ContextServerSetup {
-                        id: context_server_id,
-                        repository_url,
-                        configuration: Configuration::Required(state),
-                    }
-                }
-            })
-            .collect::<Vec<_>>();
-
-        Self {
-            workspace,
-            focus_handle: cx.focus_handle(),
-            context_servers_to_setup,
-            context_server_store,
-        }
-    }
-}
-
-impl ConfigureContextServerModal {
-    pub fn confirm(&mut self, cx: &mut Context<Self>) {
-        if self.context_servers_to_setup.is_empty() {
-            self.dismiss(cx);
-            return;
-        }
-
-        let Some(workspace) = self.workspace.upgrade() else {
-            return;
-        };
-
-        let id = self.context_servers_to_setup[0].id.clone();
-        let configuration = match &mut self.context_servers_to_setup[0].configuration {
-            Configuration::NotAvailable => {
-                self.context_servers_to_setup.remove(0);
-                if self.context_servers_to_setup.is_empty() {
-                    self.dismiss(cx);
-                }
-                return;
-            }
-            Configuration::Required(state) => state,
-        };
-
-        configuration.last_error.take();
-        if configuration.waiting_for_context_server {
-            return;
-        }
-
-        let settings_value = match serde_json_lenient::from_str::<serde_json::Value>(
-            &configuration.settings_editor.read(cx).text(cx),
-        ) {
-            Ok(value) => value,
-            Err(error) => {
-                configuration.last_error = Some(error.to_string().into());
-                cx.notify();
-                return;
-            }
-        };
-
-        if let Some(validator) = configuration.settings_validator.as_ref() {
-            if let Err(error) = validator.validate(&settings_value) {
-                configuration.last_error = Some(error.to_string().into());
-                cx.notify();
-                return;
-            }
-        }
-        let id = id.clone();
-
-        let settings_changed = ProjectSettings::get_global(cx)
-            .context_servers
-            .get(&id.0)
-            .map_or(true, |config| {
-                config.settings.as_ref() != Some(&settings_value)
-            });
-
-        let is_running = self.context_server_store.read(cx).status_for_server(&id)
-            == Some(ContextServerStatus::Running);
-
-        if !settings_changed && is_running {
-            self.complete_setup(id, cx);
-            return;
-        }
-
-        configuration.waiting_for_context_server = true;
-
-        let task = wait_for_context_server(&self.context_server_store, id.clone(), cx);
-        cx.spawn({
-            let id = id.clone();
-            async move |this, cx| {
-                let result = task.await;
-                this.update(cx, |this, cx| match result {
-                    Ok(_) => {
-                        this.complete_setup(id, cx);
-                    }
-                    Err(err) => {
-                        if let Some(setup) = this.context_servers_to_setup.get_mut(0) {
-                            match &mut setup.configuration {
-                                Configuration::NotAvailable => {}
-                                Configuration::Required(state) => {
-                                    state.last_error = Some(err.into());
-                                    state.waiting_for_context_server = false;
-                                }
-                            }
-                        } else {
-                            this.dismiss(cx);
-                        }
-                        cx.notify();
-                    }
-                })
-            }
-        })
-        .detach();
-
-        // When we write the settings to the file, the context server will be restarted.
-        update_settings_file::<ProjectSettings>(workspace.read(cx).app_state().fs.clone(), cx, {
-            let id = id.clone();
-            |settings, _| {
-                if let Some(server_config) = settings.context_servers.get_mut(&id.0) {
-                    server_config.settings = Some(settings_value);
-                } else {
-                    settings.context_servers.insert(
-                        id.0,
-                        ContextServerConfiguration {
-                            settings: Some(settings_value),
-                            ..Default::default()
-                        },
-                    );
-                }
-            }
-        });
-    }
-
-    fn complete_setup(&mut self, id: ContextServerId, cx: &mut Context<Self>) {
-        self.context_servers_to_setup.remove(0);
-        cx.notify();
-
-        if !self.context_servers_to_setup.is_empty() {
-            return;
-        }
-
-        self.workspace
-            .update(cx, {
-                |workspace, cx| {
-                    let status_toast = StatusToast::new(
-                        format!("{} configured successfully.", id),
-                        cx,
-                        |this, _cx| {
-                            this.icon(ToastIcon::new(IconName::Hammer).color(Color::Muted))
-                                .action("Dismiss", |_, _| {})
-                        },
-                    );
-
-                    workspace.toggle_status_toast(status_toast, cx);
-                }
-            })
-            .log_err();
-
-        self.dismiss(cx);
-    }
-
-    fn dismiss(&self, cx: &mut Context<Self>) {
-        cx.emit(DismissEvent);
-    }
-}
-
-fn wait_for_context_server(
-    context_server_store: &Entity<ContextServerStore>,
-    context_server_id: ContextServerId,
-    cx: &mut App,
-) -> Task<Result<(), Arc<str>>> {
-    let (tx, rx) = futures::channel::oneshot::channel();
-    let tx = Arc::new(Mutex::new(Some(tx)));
-
-    let subscription = cx.subscribe(context_server_store, move |_, event, _cx| match event {
-        project::context_server_store::Event::ServerStatusChanged { server_id, status } => {
-            match status {
-                ContextServerStatus::Running => {
-                    if server_id == &context_server_id {
-                        if let Some(tx) = tx.lock().unwrap().take() {
-                            let _ = tx.send(Ok(()));
-                        }
-                    }
-                }
-                ContextServerStatus::Stopped => {
-                    if server_id == &context_server_id {
-                        if let Some(tx) = tx.lock().unwrap().take() {
-                            let _ = tx.send(Err("Context server stopped running".into()));
-                        }
-                    }
-                }
-                ContextServerStatus::Error(error) => {
-                    if server_id == &context_server_id {
-                        if let Some(tx) = tx.lock().unwrap().take() {
-                            let _ = tx.send(Err(error.clone()));
-                        }
-                    }
-                }
-                _ => {}
-            }
-        }
-    });
-
-    cx.spawn(async move |_cx| {
-        let result = rx.await.unwrap();
-        drop(subscription);
-        result
-    })
-}
-
-impl Render for ConfigureContextServerModal {
-    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let Some(setup) = self.context_servers_to_setup.first() else {
-            return div().into_any_element();
-        };
-
-        let focus_handle = self.focus_handle(cx);
-
-        div()
-            .elevation_3(cx)
-            .w(rems(42.))
-            .key_context("ConfigureContextServerModal")
-            .track_focus(&focus_handle)
-            .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| this.confirm(cx)))
-            .on_action(cx.listener(|this, _: &menu::Cancel, _window, cx| this.dismiss(cx)))
-            .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
-                this.focus_handle(cx).focus(window);
-            }))
-            .child(
-                Modal::new("configure-context-server", None)
-                    .header(ModalHeader::new().headline(format!("Configure {}", setup.id)))
-                    .section(match &setup.configuration {
-                        Configuration::NotAvailable => Section::new().child(
-                            Label::new(
-                                "No configuration options available for this context server. Visit the Repository for any further instructions.",
-                            )
-                            .color(Color::Muted),
-                        ),
-                        Configuration::Required(configuration) => Section::new()
-                            .child(div().pb_2().text_sm().child(MarkdownElement::new(
-                                configuration.installation_instructions.clone(),
-                                default_markdown_style(window, cx),
-                            )))
-                            .child(
-                                div()
-                                    .p_2()
-                                    .rounded_md()
-                                    .border_1()
-                                    .border_color(cx.theme().colors().border_variant)
-                                    .bg(cx.theme().colors().editor_background)
-                                    .gap_1()
-                                    .child({
-                                        let settings = ThemeSettings::get_global(cx);
-                                        let text_style = TextStyle {
-                                            color: cx.theme().colors().text,
-                                            font_family: settings.buffer_font.family.clone(),
-                                            font_fallbacks: settings.buffer_font.fallbacks.clone(),
-                                            font_size: settings.buffer_font_size(cx).into(),
-                                            font_weight: settings.buffer_font.weight,
-                                            line_height: relative(
-                                                settings.buffer_line_height.value(),
-                                            ),
-                                            ..Default::default()
-                                        };
-                                        EditorElement::new(
-                                            &configuration.settings_editor,
-                                            EditorStyle {
-                                                background: cx.theme().colors().editor_background,
-                                                local_player: cx.theme().players().local(),
-                                                text: text_style,
-                                                syntax: cx.theme().syntax().clone(),
-                                                ..Default::default()
-                                            },
-                                        )
-                                    })
-                                    .when_some(configuration.last_error.clone(), |this, error| {
-                                        this.child(
-                                            h_flex()
-                                                .gap_2()
-                                                .px_2()
-                                                .py_1()
-                                                .child(
-                                                    Icon::new(IconName::Warning)
-                                                        .size(IconSize::XSmall)
-                                                        .color(Color::Warning),
-                                                )
-                                                .child(
-                                                    div().w_full().child(
-                                                        Label::new(error)
-                                                            .size(LabelSize::Small)
-                                                            .color(Color::Muted),
-                                                    ),
-                                                ),
-                                        )
-                                    }),
-                            )
-                            .when(configuration.waiting_for_context_server, |this| {
-                                this.child(
-                                    h_flex()
-                                        .gap_1p5()
-                                        .child(
-                                            Icon::new(IconName::ArrowCircle)
-                                                .size(IconSize::XSmall)
-                                                .color(Color::Info)
-                                                .with_animation(
-                                                    "arrow-circle",
-                                                    Animation::new(Duration::from_secs(2)).repeat(),
-                                                    |icon, delta| {
-                                                        icon.transform(Transformation::rotate(
-                                                            percentage(delta),
-                                                        ))
-                                                    },
-                                                )
-                                                .into_any_element(),
-                                        )
-                                        .child(
-                                            Label::new("Waiting for Context Server")
-                                                .size(LabelSize::Small)
-                                                .color(Color::Muted),
-                                        ),
-                                )
-                            }),
-                    })
-                    .footer(
-                        ModalFooter::new()
-                            .when_some(setup.repository_url.clone(), |this, repository_url| {
-                                this.start_slot(
-                                    h_flex().w_full().child(
-                                        Button::new("open-repository", "Open Repository")
-                                            .icon(IconName::ArrowUpRight)
-                                            .icon_color(Color::Muted)
-                                            .icon_size(IconSize::XSmall)
-                                            .tooltip({
-                                                let repository_url = repository_url.clone();
-                                                move |window, cx| {
-                                                    Tooltip::with_meta(
-                                                        "Open Repository",
-                                                        None,
-                                                        repository_url.clone(),
-                                                        window,
-                                                        cx,
-                                                    )
-                                                }
-                                            })
-                                            .on_click(move |_, _, cx| cx.open_url(&repository_url)),
-                                    ),
-                                )
-                            })
-                            .end_slot(match &setup.configuration {
-                                Configuration::NotAvailable => Button::new("dismiss", "Dismiss")
-                                    .key_binding(
-                                        KeyBinding::for_action_in(
-                                            &menu::Cancel,
-                                            &focus_handle,
-                                            window,
-                                            cx,
-                                        )
-                                        .map(|kb| kb.size(rems_from_px(12.))),
-                                    )
-                                    .on_click(
-                                        cx.listener(|this, _event, _window, cx| this.dismiss(cx)),
-                                    )
-                                    .into_any_element(),
-                                Configuration::Required(state) => h_flex()
-                                    .gap_2()
-                                    .child(
-                                        Button::new("cancel", "Cancel")
-                                            .key_binding(
-                                                KeyBinding::for_action_in(
-                                                    &menu::Cancel,
-                                                    &focus_handle,
-                                                    window,
-                                                    cx,
-                                                )
-                                                .map(|kb| kb.size(rems_from_px(12.))),
-                                            )
-                                            .on_click(cx.listener(|this, _event, _window, cx| {
-                                                this.dismiss(cx)
-                                            })),
-                                    )
-                                    .child(
-                                        Button::new("configure-server", "Configure MCP")
-                                            .disabled(state.waiting_for_context_server)
-                                            .key_binding(
-                                                KeyBinding::for_action_in(
-                                                    &menu::Confirm,
-                                                    &focus_handle,
-                                                    window,
-                                                    cx,
-                                                )
-                                                .map(|kb| kb.size(rems_from_px(12.))),
-                                            )
-                                            .on_click(cx.listener(|this, _event, _window, cx| {
-                                                this.confirm(cx)
-                                            })),
-                                    )
-                                    .into_any_element(),
-                            }),
-                    ),
-            ).into_any_element()
-    }
-}
-
-pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
-    let theme_settings = ThemeSettings::get_global(cx);
-    let colors = cx.theme().colors();
-    let mut text_style = window.text_style();
-    text_style.refine(&TextStyleRefinement {
-        font_family: Some(theme_settings.ui_font.family.clone()),
-        font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
-        font_features: Some(theme_settings.ui_font.features.clone()),
-        font_size: Some(TextSize::XSmall.rems(cx).into()),
-        color: Some(colors.text_muted),
-        ..Default::default()
-    });
-
-    MarkdownStyle {
-        base_text_style: text_style.clone(),
-        selection_background_color: cx.theme().players().local().selection,
-        link: TextStyleRefinement {
-            background_color: Some(colors.editor_foreground.opacity(0.025)),
-            underline: Some(UnderlineStyle {
-                color: Some(colors.text_accent.opacity(0.5)),
-                thickness: px(1.),
-                ..Default::default()
-            }),
-            ..Default::default()
-        },
-        ..Default::default()
-    }
-}
-
-impl ModalView for ConfigureContextServerModal {}
-impl EventEmitter<DismissEvent> for ConfigureContextServerModal {}
-impl Focusable for ConfigureContextServerModal {
-    fn focus_handle(&self, cx: &App) -> FocusHandle {
-        if let Some(current) = self.context_servers_to_setup.first() {
-            match &current.configuration {
-                Configuration::NotAvailable => self.focus_handle.clone(),
-                Configuration::Required(configuration) => {
-                    configuration.settings_editor.read(cx).focus_handle(cx)
-                }
-            }
-        } else {
-            self.focus_handle.clone()
-        }
-    }
-}

crates/agent/src/agent_profile.rs 🔗

@@ -0,0 +1,341 @@
+use std::sync::Arc;
+
+use agent_settings::{AgentProfileId, AgentProfileSettings, AgentSettings};
+use assistant_tool::{Tool, ToolSource, ToolWorkingSet};
+use collections::IndexMap;
+use convert_case::{Case, Casing};
+use fs::Fs;
+use gpui::{App, Entity, SharedString};
+use settings::{Settings, update_settings_file};
+use util::ResultExt;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AgentProfile {
+    id: AgentProfileId,
+    tool_set: Entity<ToolWorkingSet>,
+}
+
+pub type AvailableProfiles = IndexMap<AgentProfileId, SharedString>;
+
+impl AgentProfile {
+    pub fn new(id: AgentProfileId, tool_set: Entity<ToolWorkingSet>) -> Self {
+        Self { id, tool_set }
+    }
+
+    /// Saves a new profile to the settings.
+    pub fn create(
+        name: String,
+        base_profile_id: Option<AgentProfileId>,
+        fs: Arc<dyn Fs>,
+        cx: &App,
+    ) -> AgentProfileId {
+        let id = AgentProfileId(name.to_case(Case::Kebab).into());
+
+        let base_profile =
+            base_profile_id.and_then(|id| AgentSettings::get_global(cx).profiles.get(&id).cloned());
+
+        let profile_settings = AgentProfileSettings {
+            name: name.into(),
+            tools: base_profile
+                .as_ref()
+                .map(|profile| profile.tools.clone())
+                .unwrap_or_default(),
+            enable_all_context_servers: base_profile
+                .as_ref()
+                .map(|profile| profile.enable_all_context_servers)
+                .unwrap_or_default(),
+            context_servers: base_profile
+                .map(|profile| profile.context_servers)
+                .unwrap_or_default(),
+        };
+
+        update_settings_file::<AgentSettings>(fs, cx, {
+            let id = id.clone();
+            move |settings, _cx| {
+                settings.create_profile(id, profile_settings).log_err();
+            }
+        });
+
+        id
+    }
+
+    /// Returns a map of AgentProfileIds to their names
+    pub fn available_profiles(cx: &App) -> AvailableProfiles {
+        let mut profiles = AvailableProfiles::default();
+        for (id, profile) in AgentSettings::get_global(cx).profiles.iter() {
+            profiles.insert(id.clone(), profile.name.clone());
+        }
+        profiles
+    }
+
+    pub fn id(&self) -> &AgentProfileId {
+        &self.id
+    }
+
+    pub fn enabled_tools(&self, cx: &App) -> Vec<Arc<dyn Tool>> {
+        let Some(settings) = AgentSettings::get_global(cx).profiles.get(&self.id) else {
+            return Vec::new();
+        };
+
+        self.tool_set
+            .read(cx)
+            .tools(cx)
+            .into_iter()
+            .filter(|tool| Self::is_enabled(settings, tool.source(), tool.name()))
+            .collect()
+    }
+
+    pub fn is_tool_enabled(&self, source: ToolSource, tool_name: String, cx: &App) -> bool {
+        let Some(settings) = AgentSettings::get_global(cx).profiles.get(&self.id) else {
+            return false;
+        };
+
+        return Self::is_enabled(settings, source, tool_name);
+    }
+
+    fn is_enabled(settings: &AgentProfileSettings, source: ToolSource, name: String) -> bool {
+        match source {
+            ToolSource::Native => *settings.tools.get(name.as_str()).unwrap_or(&false),
+            ToolSource::ContextServer { id } => {
+                if settings.enable_all_context_servers {
+                    return true;
+                }
+
+                let Some(preset) = settings.context_servers.get(id.as_ref()) else {
+                    return false;
+                };
+                *preset.tools.get(name.as_str()).unwrap_or(&false)
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use agent_settings::ContextServerPreset;
+    use assistant_tool::ToolRegistry;
+    use collections::IndexMap;
+    use gpui::SharedString;
+    use gpui::{AppContext, TestAppContext};
+    use http_client::FakeHttpClient;
+    use project::Project;
+    use settings::{Settings, SettingsStore};
+
+    use super::*;
+
+    #[gpui::test]
+    async fn test_enabled_built_in_tools_for_profile(cx: &mut TestAppContext) {
+        init_test_settings(cx);
+
+        let id = AgentProfileId::default();
+        let profile_settings = cx.read(|cx| {
+            AgentSettings::get_global(cx)
+                .profiles
+                .get(&id)
+                .unwrap()
+                .clone()
+        });
+        let tool_set = default_tool_set(cx);
+
+        let profile = AgentProfile::new(id.clone(), tool_set);
+
+        let mut enabled_tools = cx
+            .read(|cx| profile.enabled_tools(cx))
+            .into_iter()
+            .map(|tool| tool.name())
+            .collect::<Vec<_>>();
+        enabled_tools.sort();
+
+        let mut expected_tools = profile_settings
+            .tools
+            .into_iter()
+            .filter_map(|(tool, enabled)| enabled.then_some(tool.to_string()))
+            // Provider dependent
+            .filter(|tool| tool != "web_search")
+            .collect::<Vec<_>>();
+        // Plus all registered MCP tools
+        expected_tools.extend(["enabled_mcp_tool".into(), "disabled_mcp_tool".into()]);
+        expected_tools.sort();
+
+        assert_eq!(enabled_tools, expected_tools);
+    }
+
+    #[gpui::test]
+    async fn test_custom_mcp_settings(cx: &mut TestAppContext) {
+        init_test_settings(cx);
+
+        let id = AgentProfileId("custom_mcp".into());
+        let profile_settings = cx.read(|cx| {
+            AgentSettings::get_global(cx)
+                .profiles
+                .get(&id)
+                .unwrap()
+                .clone()
+        });
+        let tool_set = default_tool_set(cx);
+
+        let profile = AgentProfile::new(id.clone(), tool_set);
+
+        let mut enabled_tools = cx
+            .read(|cx| profile.enabled_tools(cx))
+            .into_iter()
+            .map(|tool| tool.name())
+            .collect::<Vec<_>>();
+        enabled_tools.sort();
+
+        let mut expected_tools = profile_settings.context_servers["mcp"]
+            .tools
+            .iter()
+            .filter_map(|(key, enabled)| enabled.then(|| key.to_string()))
+            .collect::<Vec<_>>();
+        expected_tools.sort();
+
+        assert_eq!(enabled_tools, expected_tools);
+    }
+
+    #[gpui::test]
+    async fn test_only_built_in(cx: &mut TestAppContext) {
+        init_test_settings(cx);
+
+        let id = AgentProfileId("write_minus_mcp".into());
+        let profile_settings = cx.read(|cx| {
+            AgentSettings::get_global(cx)
+                .profiles
+                .get(&id)
+                .unwrap()
+                .clone()
+        });
+        let tool_set = default_tool_set(cx);
+
+        let profile = AgentProfile::new(id.clone(), tool_set);
+
+        let mut enabled_tools = cx
+            .read(|cx| profile.enabled_tools(cx))
+            .into_iter()
+            .map(|tool| tool.name())
+            .collect::<Vec<_>>();
+        enabled_tools.sort();
+
+        let mut expected_tools = profile_settings
+            .tools
+            .into_iter()
+            .filter_map(|(tool, enabled)| enabled.then_some(tool.to_string()))
+            // Provider dependent
+            .filter(|tool| tool != "web_search")
+            .collect::<Vec<_>>();
+        expected_tools.sort();
+
+        assert_eq!(enabled_tools, expected_tools);
+    }
+
+    fn init_test_settings(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            Project::init_settings(cx);
+            AgentSettings::register(cx);
+            language_model::init_settings(cx);
+            ToolRegistry::default_global(cx);
+            assistant_tools::init(FakeHttpClient::with_404_response(), cx);
+        });
+
+        cx.update(|cx| {
+            let mut agent_settings = AgentSettings::get_global(cx).clone();
+            agent_settings.profiles.insert(
+                AgentProfileId("write_minus_mcp".into()),
+                AgentProfileSettings {
+                    name: "write_minus_mcp".into(),
+                    enable_all_context_servers: false,
+                    ..agent_settings.profiles[&AgentProfileId::default()].clone()
+                },
+            );
+            agent_settings.profiles.insert(
+                AgentProfileId("custom_mcp".into()),
+                AgentProfileSettings {
+                    name: "mcp".into(),
+                    tools: IndexMap::default(),
+                    enable_all_context_servers: false,
+                    context_servers: IndexMap::from_iter([("mcp".into(), context_server_preset())]),
+                },
+            );
+            AgentSettings::override_global(agent_settings, cx);
+        })
+    }
+
+    fn context_server_preset() -> ContextServerPreset {
+        ContextServerPreset {
+            tools: IndexMap::from_iter([
+                ("enabled_mcp_tool".into(), true),
+                ("disabled_mcp_tool".into(), false),
+            ]),
+        }
+    }
+
+    fn default_tool_set(cx: &mut TestAppContext) -> Entity<ToolWorkingSet> {
+        cx.new(|_| {
+            let mut tool_set = ToolWorkingSet::default();
+            tool_set.insert(Arc::new(FakeTool::new("enabled_mcp_tool", "mcp")));
+            tool_set.insert(Arc::new(FakeTool::new("disabled_mcp_tool", "mcp")));
+            tool_set
+        })
+    }
+
+    struct FakeTool {
+        name: String,
+        source: SharedString,
+    }
+
+    impl FakeTool {
+        fn new(name: impl Into<String>, source: impl Into<SharedString>) -> Self {
+            Self {
+                name: name.into(),
+                source: source.into(),
+            }
+        }
+    }
+
+    impl Tool for FakeTool {
+        fn name(&self) -> String {
+            self.name.clone()
+        }
+
+        fn source(&self) -> ToolSource {
+            ToolSource::ContextServer {
+                id: self.source.clone(),
+            }
+        }
+
+        fn description(&self) -> String {
+            unimplemented!()
+        }
+
+        fn icon(&self) -> icons::IconName {
+            unimplemented!()
+        }
+
+        fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool {
+            unimplemented!()
+        }
+
+        fn ui_text(&self, _input: &serde_json::Value) -> String {
+            unimplemented!()
+        }
+
+        fn run(
+            self: Arc<Self>,
+            _input: serde_json::Value,
+            _request: Arc<language_model::LanguageModelRequest>,
+            _project: Entity<Project>,
+            _action_log: Entity<assistant_tool::ActionLog>,
+            _model: Arc<dyn language_model::LanguageModel>,
+            _window: Option<gpui::AnyWindowHandle>,
+            _cx: &mut App,
+        ) -> assistant_tool::ToolResult {
+            unimplemented!()
+        }
+
+        fn may_perform_edits(&self) -> bool {
+            unimplemented!()
+        }
+    }
+}

crates/agent/src/context.rs 🔗

@@ -1,30 +1,25 @@
-use std::fmt::{self, Display, Formatter, Write as _};
-use std::hash::{Hash, Hasher};
-use std::path::PathBuf;
-use std::{ops::Range, path::Path, sync::Arc};
-
-use assistant_context_editor::AssistantContext;
+use crate::thread::Thread;
+use assistant_context::AssistantContext;
 use assistant_tool::outline;
-use collections::{HashMap, HashSet};
-use editor::display_map::CreaseId;
-use editor::{Addon, Editor};
+use collections::HashSet;
 use futures::future;
 use futures::{FutureExt, future::Shared};
-use gpui::{App, AppContext as _, Entity, SharedString, Subscription, Task};
+use gpui::{App, AppContext as _, ElementId, Entity, SharedString, Task};
+use icons::IconName;
 use language::{Buffer, ParseStatus};
 use language_model::{LanguageModelImage, LanguageModelRequestMessage, MessageContent};
 use project::{Project, ProjectEntryId, ProjectPath, Worktree};
 use prompt_store::{PromptStore, UserPromptId};
 use ref_cast::RefCast;
 use rope::Point;
+use std::fmt::{self, Display, Formatter, Write as _};
+use std::hash::{Hash, Hasher};
+use std::path::PathBuf;
+use std::{ops::Range, path::Path, sync::Arc};
 use text::{Anchor, OffsetRangeExt as _};
-use ui::{Context, ElementId, IconName};
 use util::markdown::MarkdownCodeBlock;
 use util::{ResultExt as _, post_inc};
 
-use crate::context_store::{ContextStore, ContextStoreEvent};
-use crate::thread::Thread;
-
 pub const RULES_ICON: IconName = IconName::Context;
 
 pub enum ContextKind {
@@ -734,6 +729,7 @@ impl Display for RulesContext {
 #[derive(Debug, Clone)]
 pub struct ImageContext {
     pub project_path: Option<ProjectPath>,
+    pub full_path: Option<Arc<Path>>,
     pub original_image: Arc<gpui::Image>,
     // TODO: handle this elsewhere and remove `ignore-interior-mutability` opt-out in clippy.toml
     // needed due to a false positive of `clippy::mutable_key_type`.
@@ -744,6 +740,7 @@ pub struct ImageContext {
 pub enum ImageStatus {
     Loading,
     Error,
+    Warning,
     Ready,
 }
 
@@ -760,11 +757,17 @@ impl ImageContext {
         self.image_task.clone().now_or_never().flatten()
     }
 
-    pub fn status(&self) -> ImageStatus {
+    pub fn status(&self, model: Option<&Arc<dyn language_model::LanguageModel>>) -> ImageStatus {
         match self.image_task.clone().now_or_never() {
             None => ImageStatus::Loading,
             Some(None) => ImageStatus::Error,
-            Some(Some(_)) => ImageStatus::Ready,
+            Some(Some(_)) => {
+                if model.is_some_and(|model| !model.supports_images()) {
+                    ImageStatus::Warning
+                } else {
+                    ImageStatus::Ready
+                }
+            }
         }
     }
 
@@ -1109,69 +1112,6 @@ impl Hash for AgentContextKey {
     }
 }
 
-#[derive(Default)]
-pub struct ContextCreasesAddon {
-    creases: HashMap<AgentContextKey, Vec<(CreaseId, SharedString)>>,
-    _subscription: Option<Subscription>,
-}
-
-impl Addon for ContextCreasesAddon {
-    fn to_any(&self) -> &dyn std::any::Any {
-        self
-    }
-
-    fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
-        Some(self)
-    }
-}
-
-impl ContextCreasesAddon {
-    pub fn new() -> Self {
-        Self {
-            creases: HashMap::default(),
-            _subscription: None,
-        }
-    }
-
-    pub fn add_creases(
-        &mut self,
-        context_store: &Entity<ContextStore>,
-        key: AgentContextKey,
-        creases: impl IntoIterator<Item = (CreaseId, SharedString)>,
-        cx: &mut Context<Editor>,
-    ) {
-        self.creases.entry(key).or_default().extend(creases);
-        self._subscription = Some(cx.subscribe(
-            &context_store,
-            |editor, _, event, cx| match event {
-                ContextStoreEvent::ContextRemoved(key) => {
-                    let Some(this) = editor.addon_mut::<Self>() else {
-                        return;
-                    };
-                    let (crease_ids, replacement_texts): (Vec<_>, Vec<_>) = this
-                        .creases
-                        .remove(key)
-                        .unwrap_or_default()
-                        .into_iter()
-                        .unzip();
-                    let ranges = editor
-                        .remove_creases(crease_ids, cx)
-                        .into_iter()
-                        .map(|(_, range)| range)
-                        .collect::<Vec<_>>();
-                    editor.unfold_ranges(&ranges, false, false, cx);
-                    editor.edit(ranges.into_iter().zip(replacement_texts), cx);
-                    cx.notify();
-                }
-            },
-        ))
-    }
-
-    pub fn into_inner(self) -> HashMap<AgentContextKey, Vec<(CreaseId, SharedString)>> {
-        self.creases
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;

crates/agent/src/context_server_configuration.rs 🔗

@@ -1,140 +0,0 @@
-use std::sync::Arc;
-
-use anyhow::Context as _;
-use context_server::ContextServerId;
-use extension::{ContextServerConfiguration, ExtensionManifest};
-use gpui::Task;
-use language::LanguageRegistry;
-use project::context_server_store::registry::ContextServerDescriptorRegistry;
-use ui::prelude::*;
-use util::ResultExt;
-use workspace::Workspace;
-
-use crate::agent_configuration::ConfigureContextServerModal;
-
-pub(crate) fn init(language_registry: Arc<LanguageRegistry>, cx: &mut App) {
-    cx.observe_new(move |_: &mut Workspace, window, cx| {
-        let Some(window) = window else {
-            return;
-        };
-
-        if let Some(extension_events) = extension::ExtensionEvents::try_global(cx).as_ref() {
-            cx.subscribe_in(extension_events, window, {
-                let language_registry = language_registry.clone();
-                move |workspace, _, event, window, cx| match event {
-                    extension::Event::ExtensionInstalled(manifest) => {
-                        show_configure_mcp_modal(
-                            language_registry.clone(),
-                            manifest,
-                            workspace,
-                            window,
-                            cx,
-                        );
-                    }
-                    extension::Event::ConfigureExtensionRequested(manifest) => {
-                        if !manifest.context_servers.is_empty() {
-                            show_configure_mcp_modal(
-                                language_registry.clone(),
-                                manifest,
-                                workspace,
-                                window,
-                                cx,
-                            );
-                        }
-                    }
-                    _ => {}
-                }
-            })
-            .detach();
-        } else {
-            log::info!(
-                "No extension events global found. Skipping context server configuration wizard"
-            );
-        }
-    })
-    .detach();
-}
-
-pub enum Configuration {
-    NotAvailable(ContextServerId, Option<SharedString>),
-    Required(
-        ContextServerId,
-        Option<SharedString>,
-        ContextServerConfiguration,
-    ),
-}
-
-fn show_configure_mcp_modal(
-    language_registry: Arc<LanguageRegistry>,
-    manifest: &Arc<ExtensionManifest>,
-    workspace: &mut Workspace,
-    window: &mut Window,
-    cx: &mut Context<'_, Workspace>,
-) {
-    let context_server_store = workspace.project().read(cx).context_server_store();
-    let repository: Option<SharedString> = manifest.repository.as_ref().map(|s| s.clone().into());
-
-    let registry = ContextServerDescriptorRegistry::default_global(cx).read(cx);
-    let worktree_store = workspace.project().read(cx).worktree_store();
-    let configuration_tasks = manifest
-        .context_servers
-        .keys()
-        .cloned()
-        .map({
-            |key| {
-                let Some(descriptor) = registry.context_server_descriptor(&key) else {
-                    return Task::ready(Configuration::NotAvailable(
-                        ContextServerId(key),
-                        repository.clone(),
-                    ));
-                };
-                cx.spawn({
-                    let repository_url = repository.clone();
-                    let worktree_store = worktree_store.clone();
-                    async move |_, cx| {
-                        let configuration = descriptor
-                            .configuration(worktree_store.clone(), &cx)
-                            .await
-                            .context("Failed to resolve context server configuration")
-                            .log_err()
-                            .flatten();
-
-                        match configuration {
-                            Some(config) => Configuration::Required(
-                                ContextServerId(key),
-                                repository_url,
-                                config,
-                            ),
-                            None => {
-                                Configuration::NotAvailable(ContextServerId(key), repository_url)
-                            }
-                        }
-                    }
-                })
-            }
-        })
-        .collect::<Vec<_>>();
-
-    let jsonc_language = language_registry.language_for_name("jsonc");
-
-    cx.spawn_in(window, async move |this, cx| {
-        let configurations = futures::future::join_all(configuration_tasks).await;
-        let jsonc_language = jsonc_language.await.ok();
-
-        this.update_in(cx, |this, window, cx| {
-            let workspace = cx.entity().downgrade();
-            this.toggle_modal(window, cx, |window, cx| {
-                ConfigureContextServerModal::new(
-                    configurations.into_iter(),
-                    context_server_store,
-                    jsonc_language,
-                    language_registry,
-                    workspace,
-                    window,
-                    cx,
-                )
-            });
-        })
-    })
-    .detach();
-}

crates/agent/src/context_server_tool.rs 🔗

@@ -4,9 +4,9 @@ use anyhow::{Result, anyhow, bail};
 use assistant_tool::{ActionLog, Tool, ToolResult, ToolSource};
 use context_server::{ContextServerId, types};
 use gpui::{AnyWindowHandle, App, Entity, Task};
+use icons::IconName;
 use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
 use project::{Project, context_server_store::ContextServerStore};
-use ui::IconName;
 
 pub struct ContextServerTool {
     store: Entity<ContextServerStore>,
@@ -51,6 +51,10 @@ impl Tool for ContextServerTool {
         true
     }
 
+    fn may_perform_edits(&self) -> bool {
+        true
+    }
+
     fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
         let mut schema = self.tool.input_schema.clone();
         assistant_tool::adapt_schema_to_format(&mut schema, format)?;
@@ -100,7 +104,15 @@ impl Tool for ContextServerTool {
                     tool_name,
                     arguments
                 );
-                let response = protocol.run_tool(tool_name, arguments).await?;
+                let response = protocol
+                    .request::<context_server::types::requests::CallTool>(
+                        context_server::types::CallToolParams {
+                            name: tool_name,
+                            arguments,
+                            meta: None,
+                        },
+                    )
+                    .await?;
 
                 let mut result = String::new();
                 for content in response.content {
@@ -111,6 +123,9 @@ impl Tool for ContextServerTool {
                         types::ToolResponseContent::Image { .. } => {
                             log::warn!("Ignoring image content from tool response");
                         }
+                        types::ToolResponseContent::Audio { .. } => {
+                            log::warn!("Ignoring audio content from tool response");
+                        }
                         types::ToolResponseContent::Resource { .. } => {
                             log::warn!("Ignoring resource content from tool response");
                         }

crates/agent/src/context_store.rs 🔗

@@ -1,28 +1,28 @@
-use std::ops::Range;
-use std::path::{Path, PathBuf};
-use std::sync::Arc;
-
-use anyhow::{Result, anyhow};
-use assistant_context_editor::AssistantContext;
+use crate::{
+    context::{
+        AgentContextHandle, AgentContextKey, ContextId, ContextKind, DirectoryContextHandle,
+        FetchedUrlContext, FileContextHandle, ImageContext, RulesContextHandle,
+        SelectionContextHandle, SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle,
+    },
+    thread::{MessageId, Thread, ThreadId},
+    thread_store::ThreadStore,
+};
+use anyhow::{Context as _, Result, anyhow};
+use assistant_context::AssistantContext;
 use collections::{HashSet, IndexSet};
 use futures::{self, FutureExt};
 use gpui::{App, Context, Entity, EventEmitter, Image, SharedString, Task, WeakEntity};
-use language::Buffer;
+use language::{Buffer, File as _};
 use language_model::LanguageModelImage;
-use project::image_store::is_image_file;
-use project::{Project, ProjectItem, ProjectPath, Symbol};
+use project::{Project, ProjectItem, ProjectPath, Symbol, image_store::is_image_file};
 use prompt_store::UserPromptId;
 use ref_cast::RefCast as _;
-use text::{Anchor, OffsetRangeExt};
-
-use crate::ThreadStore;
-use crate::context::{
-    AgentContextHandle, AgentContextKey, ContextId, DirectoryContextHandle, FetchedUrlContext,
-    FileContextHandle, ImageContext, RulesContextHandle, SelectionContextHandle,
-    SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle,
+use std::{
+    ops::Range,
+    path::{Path, PathBuf},
+    sync::Arc,
 };
-use crate::context_strip::SuggestedContext;
-use crate::thread::{MessageId, Thread, ThreadId};
+use text::{Anchor, OffsetRangeExt};
 
 pub struct ContextStore {
     project: WeakEntity<Project>,
@@ -58,9 +58,10 @@ impl ContextStore {
         self.context_set.iter().map(|entry| entry.as_ref())
     }
 
-    pub fn clear(&mut self) {
+    pub fn clear(&mut self, cx: &mut Context<Self>) {
         self.context_set.clear();
         self.context_thread_ids.clear();
+        cx.notify();
     }
 
     pub fn new_context_for_thread(
@@ -142,17 +143,12 @@ impl ContextStore {
         remove_if_exists: bool,
         cx: &mut Context<Self>,
     ) -> Result<Option<AgentContextHandle>> {
-        let Some(project) = self.project.upgrade() else {
-            return Err(anyhow!("failed to read project"));
-        };
-
-        let Some(entry_id) = project
+        let project = self.project.upgrade().context("failed to read project")?;
+        let entry_id = project
             .read(cx)
             .entry_for_path(project_path, cx)
             .map(|entry| entry.id)
-        else {
-            return Err(anyhow!("no entry found for directory context"));
-        };
+            .context("no entry found for directory context")?;
 
         let context_id = self.next_context_id.post_inc();
         let context = AgentContextHandle::Directory(DirectoryContextHandle {
@@ -308,11 +304,13 @@ impl ContextStore {
                 project.open_image(project_path.clone(), cx)
             })?;
             let image_item = open_image_task.await?;
-            let image = image_item.read_with(cx, |image_item, _| image_item.image.clone())?;
+
             this.update(cx, |this, cx| {
+                let item = image_item.read(cx);
                 this.insert_image(
-                    Some(image_item.read(cx).project_path(cx)),
-                    image,
+                    Some(item.project_path(cx)),
+                    Some(item.file.full_path(cx).into()),
+                    item.image.clone(),
                     remove_if_exists,
                     cx,
                 )
@@ -321,12 +319,13 @@ impl ContextStore {
     }
 
     pub fn add_image_instance(&mut self, image: Arc<Image>, cx: &mut Context<ContextStore>) {
-        self.insert_image(None, image, false, cx);
+        self.insert_image(None, None, image, false, cx);
     }
 
     fn insert_image(
         &mut self,
         project_path: Option<ProjectPath>,
+        full_path: Option<Arc<Path>>,
         image: Arc<Image>,
         remove_if_exists: bool,
         cx: &mut Context<ContextStore>,
@@ -334,6 +333,7 @@ impl ContextStore {
         let image_task = LanguageModelImage::from_image(image.clone(), cx).shared();
         let context = AgentContextHandle::Image(ImageContext {
             project_path,
+            full_path,
             original_image: image,
             image_task,
             context_id: self.next_context_id.post_inc(),
@@ -561,6 +561,49 @@ impl ContextStore {
     }
 }
 
+#[derive(Clone)]
+pub enum SuggestedContext {
+    File {
+        name: SharedString,
+        icon_path: Option<SharedString>,
+        buffer: WeakEntity<Buffer>,
+    },
+    Thread {
+        name: SharedString,
+        thread: WeakEntity<Thread>,
+    },
+    TextThread {
+        name: SharedString,
+        context: WeakEntity<AssistantContext>,
+    },
+}
+
+impl SuggestedContext {
+    pub fn name(&self) -> &SharedString {
+        match self {
+            Self::File { name, .. } => name,
+            Self::Thread { name, .. } => name,
+            Self::TextThread { name, .. } => name,
+        }
+    }
+
+    pub fn icon_path(&self) -> Option<SharedString> {
+        match self {
+            Self::File { icon_path, .. } => icon_path.clone(),
+            Self::Thread { .. } => None,
+            Self::TextThread { .. } => None,
+        }
+    }
+
+    pub fn kind(&self) -> ContextKind {
+        match self {
+            Self::File { .. } => ContextKind::File,
+            Self::Thread { .. } => ContextKind::Thread,
+            Self::TextThread { .. } => ContextKind::TextThread,
+        }
+    }
+}
+
 pub enum FileInclusion {
     Direct,
     InDirectory { full_path: PathBuf },

crates/agent/src/history_store.rs 🔗

@@ -1,21 +1,16 @@
-use std::{collections::VecDeque, path::Path, sync::Arc};
-
-use anyhow::{Context as _, anyhow};
-use assistant_context_editor::{AssistantContext, SavedContextMetadata};
-use chrono::{DateTime, Utc};
-use futures::future::{TryFutureExt as _, join_all};
-use gpui::{Entity, Task, prelude::*};
-use serde::{Deserialize, Serialize};
-use smol::future::FutureExt;
-use std::time::Duration;
-use ui::{App, SharedString, Window};
-use util::ResultExt as _;
-
 use crate::{
-    Thread,
-    thread::ThreadId,
+    ThreadId,
     thread_store::{SerializedThreadMetadata, ThreadStore},
 };
+use anyhow::{Context as _, Result};
+use assistant_context::SavedContextMetadata;
+use chrono::{DateTime, Utc};
+use gpui::{App, AsyncApp, Entity, SharedString, Task, prelude::*};
+use itertools::Itertools;
+use paths::contexts_dir;
+use serde::{Deserialize, Serialize};
+use std::{collections::VecDeque, path::Path, sync::Arc, time::Duration};
+use util::ResultExt as _;
 
 const MAX_RECENTLY_OPENED_ENTRIES: usize = 6;
 const NAVIGATION_HISTORY_PATH: &str = "agent-navigation-history.json";
@@ -41,52 +36,34 @@ impl HistoryEntry {
             HistoryEntry::Context(context) => HistoryEntryId::Context(context.path.clone()),
         }
     }
+
+    pub fn title(&self) -> &SharedString {
+        match self {
+            HistoryEntry::Thread(thread) => &thread.summary,
+            HistoryEntry::Context(context) => &context.title,
+        }
+    }
 }
 
 /// Generic identifier for a history entry.
-#[derive(Clone, PartialEq, Eq)]
+#[derive(Clone, PartialEq, Eq, Debug)]
 pub enum HistoryEntryId {
     Thread(ThreadId),
     Context(Arc<Path>),
 }
 
-#[derive(Clone, Debug)]
-pub(crate) enum RecentEntry {
-    Thread(ThreadId, Entity<Thread>),
-    Context(Entity<AssistantContext>),
-}
-
-impl PartialEq for RecentEntry {
-    fn eq(&self, other: &Self) -> bool {
-        match (self, other) {
-            (Self::Thread(l0, _), Self::Thread(r0, _)) => l0 == r0,
-            (Self::Context(l0), Self::Context(r0)) => l0 == r0,
-            _ => false,
-        }
-    }
-}
-
-impl Eq for RecentEntry {}
-
-impl RecentEntry {
-    pub(crate) fn summary(&self, cx: &App) -> SharedString {
-        match self {
-            RecentEntry::Thread(_, thread) => thread.read(cx).summary().or_default(),
-            RecentEntry::Context(context) => context.read(cx).summary().or_default(),
-        }
-    }
-}
-
 #[derive(Serialize, Deserialize)]
-enum SerializedRecentEntry {
+enum SerializedRecentOpen {
     Thread(String),
+    ContextName(String),
+    /// Old format which stores the full path
     Context(String),
 }
 
 pub struct HistoryStore {
     thread_store: Entity<ThreadStore>,
-    context_store: Entity<assistant_context_editor::ContextStore>,
-    recently_opened_entries: VecDeque<RecentEntry>,
+    context_store: Entity<assistant_context::ContextStore>,
+    recently_opened_entries: VecDeque<HistoryEntryId>,
     _subscriptions: Vec<gpui::Subscription>,
     _save_recently_opened_entries_task: Task<()>,
 }
@@ -94,9 +71,8 @@ pub struct HistoryStore {
 impl HistoryStore {
     pub fn new(
         thread_store: Entity<ThreadStore>,
-        context_store: Entity<assistant_context_editor::ContextStore>,
-        initial_recent_entries: impl IntoIterator<Item = RecentEntry>,
-        window: &mut Window,
+        context_store: Entity<assistant_context::ContextStore>,
+        initial_recent_entries: impl IntoIterator<Item = HistoryEntryId>,
         cx: &mut Context<Self>,
     ) -> Self {
         let subscriptions = vec![
@@ -104,62 +80,20 @@ impl HistoryStore {
             cx.observe(&context_store, |_, _, cx| cx.notify()),
         ];
 
-        window
-            .spawn(cx, {
-                let thread_store = thread_store.downgrade();
-                let context_store = context_store.downgrade();
-                let this = cx.weak_entity();
-                async move |cx| {
-                    let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH);
-                    let contents = cx
-                        .background_spawn(async move { std::fs::read_to_string(path) })
-                        .await
-                        .ok()?;
-                    let entries = serde_json::from_str::<Vec<SerializedRecentEntry>>(&contents)
-                        .context("deserializing persisted agent panel navigation history")
-                        .log_err()?
-                        .into_iter()
-                        .take(MAX_RECENTLY_OPENED_ENTRIES)
-                        .map(|serialized| match serialized {
-                            SerializedRecentEntry::Thread(id) => thread_store
-                                .update_in(cx, |thread_store, window, cx| {
-                                    let thread_id = ThreadId::from(id.as_str());
-                                    thread_store
-                                        .open_thread(&thread_id, window, cx)
-                                        .map_ok(|thread| RecentEntry::Thread(thread_id, thread))
-                                        .boxed()
-                                })
-                                .unwrap_or_else(|_| {
-                                    async { Err(anyhow!("no thread store")) }.boxed()
-                                }),
-                            SerializedRecentEntry::Context(id) => context_store
-                                .update(cx, |context_store, cx| {
-                                    context_store
-                                        .open_local_context(Path::new(&id).into(), cx)
-                                        .map_ok(RecentEntry::Context)
-                                        .boxed()
-                                })
-                                .unwrap_or_else(|_| {
-                                    async { Err(anyhow!("no context store")) }.boxed()
-                                }),
-                        });
-                    let entries = join_all(entries)
-                        .await
-                        .into_iter()
-                        .filter_map(|result| result.log_err())
-                        .collect::<VecDeque<_>>();
-
-                    this.update(cx, |this, _| {
-                        this.recently_opened_entries.extend(entries);
-                        this.recently_opened_entries
-                            .truncate(MAX_RECENTLY_OPENED_ENTRIES);
-                    })
-                    .ok();
-
-                    Some(())
-                }
+        cx.spawn(async move |this, cx| {
+            let entries = Self::load_recently_opened_entries(cx).await.log_err()?;
+            this.update(cx, |this, _| {
+                this.recently_opened_entries
+                    .extend(
+                        entries.into_iter().take(
+                            MAX_RECENTLY_OPENED_ENTRIES
+                                .saturating_sub(this.recently_opened_entries.len()),
+                        ),
+                    );
             })
-            .detach();
+            .ok()
+        })
+        .detach();
 
         Self {
             thread_store,
@@ -178,19 +112,20 @@ impl HistoryStore {
             return history_entries;
         }
 
-        for thread in self
-            .thread_store
-            .update(cx, |this, _cx| this.reverse_chronological_threads())
-        {
-            history_entries.push(HistoryEntry::Thread(thread));
-        }
-
-        for context in self
-            .context_store
-            .update(cx, |this, _cx| this.reverse_chronological_contexts())
-        {
-            history_entries.push(HistoryEntry::Context(context));
-        }
+        history_entries.extend(
+            self.thread_store
+                .read(cx)
+                .reverse_chronological_threads()
+                .cloned()
+                .map(HistoryEntry::Thread),
+        );
+        history_entries.extend(
+            self.context_store
+                .read(cx)
+                .unordered_contexts()
+                .cloned()
+                .map(HistoryEntry::Context),
+        );
 
         history_entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.updated_at()));
         history_entries
@@ -200,15 +135,62 @@ impl HistoryStore {
         self.entries(cx).into_iter().take(limit).collect()
     }
 
+    pub fn recently_opened_entries(&self, cx: &App) -> Vec<HistoryEntry> {
+        #[cfg(debug_assertions)]
+        if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() {
+            return Vec::new();
+        }
+
+        let thread_entries = self
+            .thread_store
+            .read(cx)
+            .reverse_chronological_threads()
+            .flat_map(|thread| {
+                self.recently_opened_entries
+                    .iter()
+                    .enumerate()
+                    .flat_map(|(index, entry)| match entry {
+                        HistoryEntryId::Thread(id) if &thread.id == id => {
+                            Some((index, HistoryEntry::Thread(thread.clone())))
+                        }
+                        _ => None,
+                    })
+            });
+
+        let context_entries =
+            self.context_store
+                .read(cx)
+                .unordered_contexts()
+                .flat_map(|context| {
+                    self.recently_opened_entries
+                        .iter()
+                        .enumerate()
+                        .flat_map(|(index, entry)| match entry {
+                            HistoryEntryId::Context(path) if &context.path == path => {
+                                Some((index, HistoryEntry::Context(context.clone())))
+                            }
+                            _ => None,
+                        })
+                });
+
+        thread_entries
+            .chain(context_entries)
+            // optimization to halt iteration early
+            .take(self.recently_opened_entries.len())
+            .sorted_unstable_by_key(|(index, _)| *index)
+            .map(|(_, entry)| entry)
+            .collect()
+    }
+
     fn save_recently_opened_entries(&mut self, cx: &mut Context<Self>) {
         let serialized_entries = self
             .recently_opened_entries
             .iter()
             .filter_map(|entry| match entry {
-                RecentEntry::Context(context) => Some(SerializedRecentEntry::Context(
-                    context.read(cx).path()?.to_str()?.to_owned(),
-                )),
-                RecentEntry::Thread(id, _) => Some(SerializedRecentEntry::Thread(id.to_string())),
+                HistoryEntryId::Context(path) => path.file_name().map(|file| {
+                    SerializedRecentOpen::ContextName(file.to_string_lossy().to_string())
+                }),
+                HistoryEntryId::Thread(id) => Some(SerializedRecentOpen::Thread(id.to_string())),
             })
             .collect::<Vec<_>>();
 
@@ -227,7 +209,33 @@ impl HistoryStore {
         });
     }
 
-    pub fn push_recently_opened_entry(&mut self, entry: RecentEntry, cx: &mut Context<Self>) {
+    fn load_recently_opened_entries(cx: &AsyncApp) -> Task<Result<Vec<HistoryEntryId>>> {
+        cx.background_spawn(async move {
+            let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH);
+            let contents = smol::fs::read_to_string(path).await?;
+            let entries = serde_json::from_str::<Vec<SerializedRecentOpen>>(&contents)
+                .context("deserializing persisted agent panel navigation history")?
+                .into_iter()
+                .take(MAX_RECENTLY_OPENED_ENTRIES)
+                .flat_map(|entry| match entry {
+                    SerializedRecentOpen::Thread(id) => {
+                        Some(HistoryEntryId::Thread(id.as_str().into()))
+                    }
+                    SerializedRecentOpen::ContextName(file_name) => Some(HistoryEntryId::Context(
+                        contexts_dir().join(file_name).into(),
+                    )),
+                    SerializedRecentOpen::Context(path) => {
+                        Path::new(&path).file_name().map(|file_name| {
+                            HistoryEntryId::Context(contexts_dir().join(file_name).into())
+                        })
+                    }
+                })
+                .collect::<Vec<_>>();
+            Ok(entries)
+        })
+    }
+
+    pub fn push_recently_opened_entry(&mut self, entry: HistoryEntryId, cx: &mut Context<Self>) {
         self.recently_opened_entries
             .retain(|old_entry| old_entry != &entry);
         self.recently_opened_entries.push_front(entry);
@@ -238,24 +246,33 @@ impl HistoryStore {
 
     pub fn remove_recently_opened_thread(&mut self, id: ThreadId, cx: &mut Context<Self>) {
         self.recently_opened_entries.retain(|entry| match entry {
-            RecentEntry::Thread(thread_id, _) if thread_id == &id => false,
+            HistoryEntryId::Thread(thread_id) if thread_id == &id => false,
             _ => true,
         });
         self.save_recently_opened_entries(cx);
     }
 
-    pub fn remove_recently_opened_entry(&mut self, entry: &RecentEntry, cx: &mut Context<Self>) {
-        self.recently_opened_entries
-            .retain(|old_entry| old_entry != entry);
+    pub fn replace_recently_opened_text_thread(
+        &mut self,
+        old_path: &Path,
+        new_path: &Arc<Path>,
+        cx: &mut Context<Self>,
+    ) {
+        for entry in &mut self.recently_opened_entries {
+            match entry {
+                HistoryEntryId::Context(path) if path.as_ref() == old_path => {
+                    *entry = HistoryEntryId::Context(new_path.clone());
+                    break;
+                }
+                _ => {}
+            }
+        }
         self.save_recently_opened_entries(cx);
     }
 
-    pub fn recently_opened_entries(&self, _cx: &mut Context<Self>) -> VecDeque<RecentEntry> {
-        #[cfg(debug_assertions)]
-        if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() {
-            return VecDeque::new();
-        }
-
-        self.recently_opened_entries.clone()
+    pub fn remove_recently_opened_entry(&mut self, entry: &HistoryEntryId, cx: &mut Context<Self>) {
+        self.recently_opened_entries
+            .retain(|old_entry| old_entry != entry);
+        self.save_recently_opened_entries(cx);
     }
 }

crates/agent/src/prompts/summarize_thread_detailed_prompt.txt 🔗

@@ -0,0 +1,6 @@
+Generate a detailed summary of this conversation. Include:
+1. A brief overview of what was discussed
+2. Key facts or information discovered
+3. Outcomes or conclusions reached
+4. Any action items or next steps if any
+Format it in Markdown with headings and bullet points.

crates/agent/src/prompts/summarize_thread_prompt.txt 🔗

@@ -0,0 +1,4 @@
+Generate a concise 3-7 word title for this conversation, omitting punctuation.
+Go straight to the title, without any preamble and prefix like `Here's a concise suggestion:...` or `Title:`.
+If the conversation is about a specific subject, include it in the title.
+Be descriptive. DO NOT speak in the first person.

crates/agent/src/thread.rs 🔗

@@ -1,52 +1,57 @@
-use std::fmt::Write as _;
-use std::io::Write;
-use std::ops::Range;
-use std::sync::Arc;
-use std::time::Instant;
-
+use crate::{
+    agent_profile::AgentProfile,
+    context::{AgentContext, AgentContextHandle, ContextLoadResult, LoadedContext},
+    thread_store::{
+        SerializedCrease, SerializedLanguageModel, SerializedMessage, SerializedMessageSegment,
+        SerializedThread, SerializedToolResult, SerializedToolUse, SharedProjectContext,
+        ThreadStore,
+    },
+    tool_use::{PendingToolUse, ToolUse, ToolUseMetadata, ToolUseState},
+};
+use agent_settings::{AgentProfileId, AgentSettings, CompletionMode};
 use anyhow::{Result, anyhow};
-use assistant_settings::{AssistantSettings, CompletionMode};
 use assistant_tool::{ActionLog, AnyToolCard, Tool, ToolWorkingSet};
 use chrono::{DateTime, Utc};
-use collections::HashMap;
-use editor::display_map::CreaseMetadata;
+use client::{ModelRequestUsage, RequestUsage};
+use collections::{HashMap, HashSet};
 use feature_flags::{self, FeatureFlagAppExt};
-use futures::future::Shared;
-use futures::{FutureExt, StreamExt as _};
+use futures::{FutureExt, StreamExt as _, future::Shared};
 use git::repository::DiffType;
 use gpui::{
     AnyWindowHandle, App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task,
-    WeakEntity,
+    WeakEntity, Window,
 };
 use language_model::{
     ConfiguredModel, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
     LanguageModelId, LanguageModelKnownError, LanguageModelRegistry, LanguageModelRequest,
     LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult,
-    LanguageModelToolUseId, MaxMonthlySpendReachedError, MessageContent,
-    ModelRequestLimitReachedError, PaymentRequiredError, RequestUsage, Role, SelectedModel,
-    StopReason, TokenUsage,
+    LanguageModelToolResultContent, LanguageModelToolUseId, MessageContent,
+    ModelRequestLimitReachedError, PaymentRequiredError, Role, SelectedModel, StopReason,
+    TokenUsage,
 };
 use postage::stream::Stream as _;
-use project::Project;
-use project::git_store::{GitStore, GitStoreCheckpoint, RepositoryState};
+use project::{
+    Project,
+    git_store::{GitStore, GitStoreCheckpoint, RepositoryState},
+};
 use prompt_store::{ModelContext, PromptBuilder};
 use proto::Plan;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::Settings;
+use std::{
+    io::Write,
+    ops::Range,
+    sync::Arc,
+    time::{Duration, Instant},
+};
 use thiserror::Error;
-use ui::Window;
 use util::{ResultExt as _, post_inc};
 use uuid::Uuid;
-use zed_llm_client::CompletionRequestStatus;
+use zed_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit};
 
-use crate::ThreadStore;
-use crate::context::{AgentContext, AgentContextHandle, ContextLoadResult, LoadedContext};
-use crate::thread_store::{
-    SerializedCrease, SerializedLanguageModel, SerializedMessage, SerializedMessageSegment,
-    SerializedThread, SerializedToolResult, SerializedToolUse, SharedProjectContext,
-};
-use crate::tool_use::{PendingToolUse, ToolUse, ToolUseMetadata, ToolUseState};
+const MAX_RETRY_ATTEMPTS: u8 = 3;
+const BASE_RETRY_DELAY_SECS: u64 = 5;
 
 #[derive(
     Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize, JsonSchema,
@@ -96,13 +101,18 @@ impl MessageId {
     fn post_inc(&mut self) -> Self {
         Self(post_inc(&mut self.0))
     }
+
+    pub fn as_usize(&self) -> usize {
+        self.0
+    }
 }
 
 /// Stored information that can be used to resurrect a context crease when creating an editor for a past message.
 #[derive(Clone, Debug)]
 pub struct MessageCrease {
     pub range: Range<usize>,
-    pub metadata: CreaseMetadata,
+    pub icon_path: SharedString,
+    pub label: SharedString,
     /// None for a deserialized message, Some otherwise.
     pub context: Option<AgentContextHandle>,
 }
@@ -115,6 +125,8 @@ pub struct Message {
     pub segments: Vec<MessageSegment>,
     pub loaded_context: LoadedContext,
     pub creases: Vec<MessageCrease>,
+    pub is_hidden: bool,
+    pub ui_only: bool,
 }
 
 impl Message {
@@ -142,6 +154,10 @@ impl Message {
         }
     }
 
+    pub fn push_redacted_thinking(&mut self, data: String) {
+        self.segments.push(MessageSegment::RedactedThinking(data));
+    }
+
     pub fn push_text(&mut self, text: &str) {
         if let Some(MessageSegment::Text(segment)) = self.segments.last_mut() {
             segment.push_str(text);
@@ -180,7 +196,7 @@ pub enum MessageSegment {
         text: String,
         signature: Option<String>,
     },
-    RedactedThinking(Vec<u8>),
+    RedactedThinking(String),
 }
 
 impl MessageSegment {
@@ -191,22 +207,29 @@ impl MessageSegment {
             Self::RedactedThinking(_) => false,
         }
     }
+
+    pub fn text(&self) -> Option<&str> {
+        match self {
+            MessageSegment::Text(text) => Some(text),
+            _ => None,
+        }
+    }
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
 pub struct ProjectSnapshot {
     pub worktree_snapshots: Vec<WorktreeSnapshot>,
     pub unsaved_buffer_paths: Vec<String>,
     pub timestamp: DateTime<Utc>,
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
 pub struct WorktreeSnapshot {
     pub worktree_path: String,
     pub git_state: Option<GitState>,
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
 pub struct GitState {
     pub remote_url: Option<String>,
     pub head_sha: Option<String>,
@@ -214,7 +237,7 @@ pub struct GitState {
     pub diff: Option<String>,
 }
 
-#[derive(Clone)]
+#[derive(Clone, Debug)]
 pub struct ThreadCheckpoint {
     message_id: MessageId,
     git_checkpoint: GitStoreCheckpoint,
@@ -245,7 +268,7 @@ impl LastRestoreCheckpoint {
     }
 }
 
-#[derive(Clone, Debug, Default, Serialize, Deserialize)]
+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
 pub enum DetailedSummaryState {
     #[default]
     NotGenerated,
@@ -270,8 +293,8 @@ impl DetailedSummaryState {
 
 #[derive(Default, Debug)]
 pub struct TotalTokenUsage {
-    pub total: usize,
-    pub max: usize,
+    pub total: u64,
+    pub max: u64,
 }
 
 impl TotalTokenUsage {
@@ -297,7 +320,7 @@ impl TotalTokenUsage {
         }
     }
 
-    pub fn add(&self, tokens: usize) -> TotalTokenUsage {
+    pub fn add(&self, tokens: u64) -> TotalTokenUsage {
         TotalTokenUsage {
             total: self.total + tokens,
             max: self.max,
@@ -329,7 +352,7 @@ pub struct Thread {
     detailed_summary_task: Task<Option<()>>,
     detailed_summary_tx: postage::watch::Sender<DetailedSummaryState>,
     detailed_summary_rx: postage::watch::Receiver<DetailedSummaryState>,
-    completion_mode: assistant_settings::CompletionMode,
+    completion_mode: agent_settings::CompletionMode,
     messages: Vec<Message>,
     next_message_id: MessageId,
     last_prompt_id: PromptId,
@@ -348,9 +371,9 @@ pub struct Thread {
     request_token_usage: Vec<TokenUsage>,
     cumulative_token_usage: TokenUsage,
     exceeded_window_error: Option<ExceededWindowError>,
-    last_usage: Option<RequestUsage>,
     tool_use_limit_reached: bool,
     feedback: Option<ThreadFeedback>,
+    retry_state: Option<RetryState>,
     message_feedback: HashMap<MessageId, ThreadFeedback>,
     last_auto_capture_at: Option<Instant>,
     last_received_chunk_at: Option<Instant>,
@@ -359,6 +382,14 @@ pub struct Thread {
     >,
     remaining_turns: u32,
     configured_model: Option<ConfiguredModel>,
+    profile: AgentProfile,
+}
+
+#[derive(Clone, Debug)]
+struct RetryState {
+    attempt: u8,
+    max_attempts: u8,
+    intent: CompletionIntent,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq)]
@@ -388,12 +419,12 @@ impl ThreadSummary {
     }
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
 pub struct ExceededWindowError {
     /// Model used when last message exceeded context window
     model_id: LanguageModelId,
     /// Token count including last message
-    token_count: usize,
+    token_count: u64,
 }
 
 impl Thread {
@@ -406,6 +437,7 @@ impl Thread {
     ) -> Self {
         let (detailed_summary_tx, detailed_summary_rx) = postage::watch::channel();
         let configured_model = LanguageModelRegistry::read_global(cx).default_model();
+        let profile_id = AgentSettings::get_global(cx).default_profile.clone();
 
         Self {
             id: ThreadId::new(),
@@ -415,7 +447,7 @@ impl Thread {
             detailed_summary_task: Task::ready(None),
             detailed_summary_tx,
             detailed_summary_rx,
-            completion_mode: AssistantSettings::get_global(cx).preferred_completion_mode,
+            completion_mode: AgentSettings::get_global(cx).preferred_completion_mode,
             messages: Vec::new(),
             next_message_id: MessageId(0),
             last_prompt_id: PromptId::new(),
@@ -439,15 +471,16 @@ impl Thread {
             request_token_usage: Vec::new(),
             cumulative_token_usage: TokenUsage::default(),
             exceeded_window_error: None,
-            last_usage: None,
             tool_use_limit_reached: false,
             feedback: None,
+            retry_state: None,
             message_feedback: HashMap::default(),
             last_auto_capture_at: None,
             last_received_chunk_at: None,
             request_callback: None,
             remaining_turns: u32::MAX,
             configured_model,
+            profile: AgentProfile::new(profile_id, tools),
         }
     }
 
@@ -458,7 +491,7 @@ impl Thread {
         tools: Entity<ToolWorkingSet>,
         prompt_builder: Arc<PromptBuilder>,
         project_context: SharedProjectContext,
-        window: &mut Window,
+        window: Option<&mut Window>, // None in headless mode
         cx: &mut Context<Self>,
     ) -> Self {
         let next_message_id = MessageId(
@@ -493,7 +526,10 @@ impl Thread {
 
         let completion_mode = serialized
             .completion_mode
-            .unwrap_or_else(|| AssistantSettings::get_global(cx).preferred_completion_mode);
+            .unwrap_or_else(|| AgentSettings::get_global(cx).preferred_completion_mode);
+        let profile_id = serialized
+            .profile
+            .unwrap_or_else(|| AgentSettings::get_global(cx).default_profile.clone());
 
         Self {
             id,
@@ -504,6 +540,7 @@ impl Thread {
             detailed_summary_tx,
             detailed_summary_rx,
             completion_mode,
+            retry_state: None,
             messages: serialized
                 .messages
                 .into_iter()
@@ -533,13 +570,13 @@ impl Thread {
                         .into_iter()
                         .map(|crease| MessageCrease {
                             range: crease.start..crease.end,
-                            metadata: CreaseMetadata {
-                                icon_path: crease.icon_path,
-                                label: crease.label,
-                            },
+                            icon_path: crease.icon_path,
+                            label: crease.label,
                             context: None,
                         })
                         .collect(),
+                    is_hidden: message.is_hidden,
+                    ui_only: false, // UI-only messages are not persisted
                 })
                 .collect(),
             next_message_id,
@@ -552,15 +589,14 @@ impl Thread {
             pending_checkpoint: None,
             project: project.clone(),
             prompt_builder,
-            tools,
+            tools: tools.clone(),
             tool_use,
             action_log: cx.new(|_| ActionLog::new(project)),
             initial_project_snapshot: Task::ready(serialized.initial_project_snapshot).shared(),
             request_token_usage: serialized.request_token_usage,
             cumulative_token_usage: serialized.cumulative_token_usage,
             exceeded_window_error: None,
-            last_usage: None,
-            tool_use_limit_reached: false,
+            tool_use_limit_reached: serialized.tool_use_limit_reached,
             feedback: None,
             message_feedback: HashMap::default(),
             last_auto_capture_at: None,
@@ -568,6 +604,7 @@ impl Thread {
             request_callback: None,
             remaining_turns: u32::MAX,
             configured_model,
+            profile: AgentProfile::new(profile_id, tools),
         }
     }
 
@@ -583,6 +620,17 @@ impl Thread {
         &self.id
     }
 
+    pub fn profile(&self) -> &AgentProfile {
+        &self.profile
+    }
+
+    pub fn set_profile(&mut self, id: AgentProfileId, cx: &mut Context<Self>) {
+        if &id != self.profile.id() {
+            self.profile = AgentProfile::new(id, self.tools.clone());
+            cx.emit(ThreadEvent::ProfileChanged);
+        }
+    }
+
     pub fn is_empty(&self) -> bool {
         self.messages.is_empty()
     }
@@ -757,6 +805,14 @@ impl Thread {
             return;
         };
 
+        self.finalize_checkpoint(pending_checkpoint, cx);
+    }
+
+    fn finalize_checkpoint(
+        &mut self,
+        pending_checkpoint: ThreadCheckpoint,
+        cx: &mut Context<Self>,
+    ) {
         let git_store = self.project.read(cx).git_store().clone();
         let final_checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx));
         cx.spawn(async move |this, cx| match final_checkpoint.await {
@@ -841,15 +897,11 @@ impl Thread {
             .get(ix + 1)
             .and_then(|message| {
                 self.message(message.id)
-                    .map(|next_message| next_message.role == Role::User)
+                    .map(|next_message| next_message.role == Role::User && !next_message.is_hidden)
             })
             .unwrap_or(false)
     }
 
-    pub fn last_usage(&self) -> Option<RequestUsage> {
-        self.last_usage
-    }
-
     pub fn tool_use_limit_reached(&self) -> bool {
         self.tool_use_limit_reached
     }
@@ -861,7 +913,16 @@ impl Thread {
         self.tool_use
             .pending_tool_uses()
             .iter()
-            .all(|tool_use| tool_use.status.is_error())
+            .all(|pending_tool_use| pending_tool_use.status.is_error())
+    }
+
+    /// Returns whether any pending tool uses may perform edits
+    pub fn has_pending_edit_tool_uses(&self) -> bool {
+        self.tool_use
+            .pending_tool_uses()
+            .iter()
+            .filter(|pending_tool_use| !pending_tool_use.status.is_error())
+            .any(|pending_tool_use| pending_tool_use.may_perform_edits)
     }
 
     pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec<ToolUse> {
@@ -880,7 +941,13 @@ impl Thread {
     }
 
     pub fn output_for_tool(&self, id: &LanguageModelToolUseId) -> Option<&Arc<str>> {
-        Some(&self.tool_use.tool_result(id)?.content)
+        match &self.tool_use.tool_result(id)?.content {
+            LanguageModelToolResultContent::Text(text) => Some(text),
+            LanguageModelToolResultContent::Image(_) => {
+                // TODO: We should display image
+                None
+            }
+        }
     }
 
     pub fn card_for_tool(&self, id: &LanguageModelToolUseId) -> Option<AnyToolCard> {
@@ -894,15 +961,13 @@ impl Thread {
         model: Arc<dyn LanguageModel>,
     ) -> Vec<LanguageModelRequestTool> {
         if model.supports_tools() {
-            self.tools()
-                .read(cx)
-                .enabled_tools(cx)
+            resolve_tool_name_conflicts(self.profile.enabled_tools(cx).as_slice())
                 .into_iter()
-                .filter_map(|tool| {
+                .filter_map(|(name, tool)| {
                     // Skip tools that cannot be supported
                     let input_schema = tool.input_schema(model.tool_input_format()).ok()?;
                     Some(LanguageModelRequestTool {
-                        name: tool.name(),
+                        name,
                         description: tool.description(),
                         input_schema,
                     })
@@ -934,6 +999,7 @@ impl Thread {
             vec![MessageSegment::Text(text.into())],
             loaded_context.loaded_context,
             creases,
+            false,
             cx,
         );
 
@@ -949,6 +1015,20 @@ impl Thread {
         message_id
     }
 
+    pub fn insert_invisible_continue_message(&mut self, cx: &mut Context<Self>) -> MessageId {
+        let id = self.insert_message(
+            Role::User,
+            vec![MessageSegment::Text("Continue where you left off".into())],
+            LoadedContext::default(),
+            vec![],
+            true,
+            cx,
+        );
+        self.pending_checkpoint = None;
+
+        id
+    }
+
     pub fn insert_assistant_message(
         &mut self,
         segments: Vec<MessageSegment>,
@@ -959,6 +1039,7 @@ impl Thread {
             segments,
             LoadedContext::default(),
             Vec::new(),
+            false,
             cx,
         )
     }
@@ -969,6 +1050,7 @@ impl Thread {
         segments: Vec<MessageSegment>,
         loaded_context: LoadedContext,
         creases: Vec<MessageCrease>,
+        is_hidden: bool,
         cx: &mut Context<Self>,
     ) -> MessageId {
         let id = self.next_message_id.post_inc();
@@ -978,6 +1060,8 @@ impl Thread {
             segments,
             loaded_context,
             creases,
+            is_hidden,
+            ui_only: false,
         });
         self.touch_updated_at();
         cx.emit(ThreadEvent::MessageAdded(id));
@@ -989,7 +1073,9 @@ impl Thread {
         id: MessageId,
         new_role: Role,
         new_segments: Vec<MessageSegment>,
+        creases: Vec<MessageCrease>,
         loaded_context: Option<LoadedContext>,
+        checkpoint: Option<GitStoreCheckpoint>,
         cx: &mut Context<Self>,
     ) -> bool {
         let Some(message) = self.messages.iter_mut().find(|message| message.id == id) else {
@@ -997,9 +1083,19 @@ impl Thread {
         };
         message.role = new_role;
         message.segments = new_segments;
+        message.creases = creases;
         if let Some(context) = loaded_context {
             message.loaded_context = context;
         }
+        if let Some(git_checkpoint) = checkpoint {
+            self.checkpoints_by_message.insert(
+                id,
+                ThreadCheckpoint {
+                    message_id: id,
+                    git_checkpoint,
+                },
+            );
+        }
         self.touch_updated_at();
         cx.emit(ThreadEvent::MessageEdited(id));
         true
@@ -1055,6 +1151,7 @@ impl Thread {
                 updated_at: this.updated_at(),
                 messages: this
                     .messages()
+                    .filter(|message| !message.ui_only)
                     .map(|message| SerializedMessage {
                         id: message.id,
                         role: message.role,
@@ -1104,10 +1201,11 @@ impl Thread {
                             .map(|crease| SerializedCrease {
                                 start: crease.range.start,
                                 end: crease.range.end,
-                                icon_path: crease.metadata.icon_path.clone(),
-                                label: crease.metadata.label.clone(),
+                                icon_path: crease.icon_path.clone(),
+                                label: crease.label.clone(),
                             })
                             .collect(),
+                        is_hidden: message.is_hidden,
                     })
                     .collect(),
                 initial_project_snapshot,
@@ -1123,6 +1221,8 @@ impl Thread {
                         model: model.model.id().0.to_string(),
                     }),
                 completion_mode: Some(this.completion_mode),
+                tool_use_limit_reached: this.tool_use_limit_reached,
+                profile: Some(this.profile.id().clone()),
             })
         })
     }
@@ -1138,6 +1238,7 @@ impl Thread {
     pub fn send_to_model(
         &mut self,
         model: Arc<dyn LanguageModel>,
+        intent: CompletionIntent,
         window: Option<AnyWindowHandle>,
         cx: &mut Context<Self>,
     ) {
@@ -1147,9 +1248,9 @@ impl Thread {
 
         self.remaining_turns -= 1;
 
-        let request = self.to_completion_request(model.clone(), cx);
+        let request = self.to_completion_request(model.clone(), intent, cx);
 
-        self.stream_completion(request, model, window, cx);
+        self.stream_completion(request, model, intent, window, cx);
     }
 
     pub fn used_tools_since_last_user_message(&self) -> bool {
@@ -1167,17 +1268,19 @@ impl Thread {
     pub fn to_completion_request(
         &self,
         model: Arc<dyn LanguageModel>,
+        intent: CompletionIntent,
         cx: &mut Context<Self>,
     ) -> LanguageModelRequest {
         let mut request = LanguageModelRequest {
             thread_id: Some(self.id.to_string()),
             prompt_id: Some(self.last_prompt_id.to_string()),
+            intent: Some(intent),
             mode: None,
             messages: vec![],
             tools: Vec::new(),
             tool_choice: None,
             stop: Vec::new(),
-            temperature: AssistantSettings::temperature_for_model(&model, cx),
+            temperature: AgentSettings::temperature_for_model(&model, cx),
         };
 
         let available_tools = self.available_tools(cx, model.clone());
@@ -1222,6 +1325,11 @@ impl Thread {
 
         let mut message_ix_to_cache = None;
         for message in &self.messages {
+            // ui_only messages are for the UI only, not for the model
+            if message.ui_only {
+                continue;
+            }
+
             let mut request_message = LanguageModelRequestMessage {
                 role: message.role,
                 content: Vec::new(),
@@ -1310,10 +1418,8 @@ impl Thread {
             request.messages[message_ix_to_cache].cache = true;
         }
 
-        self.attached_tracked_files_state(&mut request.messages, cx);
-
         request.tools = available_tools;
-        request.mode = if model.supports_max_mode() {
+        request.mode = if model.supports_burn_mode() {
             Some(self.completion_mode.into())
         } else {
             Some(CompletionMode::Normal.into())
@@ -1325,18 +1431,20 @@ impl Thread {
     fn to_summarize_request(
         &self,
         model: &Arc<dyn LanguageModel>,
+        intent: CompletionIntent,
         added_user_message: String,
         cx: &App,
     ) -> LanguageModelRequest {
         let mut request = LanguageModelRequest {
             thread_id: None,
             prompt_id: None,
+            intent: Some(intent),
             mode: None,
             messages: vec![],
             tools: Vec::new(),
             tool_choice: None,
             stop: Vec::new(),
-            temperature: AssistantSettings::temperature_for_model(model, cx),
+            temperature: AgentSettings::temperature_for_model(model, cx),
         };
 
         for message in &self.messages {
@@ -1372,50 +1480,11 @@ impl Thread {
         request
     }
 
-    fn attached_tracked_files_state(
-        &self,
-        messages: &mut Vec<LanguageModelRequestMessage>,
-        cx: &App,
-    ) {
-        const STALE_FILES_HEADER: &str = "These files changed since last read:";
-
-        let mut stale_message = String::new();
-
-        let action_log = self.action_log.read(cx);
-
-        for stale_file in action_log.stale_buffers(cx) {
-            let Some(file) = stale_file.read(cx).file() else {
-                continue;
-            };
-
-            if stale_message.is_empty() {
-                write!(&mut stale_message, "{}\n", STALE_FILES_HEADER).ok();
-            }
-
-            writeln!(&mut stale_message, "- {}", file.path().display()).ok();
-        }
-
-        let mut content = Vec::with_capacity(2);
-
-        if !stale_message.is_empty() {
-            content.push(stale_message.into());
-        }
-
-        if !content.is_empty() {
-            let context_message = LanguageModelRequestMessage {
-                role: Role::User,
-                content,
-                cache: false,
-            };
-
-            messages.push(context_message);
-        }
-    }
-
     pub fn stream_completion(
         &mut self,
         request: LanguageModelRequest,
         model: Arc<dyn LanguageModel>,
+        intent: CompletionIntent,
         window: Option<AnyWindowHandle>,
         cx: &mut Context<Self>,
     ) {
@@ -1463,24 +1532,76 @@ impl Thread {
                     thread.update(cx, |thread, cx| {
                         let event = match event {
                             Ok(event) => event,
-                            Err(LanguageModelCompletionError::BadInputJson {
-                                id,
-                                tool_name,
-                                raw_input: invalid_input_json,
-                                json_parse_error,
-                            }) => {
-                                thread.receive_invalid_tool_json(
-                                    id,
-                                    tool_name,
-                                    invalid_input_json,
-                                    json_parse_error,
-                                    window,
-                                    cx,
-                                );
-                                return Ok(());
-                            }
-                            Err(LanguageModelCompletionError::Other(error)) => {
-                                return Err(error);
+                            Err(error) => {
+                                match error {
+                                    LanguageModelCompletionError::RateLimitExceeded { retry_after } => {
+                                        anyhow::bail!(LanguageModelKnownError::RateLimitExceeded { retry_after });
+                                    }
+                                    LanguageModelCompletionError::Overloaded => {
+                                        anyhow::bail!(LanguageModelKnownError::Overloaded);
+                                    }
+                                    LanguageModelCompletionError::ApiInternalServerError =>{
+                                        anyhow::bail!(LanguageModelKnownError::ApiInternalServerError);
+                                    }
+                                    LanguageModelCompletionError::PromptTooLarge { tokens } => {
+                                        let tokens = tokens.unwrap_or_else(|| {
+                                            // We didn't get an exact token count from the API, so fall back on our estimate.
+                                            thread.total_token_usage()
+                                                .map(|usage| usage.total)
+                                                .unwrap_or(0)
+                                                // We know the context window was exceeded in practice, so if our estimate was
+                                                // lower than max tokens, the estimate was wrong; return that we exceeded by 1.
+                                                .max(model.max_token_count().saturating_add(1))
+                                        });
+
+                                        anyhow::bail!(LanguageModelKnownError::ContextWindowLimitExceeded { tokens })
+                                    }
+                                    LanguageModelCompletionError::ApiReadResponseError(io_error) => {
+                                        anyhow::bail!(LanguageModelKnownError::ReadResponseError(io_error));
+                                    }
+                                    LanguageModelCompletionError::UnknownResponseFormat(error) => {
+                                        anyhow::bail!(LanguageModelKnownError::UnknownResponseFormat(error));
+                                    }
+                                    LanguageModelCompletionError::HttpResponseError { status, ref body } => {
+                                        if let Some(known_error) = LanguageModelKnownError::from_http_response(status, body) {
+                                            anyhow::bail!(known_error);
+                                        } else {
+                                            return Err(error.into());
+                                        }
+                                    }
+                                    LanguageModelCompletionError::DeserializeResponse(error) => {
+                                        anyhow::bail!(LanguageModelKnownError::DeserializeResponse(error));
+                                    }
+                                    LanguageModelCompletionError::BadInputJson {
+                                        id,
+                                        tool_name,
+                                        raw_input: invalid_input_json,
+                                        json_parse_error,
+                                    } => {
+                                        thread.receive_invalid_tool_json(
+                                            id,
+                                            tool_name,
+                                            invalid_input_json,
+                                            json_parse_error,
+                                            window,
+                                            cx,
+                                        );
+                                        return Ok(());
+                                    }
+                                    // These are all errors we can't automatically attempt to recover from (e.g. by retrying)
+                                    err @ LanguageModelCompletionError::BadRequestFormat |
+                                    err @ LanguageModelCompletionError::AuthenticationError |
+                                    err @ LanguageModelCompletionError::PermissionError |
+                                    err @ LanguageModelCompletionError::ApiEndpointNotFound |
+                                    err @ LanguageModelCompletionError::SerializeRequest(_) |
+                                    err @ LanguageModelCompletionError::BuildRequestBody(_) |
+                                    err @ LanguageModelCompletionError::HttpSend(_) => {
+                                        anyhow::bail!(err);
+                                    }
+                                    LanguageModelCompletionError::Other(error) => {
+                                        return Err(error);
+                                    }
+                                }
                             }
                         };
 
@@ -1561,6 +1682,25 @@ impl Thread {
                                     };
                                 }
                             }
+                            LanguageModelCompletionEvent::RedactedThinking {
+                                data
+                            } => {
+                                thread.received_chunk();
+
+                                if let Some(last_message) = thread.messages.last_mut() {
+                                    if last_message.role == Role::Assistant
+                                        && !thread.tool_use.has_tool_results(last_message.id)
+                                    {
+                                        last_message.push_redacted_thinking(data);
+                                    } else {
+                                        request_assistant_message_id =
+                                            Some(thread.insert_assistant_message(
+                                                vec![MessageSegment::RedactedThinking(data)],
+                                                cx,
+                                            ));
+                                    };
+                                }
+                            }
                             LanguageModelCompletionEvent::ToolUse(tool_use) => {
                                 let last_assistant_message_id = request_assistant_message_id
                                     .unwrap_or_else(|| {
@@ -1611,17 +1751,16 @@ impl Thread {
                                         CompletionRequestStatus::Failed {
                                             code, message, request_id
                                         } => {
-                                            return Err(anyhow!("completion request failed. request_id: {request_id}, code: {code}, message: {message}"));
+                                            anyhow::bail!("completion request failed. request_id: {request_id}, code: {code}, message: {message}");
                                         }
                                         CompletionRequestStatus::UsageUpdated {
                                             amount, limit
                                         } => {
-                                            let usage = RequestUsage { limit, amount: amount as i32 };
-
-                                            thread.last_usage = Some(usage);
+                                            thread.update_model_request_usage(amount as u32, limit, cx);
                                         }
                                         CompletionRequestStatus::ToolUseLimitReached => {
                                             thread.tool_use_limit_reached = true;
+                                            cx.emit(ThreadEvent::ToolUseLimitReached);
                                         }
                                     }
                                 }
@@ -1659,33 +1798,84 @@ impl Thread {
             };
 
             let result = stream_completion.await;
+            let mut retry_scheduled = false;
 
             thread
                 .update(cx, |thread, cx| {
                     thread.finalize_pending_checkpoint(cx);
                     match result.as_ref() {
-                        Ok(stop_reason) => match stop_reason {
-                            StopReason::ToolUse => {
-                                let tool_uses = thread.use_pending_tools(window, cx, model.clone());
-                                cx.emit(ThreadEvent::UsePendingTools { tool_uses });
-                            }
-                            StopReason::EndTurn | StopReason::MaxTokens  => {
-                                thread.project.update(cx, |project, cx| {
-                                    project.set_agent_location(None, cx);
-                                });
+                        Ok(stop_reason) => {
+                            match stop_reason {
+                                StopReason::ToolUse => {
+                                    let tool_uses = thread.use_pending_tools(window, model.clone(), cx);
+                                    cx.emit(ThreadEvent::UsePendingTools { tool_uses });
+                                }
+                                StopReason::EndTurn | StopReason::MaxTokens  => {
+                                    thread.project.update(cx, |project, cx| {
+                                        project.set_agent_location(None, cx);
+                                    });
+                                }
+                                StopReason::Refusal => {
+                                    thread.project.update(cx, |project, cx| {
+                                        project.set_agent_location(None, cx);
+                                    });
+
+                                    // Remove the turn that was refused.
+                                    //
+                                    // https://docs.anthropic.com/en/docs/test-and-evaluate/strengthen-guardrails/handle-streaming-refusals#reset-context-after-refusal
+                                    {
+                                        let mut messages_to_remove = Vec::new();
+
+                                        for (ix, message) in thread.messages.iter().enumerate().rev() {
+                                            messages_to_remove.push(message.id);
+
+                                            if message.role == Role::User {
+                                                if ix == 0 {
+                                                    break;
+                                                }
+
+                                                if let Some(prev_message) = thread.messages.get(ix - 1) {
+                                                    if prev_message.role == Role::Assistant {
+                                                        break;
+                                                    }
+                                                }
+                                            }
+                                        }
+
+                                        for message_id in messages_to_remove {
+                                            thread.delete_message(message_id, cx);
+                                        }
+                                    }
+
+                                    cx.emit(ThreadEvent::ShowError(ThreadError::Message {
+                                        header: "Language model refusal".into(),
+                                        message: "Model refused to generate content for safety reasons.".into(),
+                                    }));
+                                }
                             }
+
+                            // We successfully completed, so cancel any remaining retries.
+                            thread.retry_state = None;
                         },
                         Err(error) => {
                             thread.project.update(cx, |project, cx| {
                                 project.set_agent_location(None, cx);
                             });
 
+                            fn emit_generic_error(error: &anyhow::Error, cx: &mut Context<Thread>) {
+                                let error_message = error
+                                    .chain()
+                                    .map(|err| err.to_string())
+                                    .collect::<Vec<_>>()
+                                    .join("\n");
+                                cx.emit(ThreadEvent::ShowError(ThreadError::Message {
+                                    header: "Error interacting with language model".into(),
+                                    message: SharedString::from(error_message.clone()),
+                                }));
+                            }
+
                             if error.is::<PaymentRequiredError>() {
                                 cx.emit(ThreadEvent::ShowError(ThreadError::PaymentRequired));
-                            } else if error.is::<MaxMonthlySpendReachedError>() {
-                                cx.emit(ThreadEvent::ShowError(
-                                    ThreadError::MaxMonthlySpendReached,
-                                ));
                             } else if let Some(error) =
                                 error.downcast_ref::<ModelRequestLimitReachedError>()
                             {
@@ -1696,32 +1886,86 @@ impl Thread {
                                 error.downcast_ref::<LanguageModelKnownError>()
                             {
                                 match known_error {
-                                    LanguageModelKnownError::ContextWindowLimitExceeded {
-                                        tokens,
-                                    } => {
+                                    LanguageModelKnownError::ContextWindowLimitExceeded { tokens } => {
                                         thread.exceeded_window_error = Some(ExceededWindowError {
                                             model_id: model.id(),
                                             token_count: *tokens,
                                         });
                                         cx.notify();
                                     }
+                                    LanguageModelKnownError::RateLimitExceeded { retry_after } => {
+                                        let provider_name = model.provider_name();
+                                        let error_message = format!(
+                                            "{}'s API rate limit exceeded",
+                                            provider_name.0.as_ref()
+                                        );
+
+                                        thread.handle_rate_limit_error(
+                                            &error_message,
+                                            *retry_after,
+                                            model.clone(),
+                                            intent,
+                                            window,
+                                            cx,
+                                        );
+                                        retry_scheduled = true;
+                                    }
+                                    LanguageModelKnownError::Overloaded => {
+                                        let provider_name = model.provider_name();
+                                        let error_message = format!(
+                                            "{}'s API servers are overloaded right now",
+                                            provider_name.0.as_ref()
+                                        );
+
+                                        retry_scheduled = thread.handle_retryable_error(
+                                            &error_message,
+                                            model.clone(),
+                                            intent,
+                                            window,
+                                            cx,
+                                        );
+                                        if !retry_scheduled {
+                                            emit_generic_error(error, cx);
+                                        }
+                                    }
+                                    LanguageModelKnownError::ApiInternalServerError => {
+                                        let provider_name = model.provider_name();
+                                        let error_message = format!(
+                                            "{}'s API server reported an internal server error",
+                                            provider_name.0.as_ref()
+                                        );
+
+                                        retry_scheduled = thread.handle_retryable_error(
+                                            &error_message,
+                                            model.clone(),
+                                            intent,
+                                            window,
+                                            cx,
+                                        );
+                                        if !retry_scheduled {
+                                            emit_generic_error(error, cx);
+                                        }
+                                    }
+                                    LanguageModelKnownError::ReadResponseError(_) |
+                                    LanguageModelKnownError::DeserializeResponse(_) |
+                                    LanguageModelKnownError::UnknownResponseFormat(_) => {
+                                        // In the future we will attempt to re-roll response, but only once
+                                        emit_generic_error(error, cx);
+                                    }
                                 }
                             } else {
-                                let error_message = error
-                                    .chain()
-                                    .map(|err| err.to_string())
-                                    .collect::<Vec<_>>()
-                                    .join("\n");
-                                cx.emit(ThreadEvent::ShowError(ThreadError::Message {
-                                    header: "Error interacting with language model".into(),
-                                    message: SharedString::from(error_message.clone()),
-                                }));
+                                emit_generic_error(error, cx);
                             }
 
-                            thread.cancel_last_completion(window, cx);
+                            if !retry_scheduled {
+                                thread.cancel_last_completion(window, cx);
+                            }
                         }
                     }
-                    cx.emit(ThreadEvent::Stopped(result.map_err(Arc::new)));
+
+                    if !retry_scheduled {
+                        cx.emit(ThreadEvent::Stopped(result.map_err(Arc::new)));
+                    }
 
                     if let Some((request_callback, (request, response_events))) = thread
                         .request_callback

crates/agent/src/thread_store.rs 🔗

@@ -1,25 +1,26 @@
-use std::borrow::Cow;
-use std::cell::{Ref, RefCell};
-use std::path::{Path, PathBuf};
-use std::rc::Rc;
-use std::sync::Arc;
-
+use crate::{
+    context_server_tool::ContextServerTool,
+    thread::{
+        DetailedSummaryState, ExceededWindowError, MessageId, ProjectSnapshot, Thread, ThreadId,
+    },
+};
+use agent_settings::{AgentProfileId, CompletionMode};
 use anyhow::{Context as _, Result, anyhow};
-use assistant_settings::{AgentProfile, AgentProfileId, AssistantSettings, CompletionMode};
-use assistant_tool::{ToolId, ToolSource, ToolWorkingSet};
+use assistant_tool::{ToolId, ToolWorkingSet};
 use chrono::{DateTime, Utc};
 use collections::HashMap;
 use context_server::ContextServerId;
-use futures::channel::{mpsc, oneshot};
-use futures::future::{self, BoxFuture, Shared};
-use futures::{FutureExt as _, StreamExt as _};
+use futures::{
+    FutureExt as _, StreamExt as _,
+    channel::{mpsc, oneshot},
+    future::{self, BoxFuture, Shared},
+};
 use gpui::{
     App, BackgroundExecutor, Context, Entity, EventEmitter, Global, ReadGlobal, SharedString,
-    Subscription, Task, prelude::*,
+    Subscription, Task, Window, prelude::*,
 };
-use heed::Database;
-use heed::types::SerdeBincode;
-use language_model::{LanguageModelToolUseId, Role, TokenUsage};
+use indoc::indoc;
+use language_model::{LanguageModelToolResultContent, LanguageModelToolUseId, Role, TokenUsage};
 use project::context_server_store::{ContextServerStatus, ContextServerStore};
 use project::{Project, ProjectItem, ProjectPath, Worktree};
 use prompt_store::{
@@ -27,22 +28,59 @@ use prompt_store::{
     UserRulesContext, WorktreeContext,
 };
 use serde::{Deserialize, Serialize};
-use settings::{Settings as _, SettingsStore};
-use ui::Window;
+use sqlez::{
+    bindable::{Bind, Column},
+    connection::Connection,
+    statement::Statement,
+};
+use std::{
+    cell::{Ref, RefCell},
+    path::{Path, PathBuf},
+    rc::Rc,
+    sync::{Arc, Mutex},
+};
 use util::ResultExt as _;
 
-use crate::context_server_tool::ContextServerTool;
-use crate::thread::{
-    DetailedSummaryState, ExceededWindowError, MessageId, ProjectSnapshot, Thread, ThreadId,
-};
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum DataType {
+    #[serde(rename = "json")]
+    Json,
+    #[serde(rename = "zstd")]
+    Zstd,
+}
 
-const RULES_FILE_NAMES: [&'static str; 6] = [
+impl Bind for DataType {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let value = match self {
+            DataType::Json => "json",
+            DataType::Zstd => "zstd",
+        };
+        value.bind(statement, start_index)
+    }
+}
+
+impl Column for DataType {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let (value, next_index) = String::column(statement, start_index)?;
+        let data_type = match value.as_str() {
+            "json" => DataType::Json,
+            "zstd" => DataType::Zstd,
+            _ => anyhow::bail!("Unknown data type: {}", value),
+        };
+        Ok((data_type, next_index))
+    }
+}
+
+const RULES_FILE_NAMES: [&'static str; 9] = [
     ".rules",
     ".cursorrules",
     ".windsurfrules",
     ".clinerules",
     ".github/copilot-instructions.md",
     "CLAUDE.md",
+    "AGENT.md",
+    "AGENTS.md",
+    "GEMINI.md",
 ];
 
 pub fn init(cx: &mut App) {
@@ -54,12 +92,12 @@ pub fn init(cx: &mut App) {
 pub struct SharedProjectContext(Rc<RefCell<Option<ProjectContext>>>);
 
 impl SharedProjectContext {
-    pub fn borrow(&self) -> Ref<Option<ProjectContext>> {
+    pub fn borrow(&self) -> Ref<'_, Option<ProjectContext>> {
         self.0.borrow()
     }
 }
 
-pub type TextThreadStore = assistant_context_editor::ContextStore;
+pub type TextThreadStore = assistant_context::ContextStore;
 
 pub struct ThreadStore {
     project: Entity<Project>,
@@ -111,12 +149,7 @@ impl ThreadStore {
         prompt_store: Option<Entity<PromptStore>>,
         cx: &mut Context<Self>,
     ) -> (Self, oneshot::Receiver<()>) {
-        let mut subscriptions = vec![
-            cx.observe_global::<SettingsStore>(move |this: &mut Self, cx| {
-                this.load_default_profile(cx);
-            }),
-            cx.subscribe(&project, Self::handle_project_event),
-        ];
+        let mut subscriptions = vec![cx.subscribe(&project, Self::handle_project_event)];
 
         if let Some(prompt_store) = prompt_store.as_ref() {
             subscriptions.push(cx.subscribe(
@@ -164,7 +197,6 @@ impl ThreadStore {
             _reload_system_prompt_task: reload_system_prompt_task,
             _subscriptions: subscriptions,
         };
-        this.load_default_profile(cx);
         this.register_context_server_handlers(cx);
         this.reload(cx).detach_and_log_err(cx);
         (this, ready_rx)
@@ -276,17 +308,19 @@ impl ThreadStore {
         project: Entity<Project>,
         cx: &mut App,
     ) -> Task<(WorktreeContext, Option<RulesLoadingError>)> {
-        let root_name = worktree.read(cx).root_name().into();
+        let tree = worktree.read(cx);
+        let root_name = tree.root_name().into();
+        let abs_path = tree.abs_path();
+
+        let mut context = WorktreeContext {
+            root_name,
+            abs_path,
+            rules_file: None,
+        };
 
         let rules_task = Self::load_worktree_rules_file(worktree, project, cx);
         let Some(rules_task) = rules_task else {
-            return Task::ready((
-                WorktreeContext {
-                    root_name,
-                    rules_file: None,
-                },
-                None,
-            ));
+            return Task::ready((context, None));
         };
 
         cx.spawn(async move |_| {
@@ -299,11 +333,8 @@ impl ThreadStore {
                     }),
                 ),
             };
-            let worktree_info = WorktreeContext {
-                root_name,
-                rules_file,
-            };
-            (worktree_info, rules_file_error)
+            context.rules_file = rules_file;
+            (context, rules_file_error)
         })
     }
 
@@ -312,12 +343,12 @@ impl ThreadStore {
         project: Entity<Project>,
         cx: &mut App,
     ) -> Option<Task<Result<RulesFileContext>>> {
-        let worktree_ref = worktree.read(cx);
-        let worktree_id = worktree_ref.id();
+        let worktree = worktree.read(cx);
+        let worktree_id = worktree.id();
         let selected_rules_file = RULES_FILE_NAMES
             .into_iter()
             .filter_map(|name| {
-                worktree_ref
+                worktree
                     .entry_for_path(name)
                     .filter(|entry| entry.is_file())
                     .map(|entry| entry.path.clone())
@@ -364,16 +395,11 @@ impl ThreadStore {
         self.threads.len()
     }
 
-    pub fn unordered_threads(&self) -> impl Iterator<Item = &SerializedThreadMetadata> {
+    pub fn reverse_chronological_threads(&self) -> impl Iterator<Item = &SerializedThreadMetadata> {
+        // ordering is from "ORDER BY" in `list_threads`
         self.threads.iter()
     }
 
-    pub fn reverse_chronological_threads(&self) -> Vec<SerializedThreadMetadata> {
-        let mut threads = self.threads.iter().cloned().collect::<Vec<_>>();
-        threads.sort_unstable_by_key(|thread| std::cmp::Reverse(thread.updated_at));
-        threads
-    }
-
     pub fn create_thread(&mut self, cx: &mut Context<Self>) -> Entity<Thread> {
         cx.new(|cx| {
             Thread::new(
@@ -386,6 +412,25 @@ impl ThreadStore {
         })
     }
 
+    pub fn create_thread_from_serialized(
+        &mut self,
+        serialized: SerializedThread,
+        cx: &mut Context<Self>,
+    ) -> Entity<Thread> {
+        cx.new(|cx| {
+            Thread::deserialize(
+                ThreadId::new(),
+                serialized,
+                self.project.clone(),
+                self.tools.clone(),
+                self.prompt_builder.clone(),
+                self.project_context.clone(),
+                None,
+                cx,
+            )
+        })
+    }
+
     pub fn open_thread(
         &self,
         id: &ThreadId,
@@ -400,7 +445,7 @@ impl ThreadStore {
             let thread = database
                 .try_find_thread(id.clone())
                 .await?
-                .ok_or_else(|| anyhow!("no thread found with ID: {id:?}"))?;
+                .with_context(|| format!("no thread found with ID: {id:?}"))?;
 
             let thread = this.update_in(cx, |this, window, cx| {
                 cx.new(|cx| {
@@ -411,7 +456,7 @@ impl ThreadStore {
                         this.tools.clone(),
                         this.prompt_builder.clone(),
                         this.project_context.clone(),
-                        window,
+                        Some(window),
                         cx,
                     )
                 })
@@ -465,94 +510,17 @@ impl ThreadStore {
         })
     }
 
-    fn load_default_profile(&self, cx: &mut Context<Self>) {
-        let assistant_settings = AssistantSettings::get_global(cx);
-
-        self.load_profile_by_id(assistant_settings.default_profile.clone(), cx);
-    }
-
-    pub fn load_profile_by_id(&self, profile_id: AgentProfileId, cx: &mut Context<Self>) {
-        let assistant_settings = AssistantSettings::get_global(cx);
-
-        if let Some(profile) = assistant_settings.profiles.get(&profile_id) {
-            self.load_profile(profile.clone(), cx);
-        }
-    }
-
-    pub fn load_profile(&self, profile: AgentProfile, cx: &mut Context<Self>) {
-        self.tools.update(cx, |tools, cx| {
-            tools.disable_all_tools(cx);
-            tools.enable(
-                ToolSource::Native,
-                &profile
-                    .tools
-                    .iter()
-                    .filter_map(|(tool, enabled)| enabled.then(|| tool.clone()))
-                    .collect::<Vec<_>>(),
-                cx,
-            );
-        });
+    fn register_context_server_handlers(&self, cx: &mut Context<Self>) {
+        let context_server_store = self.project.read(cx).context_server_store();
+        cx.subscribe(&context_server_store, Self::handle_context_server_event)
+            .detach();
 
-        if profile.enable_all_context_servers {
-            for context_server_id in self
-                .project
-                .read(cx)
-                .context_server_store()
-                .read(cx)
-                .all_server_ids()
-            {
-                self.tools.update(cx, |tools, cx| {
-                    tools.enable_source(
-                        ToolSource::ContextServer {
-                            id: context_server_id.0.into(),
-                        },
-                        cx,
-                    );
-                });
-            }
-            // Enable all the tools from all context servers, but disable the ones that are explicitly disabled
-            for (context_server_id, preset) in &profile.context_servers {
-                self.tools.update(cx, |tools, cx| {
-                    tools.disable(
-                        ToolSource::ContextServer {
-                            id: context_server_id.clone().into(),
-                        },
-                        &preset
-                            .tools
-                            .iter()
-                            .filter_map(|(tool, enabled)| (!enabled).then(|| tool.clone()))
-                            .collect::<Vec<_>>(),
-                        cx,
-                    )
-                })
-            }
-        } else {
-            for (context_server_id, preset) in &profile.context_servers {
-                self.tools.update(cx, |tools, cx| {
-                    tools.enable(
-                        ToolSource::ContextServer {
-                            id: context_server_id.clone().into(),
-                        },
-                        &preset
-                            .tools
-                            .iter()
-                            .filter_map(|(tool, enabled)| enabled.then(|| tool.clone()))
-                            .collect::<Vec<_>>(),
-                        cx,
-                    )
-                })
-            }
+        // Check for any servers that were already running before the handler was registered
+        for server in context_server_store.read(cx).running_servers() {
+            self.load_context_server_tools(server.id(), context_server_store.clone(), cx);
         }
     }
 
-    fn register_context_server_handlers(&self, cx: &mut Context<Self>) {
-        cx.subscribe(
-            &self.project.read(cx).context_server_store(),
-            Self::handle_context_server_event,
-        )
-        .detach();
-    }
-
     fn handle_context_server_event(
         &mut self,
         context_server_store: Entity<ContextServerStore>,
@@ -563,71 +531,71 @@ impl ThreadStore {
         match event {
             project::context_server_store::Event::ServerStatusChanged { server_id, status } => {
                 match status {
+                    ContextServerStatus::Starting => {}
                     ContextServerStatus::Running => {
-                        if let Some(server) =
-                            context_server_store.read(cx).get_running_server(server_id)
-                        {
-                            let context_server_manager = context_server_store.clone();
-                            cx.spawn({
-                                let server = server.clone();
-                                let server_id = server_id.clone();
-                                async move |this, cx| {
-                                    let Some(protocol) = server.client() else {
-                                        return;
-                                    };
-
-                                    if protocol.capable(context_server::protocol::ServerCapability::Tools) {
-                                        if let Some(tools) = protocol.list_tools().await.log_err() {
-                                            let tool_ids = tool_working_set
-                                                .update(cx, |tool_working_set, _| {
-                                                    tools
-                                                        .tools
-                                                        .into_iter()
-                                                        .map(|tool| {
-                                                            log::info!(
-                                                                "registering context server tool: {:?}",
-                                                                tool.name
-                                                            );
-                                                            tool_working_set.insert(Arc::new(
-                                                                ContextServerTool::new(
-                                                                    context_server_manager.clone(),
-                                                                    server.id(),
-                                                                    tool,
-                                                                ),
-                                                            ))
-                                                        })
-                                                        .collect::<Vec<_>>()
-                                                })
-                                                .log_err();
-
-                                            if let Some(tool_ids) = tool_ids {
-                                                this.update(cx, |this, cx| {
-                                                    this.context_server_tool_ids
-                                                        .insert(server_id, tool_ids);
-                                                    this.load_default_profile(cx);
-                                                })
-                                                .log_err();
-                                            }
-                                        }
-                                    }
-                                }
-                            })
-                            .detach();
-                        }
+                        self.load_context_server_tools(server_id.clone(), context_server_store, cx);
                     }
                     ContextServerStatus::Stopped | ContextServerStatus::Error(_) => {
                         if let Some(tool_ids) = self.context_server_tool_ids.remove(server_id) {
                             tool_working_set.update(cx, |tool_working_set, _| {
                                 tool_working_set.remove(&tool_ids);
                             });
-                            self.load_default_profile(cx);
                         }
                     }
-                    _ => {}
                 }
             }
         }
     }
+
+    fn load_context_server_tools(
+        &self,
+        server_id: ContextServerId,
+        context_server_store: Entity<ContextServerStore>,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(server) = context_server_store.read(cx).get_running_server(&server_id) else {
+            return;
+        };
+        let tool_working_set = self.tools.clone();
+        cx.spawn(async move |this, cx| {
+            let Some(protocol) = server.client() else {
+                return;
+            };
+
+            if protocol.capable(context_server::protocol::ServerCapability::Tools) {
+                if let Some(response) = protocol
+                    .request::<context_server::types::requests::ListTools>(())
+                    .await
+                    .log_err()
+                {
+                    let tool_ids = tool_working_set
+                        .update(cx, |tool_working_set, _| {
+                            response
+                                .tools
+                                .into_iter()
+                                .map(|tool| {
+                                    log::info!("registering context server tool: {:?}", tool.name);
+                                    tool_working_set.insert(Arc::new(ContextServerTool::new(
+                                        context_server_store.clone(),
+                                        server.id(),
+                                        tool,
+                                    )))
+                                })
+                                .collect::<Vec<_>>()
+                        })
+                        .log_err();
+
+                    if let Some(tool_ids) = tool_ids {
+                        this.update(cx, |this, _| {
+                            this.context_server_tool_ids.insert(server_id, tool_ids);
+                        })
+                        .log_err();
+                    }
+                }
+            }
+        })
+        .detach();
+    }
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -637,7 +605,7 @@ pub struct SerializedThreadMetadata {
     pub updated_at: DateTime<Utc>,
 }
 
-#[derive(Serialize, Deserialize, Debug)]
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
 pub struct SerializedThread {
     pub version: String,
     pub summary: SharedString,
@@ -657,9 +625,13 @@ pub struct SerializedThread {
     pub model: Option<SerializedLanguageModel>,
     #[serde(default)]
     pub completion_mode: Option<CompletionMode>,
+    #[serde(default)]
+    pub tool_use_limit_reached: bool,
+    #[serde(default)]
+    pub profile: Option<AgentProfileId>,
 }
 
-#[derive(Serialize, Deserialize, Debug)]
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
 pub struct SerializedLanguageModel {
     pub provider: String,
     pub model: String,
@@ -680,20 +652,14 @@ impl SerializedThread {
                 SerializedThread::VERSION => Ok(serde_json::from_value::<SerializedThread>(
                     saved_thread_json,
                 )?),
-                _ => Err(anyhow!(
-                    "unrecognized serialized thread version: {}",
-                    version
-                )),
+                _ => anyhow::bail!("unrecognized serialized thread version: {version:?}"),
             },
             None => {
                 let saved_thread =
                     serde_json::from_value::<LegacySerializedThread>(saved_thread_json)?;
                 Ok(saved_thread.upgrade())
             }
-            version => Err(anyhow!(
-                "unrecognized serialized thread version: {:?}",
-                version
-            )),
+            version => anyhow::bail!("unrecognized serialized thread version: {version:?}"),
         }
     }
 }
@@ -726,11 +692,15 @@ impl SerializedThreadV0_1_0 {
             messages.push(message);
         }
 
-        SerializedThread { messages, ..self.0 }
+        SerializedThread {
+            messages,
+            version: SerializedThread::VERSION.to_string(),
+            ..self.0
+        }
     }
 }
 
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
 pub struct SerializedMessage {
     pub id: MessageId,
     pub role: Role,
@@ -744,9 +714,11 @@ pub struct SerializedMessage {
     pub context: String,
     #[serde(default)]
     pub creases: Vec<SerializedCrease>,
+    #[serde(default)]
+    pub is_hidden: bool,
 }
 
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
 #[serde(tag = "type")]
 pub enum SerializedMessageSegment {
     #[serde(rename = "text")]
@@ -760,22 +732,22 @@ pub enum SerializedMessageSegment {
         signature: Option<String>,
     },
     RedactedThinking {
-        data: Vec<u8>,
+        data: String,
     },
 }
 
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
 pub struct SerializedToolUse {
     pub id: LanguageModelToolUseId,
     pub name: SharedString,
     pub input: serde_json::Value,
 }
 
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
 pub struct SerializedToolResult {
     pub tool_use_id: LanguageModelToolUseId,
     pub is_error: bool,
-    pub content: Arc<str>,
+    pub content: LanguageModelToolResultContent,
     pub output: Option<serde_json::Value>,
 }
 
@@ -802,6 +774,8 @@ impl LegacySerializedThread {
             exceeded_window_error: None,
             model: None,
             completion_mode: None,
+            tool_use_limit_reached: false,
+            profile: None,
         }
     }
 }
@@ -827,11 +801,12 @@ impl LegacySerializedMessage {
             tool_results: self.tool_results,
             context: String::new(),
             creases: Vec::new(),
+            is_hidden: false,
         }
     }
 }
 
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
 pub struct SerializedCrease {
     pub start: usize,
     pub end: usize,
@@ -847,25 +822,27 @@ impl Global for GlobalThreadsDatabase {}
 
 pub(crate) struct ThreadsDatabase {
     executor: BackgroundExecutor,
-    env: heed::Env,
-    threads: Database<SerdeBincode<ThreadId>, SerializedThread>,
+    connection: Arc<Mutex<Connection>>,
 }
 
-impl heed::BytesEncode<'_> for SerializedThread {
-    type EItem = SerializedThread;
-
-    fn bytes_encode(item: &Self::EItem) -> Result<Cow<[u8]>, heed::BoxedError> {
-        serde_json::to_vec(item).map(Cow::Owned).map_err(Into::into)
+impl ThreadsDatabase {
+    fn connection(&self) -> Arc<Mutex<Connection>> {
+        self.connection.clone()
     }
+
+    const COMPRESSION_LEVEL: i32 = 3;
 }
 
-impl<'a> heed::BytesDecode<'a> for SerializedThread {
-    type DItem = SerializedThread;
+impl Bind for ThreadId {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        self.to_string().bind(statement, start_index)
+    }
+}
 
-    fn bytes_decode(bytes: &'a [u8]) -> Result<Self::DItem, heed::BoxedError> {
-        // We implement this type manually because we want to call `SerializedThread::from_json`,
-        // instead of the Deserialize trait implementation for `SerializedThread`.
-        SerializedThread::from_json(bytes).map_err(Into::into)
+impl Column for ThreadId {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let (id_str, next_index) = String::column(statement, start_index)?;
+        Ok((ThreadId::from(id_str.as_str()), next_index))
     }
 }
 
@@ -881,8 +858,8 @@ impl ThreadsDatabase {
         let database_future = executor
             .spawn({
                 let executor = executor.clone();
-                let database_path = paths::data_dir().join("threads/threads-db.1.mdb");
-                async move { ThreadsDatabase::new(database_path, executor) }
+                let threads_dir = paths::data_dir().join("threads");
+                async move { ThreadsDatabase::new(threads_dir, executor) }
             })
             .then(|result| future::ready(result.map(Arc::new).map_err(Arc::new)))
             .boxed()
@@ -891,41 +868,144 @@ impl ThreadsDatabase {
         cx.set_global(GlobalThreadsDatabase(database_future));
     }
 
-    pub fn new(path: PathBuf, executor: BackgroundExecutor) -> Result<Self> {
-        std::fs::create_dir_all(&path)?;
+    pub fn new(threads_dir: PathBuf, executor: BackgroundExecutor) -> Result<Self> {
+        std::fs::create_dir_all(&threads_dir)?;
+
+        let sqlite_path = threads_dir.join("threads.db");
+        let mdb_path = threads_dir.join("threads-db.1.mdb");
+
+        let needs_migration_from_heed = mdb_path.exists();
+
+        let connection = Connection::open_file(&sqlite_path.to_string_lossy());
+
+        connection.exec(indoc! {"
+                CREATE TABLE IF NOT EXISTS threads (
+                    id TEXT PRIMARY KEY,
+                    summary TEXT NOT NULL,
+                    updated_at TEXT NOT NULL,
+                    data_type TEXT NOT NULL,
+                    data BLOB NOT NULL
+                )
+            "})?()
+        .map_err(|e| anyhow!("Failed to create threads table: {}", e))?;
+
+        let db = Self {
+            executor: executor.clone(),
+            connection: Arc::new(Mutex::new(connection)),
+        };
+
+        if needs_migration_from_heed {
+            let db_connection = db.connection();
+            let executor_clone = executor.clone();
+            executor
+                .spawn(async move {
+                    log::info!("Starting threads.db migration");
+                    Self::migrate_from_heed(&mdb_path, db_connection, executor_clone)?;
+                    std::fs::remove_dir_all(mdb_path)?;
+                    log::info!("threads.db migrated to sqlite");
+                    Ok::<(), anyhow::Error>(())
+                })
+                .detach();
+        }
+
+        Ok(db)
+    }
+
+    // Remove this migration after 2025-09-01
+    fn migrate_from_heed(
+        mdb_path: &Path,
+        connection: Arc<Mutex<Connection>>,
+        _executor: BackgroundExecutor,
+    ) -> Result<()> {
+        use heed::types::SerdeBincode;
+        struct SerializedThreadHeed(SerializedThread);
+
+        impl heed::BytesEncode<'_> for SerializedThreadHeed {
+            type EItem = SerializedThreadHeed;
+
+            fn bytes_encode(
+                item: &Self::EItem,
+            ) -> Result<std::borrow::Cow<'_, [u8]>, heed::BoxedError> {
+                serde_json::to_vec(&item.0)
+                    .map(std::borrow::Cow::Owned)
+                    .map_err(Into::into)
+            }
+        }
+
+        impl<'a> heed::BytesDecode<'a> for SerializedThreadHeed {
+            type DItem = SerializedThreadHeed;
+
+            fn bytes_decode(bytes: &'a [u8]) -> Result<Self::DItem, heed::BoxedError> {
+                SerializedThread::from_json(bytes)
+                    .map(SerializedThreadHeed)
+                    .map_err(Into::into)
+            }
+        }
 
         const ONE_GB_IN_BYTES: usize = 1024 * 1024 * 1024;
+
         let env = unsafe {
             heed::EnvOpenOptions::new()
                 .map_size(ONE_GB_IN_BYTES)
                 .max_dbs(1)
-                .open(path)?
+                .open(mdb_path)?
         };
 
-        let mut txn = env.write_txn()?;
-        let threads = env.create_database(&mut txn, Some("threads"))?;
-        txn.commit()?;
+        let txn = env.write_txn()?;
+        let threads: heed::Database<SerdeBincode<ThreadId>, SerializedThreadHeed> = env
+            .open_database(&txn, Some("threads"))?
+            .ok_or_else(|| anyhow!("threads database not found"))?;
 
-        Ok(Self {
-            executor,
-            env,
-            threads,
-        })
+        for result in threads.iter(&txn)? {
+            let (thread_id, thread_heed) = result?;
+            Self::save_thread_sync(&connection, thread_id, thread_heed.0)?;
+        }
+
+        Ok(())
+    }
+
+    fn save_thread_sync(
+        connection: &Arc<Mutex<Connection>>,
+        id: ThreadId,
+        thread: SerializedThread,
+    ) -> Result<()> {
+        let json_data = serde_json::to_string(&thread)?;
+        let summary = thread.summary.to_string();
+        let updated_at = thread.updated_at.to_rfc3339();
+
+        let connection = connection.lock().unwrap();
+
+        let compressed = zstd::encode_all(json_data.as_bytes(), Self::COMPRESSION_LEVEL)?;
+        let data_type = DataType::Zstd;
+        let data = compressed;
+
+        let mut insert = connection.exec_bound::<(ThreadId, String, String, DataType, Vec<u8>)>(indoc! {"
+            INSERT OR REPLACE INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?)
+        "})?;
+
+        insert((id, summary, updated_at, data_type, data))?;
+
+        Ok(())
     }
 
     pub fn list_threads(&self) -> Task<Result<Vec<SerializedThreadMetadata>>> {
-        let env = self.env.clone();
-        let threads = self.threads;
+        let connection = self.connection.clone();
 
         self.executor.spawn(async move {
-            let txn = env.read_txn()?;
-            let mut iter = threads.iter(&txn)?;
+            let connection = connection.lock().unwrap();
+            let mut select =
+                connection.select_bound::<(), (ThreadId, String, String)>(indoc! {"
+                SELECT id, summary, updated_at FROM threads ORDER BY updated_at DESC
+            "})?;
+
+            let rows = select(())?;
             let mut threads = Vec::new();
-            while let Some((key, value)) = iter.next().transpose()? {
+
+            for (id, summary, updated_at) in rows {
                 threads.push(SerializedThreadMetadata {
-                    id: key,
-                    summary: value.summary,
-                    updated_at: value.updated_at,
+                    id,
+                    summary: summary.into(),
+                    updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc),
                 });
             }
 
@@ -934,37 +1014,230 @@ impl ThreadsDatabase {
     }
 
     pub fn try_find_thread(&self, id: ThreadId) -> Task<Result<Option<SerializedThread>>> {
-        let env = self.env.clone();
-        let threads = self.threads;
+        let connection = self.connection.clone();
 
         self.executor.spawn(async move {
-            let txn = env.read_txn()?;
-            let thread = threads.get(&txn, &id)?;
-            Ok(thread)
+            let connection = connection.lock().unwrap();
+            let mut select = connection.select_bound::<ThreadId, (DataType, Vec<u8>)>(indoc! {"
+                SELECT data_type, data FROM threads WHERE id = ? LIMIT 1
+            "})?;
+
+            let rows = select(id)?;
+            if let Some((data_type, data)) = rows.into_iter().next() {
+                let json_data = match data_type {
+                    DataType::Zstd => {
+                        let decompressed = zstd::decode_all(&data[..])?;
+                        String::from_utf8(decompressed)?
+                    }
+                    DataType::Json => String::from_utf8(data)?,
+                };
+
+                let thread = SerializedThread::from_json(json_data.as_bytes())?;
+                Ok(Some(thread))
+            } else {
+                Ok(None)
+            }
         })
     }
 
     pub fn save_thread(&self, id: ThreadId, thread: SerializedThread) -> Task<Result<()>> {
-        let env = self.env.clone();
-        let threads = self.threads;
+        let connection = self.connection.clone();
 
-        self.executor.spawn(async move {
-            let mut txn = env.write_txn()?;
-            threads.put(&mut txn, &id, &thread)?;
-            txn.commit()?;
-            Ok(())
-        })
+        self.executor
+            .spawn(async move { Self::save_thread_sync(&connection, id, thread) })
     }
 
     pub fn delete_thread(&self, id: ThreadId) -> Task<Result<()>> {
-        let env = self.env.clone();
-        let threads = self.threads;
+        let connection = self.connection.clone();
 
         self.executor.spawn(async move {
-            let mut txn = env.write_txn()?;
-            threads.delete(&mut txn, &id)?;
-            txn.commit()?;
+            let connection = connection.lock().unwrap();
+
+            let mut delete = connection.exec_bound::<ThreadId>(indoc! {"
+                DELETE FROM threads WHERE id = ?
+            "})?;
+
+            delete(id)?;
+
             Ok(())
         })
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::thread::{DetailedSummaryState, MessageId};
+    use chrono::Utc;
+    use language_model::{Role, TokenUsage};
+    use pretty_assertions::assert_eq;
+
+    #[test]
+    fn test_legacy_serialized_thread_upgrade() {
+        let updated_at = Utc::now();
+        let legacy_thread = LegacySerializedThread {
+            summary: "Test conversation".into(),
+            updated_at,
+            messages: vec![LegacySerializedMessage {
+                id: MessageId(1),
+                role: Role::User,
+                text: "Hello, world!".to_string(),
+                tool_uses: vec![],
+                tool_results: vec![],
+            }],
+            initial_project_snapshot: None,
+        };
+
+        let upgraded = legacy_thread.upgrade();
+
+        assert_eq!(
+            upgraded,
+            SerializedThread {
+                summary: "Test conversation".into(),
+                updated_at,
+                messages: vec![SerializedMessage {
+                    id: MessageId(1),
+                    role: Role::User,
+                    segments: vec![SerializedMessageSegment::Text {
+                        text: "Hello, world!".to_string()
+                    }],
+                    tool_uses: vec![],
+                    tool_results: vec![],
+                    context: "".to_string(),
+                    creases: vec![],
+                    is_hidden: false
+                }],
+                version: SerializedThread::VERSION.to_string(),
+                initial_project_snapshot: None,
+                cumulative_token_usage: TokenUsage::default(),
+                request_token_usage: vec![],
+                detailed_summary_state: DetailedSummaryState::default(),
+                exceeded_window_error: None,
+                model: None,
+                completion_mode: None,
+                tool_use_limit_reached: false,
+                profile: None
+            }
+        )
+    }
+
+    #[test]
+    fn test_serialized_threadv0_1_0_upgrade() {
+        let updated_at = Utc::now();
+        let thread_v0_1_0 = SerializedThreadV0_1_0(SerializedThread {
+            summary: "Test conversation".into(),
+            updated_at,
+            messages: vec![
+                SerializedMessage {
+                    id: MessageId(1),
+                    role: Role::User,
+                    segments: vec![SerializedMessageSegment::Text {
+                        text: "Use tool_1".to_string(),
+                    }],
+                    tool_uses: vec![],
+                    tool_results: vec![],
+                    context: "".to_string(),
+                    creases: vec![],
+                    is_hidden: false,
+                },
+                SerializedMessage {
+                    id: MessageId(2),
+                    role: Role::Assistant,
+                    segments: vec![SerializedMessageSegment::Text {
+                        text: "I want to use a tool".to_string(),
+                    }],
+                    tool_uses: vec![SerializedToolUse {
+                        id: "abc".into(),
+                        name: "tool_1".into(),
+                        input: serde_json::Value::Null,
+                    }],
+                    tool_results: vec![],
+                    context: "".to_string(),
+                    creases: vec![],
+                    is_hidden: false,
+                },
+                SerializedMessage {
+                    id: MessageId(1),
+                    role: Role::User,
+                    segments: vec![SerializedMessageSegment::Text {
+                        text: "Here is the tool result".to_string(),
+                    }],
+                    tool_uses: vec![],
+                    tool_results: vec![SerializedToolResult {
+                        tool_use_id: "abc".into(),
+                        is_error: false,
+                        content: LanguageModelToolResultContent::Text("abcdef".into()),
+                        output: Some(serde_json::Value::Null),
+                    }],
+                    context: "".to_string(),
+                    creases: vec![],
+                    is_hidden: false,
+                },
+            ],
+            version: SerializedThreadV0_1_0::VERSION.to_string(),
+            initial_project_snapshot: None,
+            cumulative_token_usage: TokenUsage::default(),
+            request_token_usage: vec![],
+            detailed_summary_state: DetailedSummaryState::default(),
+            exceeded_window_error: None,
+            model: None,
+            completion_mode: None,
+            tool_use_limit_reached: false,
+            profile: None,
+        });
+        let upgraded = thread_v0_1_0.upgrade();
+
+        assert_eq!(
+            upgraded,
+            SerializedThread {
+                summary: "Test conversation".into(),
+                updated_at,
+                messages: vec![
+                    SerializedMessage {
+                        id: MessageId(1),
+                        role: Role::User,
+                        segments: vec![SerializedMessageSegment::Text {
+                            text: "Use tool_1".to_string()
+                        }],
+                        tool_uses: vec![],
+                        tool_results: vec![],
+                        context: "".to_string(),
+                        creases: vec![],
+                        is_hidden: false
+                    },
+                    SerializedMessage {
+                        id: MessageId(2),
+                        role: Role::Assistant,
+                        segments: vec![SerializedMessageSegment::Text {
+                            text: "I want to use a tool".to_string(),
+                        }],
+                        tool_uses: vec![SerializedToolUse {
+                            id: "abc".into(),
+                            name: "tool_1".into(),
+                            input: serde_json::Value::Null,
+                        }],
+                        tool_results: vec![SerializedToolResult {
+                            tool_use_id: "abc".into(),
+                            is_error: false,
+                            content: LanguageModelToolResultContent::Text("abcdef".into()),
+                            output: Some(serde_json::Value::Null),
+                        }],
+                        context: "".to_string(),
+                        creases: vec![],
+                        is_hidden: false,
+                    },
+                ],
+                version: SerializedThread::VERSION.to_string(),
+                initial_project_snapshot: None,
+                cumulative_token_usage: TokenUsage::default(),
+                request_token_usage: vec![],
+                detailed_summary_state: DetailedSummaryState::default(),
+                exceeded_window_error: None,
+                model: None,
+                completion_mode: None,
+                tool_use_limit_reached: false,
+                profile: None
+            }
+        )
+    }
+}

crates/agent/src/tool_use.rs 🔗

@@ -1,22 +1,23 @@
-use std::sync::Arc;
-
+use crate::{
+    thread::{MessageId, PromptId, ThreadId},
+    thread_store::SerializedMessage,
+};
 use anyhow::Result;
-use assistant_tool::{AnyToolCard, Tool, ToolResultOutput, ToolUseStatus, ToolWorkingSet};
+use assistant_tool::{
+    AnyToolCard, Tool, ToolResultContent, ToolResultOutput, ToolUseStatus, ToolWorkingSet,
+};
 use collections::HashMap;
-use futures::FutureExt as _;
-use futures::future::Shared;
-use gpui::{App, Entity, SharedString, Task};
+use futures::{FutureExt as _, future::Shared};
+use gpui::{App, Entity, SharedString, Task, Window};
+use icons::IconName;
 use language_model::{
     ConfiguredModel, LanguageModel, LanguageModelRequest, LanguageModelToolResult,
-    LanguageModelToolUse, LanguageModelToolUseId, Role,
+    LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseId, Role,
 };
 use project::Project;
-use ui::{IconName, Window};
+use std::sync::Arc;
 use util::truncate_lines_to_byte_limit;
 
-use crate::thread::{MessageId, PromptId, ThreadId};
-use crate::thread_store::SerializedMessage;
-
 #[derive(Debug)]
 pub struct ToolUse {
     pub id: LanguageModelToolUseId,
@@ -24,7 +25,7 @@ pub struct ToolUse {
     pub ui_text: SharedString,
     pub status: ToolUseStatus,
     pub input: serde_json::Value,
-    pub icon: ui::IconName,
+    pub icon: icons::IconName,
     pub needs_confirmation: bool,
 }
 
@@ -52,15 +53,19 @@ impl ToolUseState {
     /// Constructs a [`ToolUseState`] from the given list of [`SerializedMessage`]s.
     ///
     /// Accepts a function to filter the tools that should be used to populate the state.
+    ///
+    /// If `window` is `None` (e.g., when in headless mode or when running evals),
+    /// tool cards won't be deserialized
     pub fn from_serialized_messages(
         tools: Entity<ToolWorkingSet>,
         messages: &[SerializedMessage],
         project: Entity<Project>,
-        window: &mut Window,
+        window: Option<&mut Window>, // None in headless mode
         cx: &mut App,
     ) -> Self {
         let mut this = Self::new(tools);
         let mut tool_names_by_id = HashMap::default();
+        let mut window = window;
 
         for message in messages {
             match message.role {
@@ -105,12 +110,17 @@ impl ToolUseState {
                                 },
                             );
 
-                            if let Some(tool) = this.tools.read(cx).tool(tool_use, cx) {
-                                if let Some(output) = tool_result.output.clone() {
-                                    if let Some(card) =
-                                        tool.deserialize_card(output, project.clone(), window, cx)
-                                    {
-                                        this.tool_result_cards.insert(tool_use_id, card);
+                            if let Some(window) = &mut window {
+                                if let Some(tool) = this.tools.read(cx).tool(tool_use, cx) {
+                                    if let Some(output) = tool_result.output.clone() {
+                                        if let Some(card) = tool.deserialize_card(
+                                            output,
+                                            project.clone(),
+                                            window,
+                                            cx,
+                                        ) {
+                                            this.tool_result_cards.insert(tool_use_id, card);
+                                        }
                                     }
                                 }
                             }
@@ -165,10 +175,16 @@ impl ToolUseState {
 
             let status = (|| {
                 if let Some(tool_result) = tool_result {
+                    let content = tool_result
+                        .content
+                        .to_str()
+                        .map(|str| str.to_owned().into())
+                        .unwrap_or_default();
+
                     return if tool_result.is_error {
-                        ToolUseStatus::Error(tool_result.content.clone().into())
+                        ToolUseStatus::Error(content)
                     } else {
-                        ToolUseStatus::Finished(tool_result.content.clone().into())
+                        ToolUseStatus::Finished(content)
                     };
                 }
 
@@ -320,6 +336,12 @@ impl ToolUseState {
             )
             .into();
 
+        let may_perform_edits = self
+            .tools
+            .read(cx)
+            .tool(&tool_use.name, cx)
+            .is_some_and(|tool| tool.may_perform_edits());
+
         self.pending_tool_uses_by_id.insert(
             tool_use.id.clone(),
             PendingToolUse {
@@ -328,6 +350,7 @@ impl ToolUseState {
                 name: tool_use.name.clone(),
                 ui_text: ui_text.clone(),
                 input: tool_use.input,
+                may_perform_edits,
                 status,
             },
         );
@@ -399,21 +422,45 @@ impl ToolUseState {
                 let tool_result = output.content;
                 const BYTES_PER_TOKEN_ESTIMATE: usize = 3;
 
-                // Protect from clearly large output
+                let old_use = self.pending_tool_uses_by_id.remove(&tool_use_id);
+
+                // Protect from overly large output
                 let tool_output_limit = configured_model
-                    .map(|model| model.model.max_token_count() * BYTES_PER_TOKEN_ESTIMATE)
+                    .map(|model| model.model.max_token_count() as usize * BYTES_PER_TOKEN_ESTIMATE)
                     .unwrap_or(usize::MAX);
 
-                let tool_result = if tool_result.len() <= tool_output_limit {
-                    tool_result
-                } else {
-                    let truncated = truncate_lines_to_byte_limit(&tool_result, tool_output_limit);
+                let content = match tool_result {
+                    ToolResultContent::Text(text) => {
+                        let text = if text.len() < tool_output_limit {
+                            text
+                        } else {
+                            let truncated = truncate_lines_to_byte_limit(&text, tool_output_limit);
+                            format!(
+                                "Tool result too long. The first {} bytes:\n\n{}",
+                                truncated.len(),
+                                truncated
+                            )
+                        };
+                        LanguageModelToolResultContent::Text(text.into())
+                    }
+                    ToolResultContent::Image(language_model_image) => {
+                        if language_model_image.estimate_tokens() < tool_output_limit {
+                            LanguageModelToolResultContent::Image(language_model_image)
+                        } else {
+                            self.tool_results.insert(
+                                tool_use_id.clone(),
+                                LanguageModelToolResult {
+                                    tool_use_id: tool_use_id.clone(),
+                                    tool_name,
+                                    content: "Tool responded with an image that would exceeded the remaining tokens".into(),
+                                    is_error: true,
+                                    output: None,
+                                },
+                            );
 
-                    format!(
-                        "Tool result too long. The first {} bytes:\n\n{}",
-                        truncated.len(),
-                        truncated
-                    )
+                            return old_use;
+                        }
+                    }
                 };
 
                 self.tool_results.insert(
@@ -421,12 +468,13 @@ impl ToolUseState {
                     LanguageModelToolResult {
                         tool_use_id: tool_use_id.clone(),
                         tool_name,
-                        content: tool_result.into(),
+                        content,
                         is_error: false,
                         output: output.output,
                     },
                 );
-                self.pending_tool_uses_by_id.remove(&tool_use_id)
+
+                old_use
             }
             Err(err) => {
                 self.tool_results.insert(
@@ -434,7 +482,7 @@ impl ToolUseState {
                     LanguageModelToolResult {
                         tool_use_id: tool_use_id.clone(),
                         tool_name,
-                        content: err.to_string().into(),
+                        content: LanguageModelToolResultContent::Text(err.to_string().into()),
                         is_error: true,
                         output: None,
                     },
@@ -476,6 +524,7 @@ pub struct PendingToolUse {
     pub ui_text: Arc<str>,
     pub input: serde_json::Value,
     pub status: PendingToolUseStatus,
+    pub may_perform_edits: bool,
 }
 
 #[derive(Debug, Clone)]

crates/agent/src/trial_markdown.md 🔗

@@ -1,3 +0,0 @@
-# Build better with Zed Pro
-
-Try [Zed Pro](https://zed.dev/pricing) for free for 14 days - no credit card required. Only $20/month afterward. Cancel anytime.

crates/agent/src/ui/max_mode_tooltip.rs 🔗

@@ -1,59 +0,0 @@
-use gpui::{Context, IntoElement, Render, Window};
-use ui::{prelude::*, tooltip_container};
-
-pub struct MaxModeTooltip {
-    selected: bool,
-}
-
-impl MaxModeTooltip {
-    pub fn new() -> Self {
-        Self { selected: false }
-    }
-
-    pub fn selected(mut self, selected: bool) -> Self {
-        self.selected = selected;
-        self
-    }
-}
-
-impl Render for MaxModeTooltip {
-    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        tooltip_container(window, cx, |this, _, _| {
-            this.gap_1()
-                .map(|header| if self.selected {
-                    header.child(
-                        h_flex()
-                            .justify_between()
-                            .child(
-                                h_flex()
-                                    .gap_1p5()
-                                    .child(Icon::new(IconName::ZedMaxMode).size(IconSize::Small).color(Color::Accent))
-                                    .child(Label::new("Zed's Max Mode"))
-                            )
-                            .child(
-                                h_flex()
-                                    .gap_0p5()
-                                    .child(Icon::new(IconName::Check).size(IconSize::XSmall).color(Color::Accent))
-                                    .child(Label::new("Turned On").size(LabelSize::XSmall).color(Color::Accent))
-                            )
-                    )
-                } else {
-                    header.child(
-                        h_flex()
-                            .gap_1p5()
-                            .child(Icon::new(IconName::ZedMaxMode).size(IconSize::Small))
-                            .child(Label::new("Zed's Max Mode"))
-                    )
-                })
-                .child(
-                    div()
-                        .max_w_72()
-                        .child(
-                            Label::new("This mode enables models to use large context windows, unlimited tool calls, and other capabilities for expanded reasoning, offering an unfettered agentic experience.")
-                                .size(LabelSize::Small)
-                                .color(Color::Muted)
-                        )
-                )
-        })
-    }
-}

crates/assistant_settings/Cargo.toml → crates/agent_settings/Cargo.toml 🔗

@@ -1,5 +1,5 @@
 [package]
-name = "assistant_settings"
+name = "agent_settings"
 version = "0.1.0"
 edition.workspace = true
 publish.workspace = true
@@ -9,20 +9,13 @@ license = "GPL-3.0-or-later"
 workspace = true
 
 [lib]
-path = "src/assistant_settings.rs"
+path = "src/agent_settings.rs"
 
 [dependencies]
-anthropic = { workspace = true, features = ["schemars"] }
 anyhow.workspace = true
 collections.workspace = true
 gpui.workspace = true
-indexmap.workspace = true
 language_model.workspace = true
-lmstudio = { workspace = true, features = ["schemars"] }
-log.workspace = true
-ollama = { workspace = true, features = ["schemars"] }
-open_ai = { workspace = true, features = ["schemars"] }
-deepseek = { workspace = true, features = ["schemars"] }
 schemars.workspace = true
 serde.workspace = true
 settings.workspace = true

crates/assistant_settings/src/agent_profile.rs → crates/agent_settings/src/agent_profile.rs 🔗

@@ -17,29 +17,6 @@ pub mod builtin_profiles {
     }
 }
 
-#[derive(Default)]
-pub struct GroupedAgentProfiles {
-    pub builtin: IndexMap<AgentProfileId, AgentProfile>,
-    pub custom: IndexMap<AgentProfileId, AgentProfile>,
-}
-
-impl GroupedAgentProfiles {
-    pub fn from_settings(settings: &crate::AssistantSettings) -> Self {
-        let mut builtin = IndexMap::default();
-        let mut custom = IndexMap::default();
-
-        for (profile_id, profile) in settings.profiles.clone() {
-            if builtin_profiles::is_builtin(&profile_id) {
-                builtin.insert(profile_id, profile);
-            } else {
-                custom.insert(profile_id, profile);
-            }
-        }
-
-        Self { builtin, custom }
-    }
-}
-
 #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)]
 pub struct AgentProfileId(pub Arc<str>);
 
@@ -63,7 +40,7 @@ impl Default for AgentProfileId {
 
 /// A profile for the Zed Agent that controls its behavior.
 #[derive(Debug, Clone)]
-pub struct AgentProfile {
+pub struct AgentProfileSettings {
     /// The name of the profile.
     pub name: SharedString,
     pub tools: IndexMap<Arc<str>, bool>,

crates/agent_settings/src/agent_settings.rs 🔗

@@ -0,0 +1,505 @@
+mod agent_profile;
+
+use std::sync::Arc;
+
+use anyhow::{Result, bail};
+use collections::IndexMap;
+use gpui::{App, Pixels, SharedString};
+use language_model::LanguageModel;
+use schemars::{JsonSchema, schema::Schema};
+use serde::{Deserialize, Serialize};
+use settings::{Settings, SettingsSources};
+
+pub use crate::agent_profile::*;
+
+pub fn init(cx: &mut App) {
+    AgentSettings::register(cx);
+}
+
+#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum AgentDockPosition {
+    Left,
+    #[default]
+    Right,
+    Bottom,
+}
+
+#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum DefaultView {
+    #[default]
+    Thread,
+    TextThread,
+}
+
+#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum NotifyWhenAgentWaiting {
+    #[default]
+    PrimaryScreen,
+    AllScreens,
+    Never,
+}
+
+#[derive(Default, Clone, Debug)]
+pub struct AgentSettings {
+    pub enabled: bool,
+    pub button: bool,
+    pub dock: AgentDockPosition,
+    pub default_width: Pixels,
+    pub default_height: Pixels,
+    pub default_model: LanguageModelSelection,
+    pub inline_assistant_model: Option<LanguageModelSelection>,
+    pub commit_message_model: Option<LanguageModelSelection>,
+    pub thread_summary_model: Option<LanguageModelSelection>,
+    pub inline_alternatives: Vec<LanguageModelSelection>,
+    pub using_outdated_settings_version: bool,
+    pub default_profile: AgentProfileId,
+    pub default_view: DefaultView,
+    pub profiles: IndexMap<AgentProfileId, AgentProfileSettings>,
+    pub always_allow_tool_actions: bool,
+    pub notify_when_agent_waiting: NotifyWhenAgentWaiting,
+    pub play_sound_when_agent_done: bool,
+    pub stream_edits: bool,
+    pub single_file_review: bool,
+    pub model_parameters: Vec<LanguageModelParameters>,
+    pub preferred_completion_mode: CompletionMode,
+    pub enable_feedback: bool,
+}
+
+impl AgentSettings {
+    pub fn temperature_for_model(model: &Arc<dyn LanguageModel>, cx: &App) -> Option<f32> {
+        let settings = Self::get_global(cx);
+        settings
+            .model_parameters
+            .iter()
+            .rfind(|setting| setting.matches(model))
+            .and_then(|m| m.temperature)
+    }
+
+    pub fn set_inline_assistant_model(&mut self, provider: String, model: String) {
+        self.inline_assistant_model = Some(LanguageModelSelection {
+            provider: provider.into(),
+            model,
+        });
+    }
+
+    pub fn set_commit_message_model(&mut self, provider: String, model: String) {
+        self.commit_message_model = Some(LanguageModelSelection {
+            provider: provider.into(),
+            model,
+        });
+    }
+
+    pub fn set_thread_summary_model(&mut self, provider: String, model: String) {
+        self.thread_summary_model = Some(LanguageModelSelection {
+            provider: provider.into(),
+            model,
+        });
+    }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
+pub struct LanguageModelParameters {
+    pub provider: Option<LanguageModelProviderSetting>,
+    pub model: Option<SharedString>,
+    pub temperature: Option<f32>,
+}
+
+impl LanguageModelParameters {
+    pub fn matches(&self, model: &Arc<dyn LanguageModel>) -> bool {
+        if let Some(provider) = &self.provider {
+            if provider.0 != model.provider_id().0 {
+                return false;
+            }
+        }
+        if let Some(setting_model) = &self.model {
+            if *setting_model != model.id().0 {
+                return false;
+            }
+        }
+        true
+    }
+}
+
+impl AgentSettingsContent {
+    pub fn set_dock(&mut self, dock: AgentDockPosition) {
+        self.dock = Some(dock);
+    }
+
+    pub fn set_model(&mut self, language_model: Arc<dyn LanguageModel>) {
+        let model = language_model.id().0.to_string();
+        let provider = language_model.provider_id().0.to_string();
+
+        self.default_model = Some(LanguageModelSelection {
+            provider: provider.into(),
+            model,
+        });
+    }
+
+    pub fn set_inline_assistant_model(&mut self, provider: String, model: String) {
+        self.inline_assistant_model = Some(LanguageModelSelection {
+            provider: provider.into(),
+            model,
+        });
+    }
+
+    pub fn set_commit_message_model(&mut self, provider: String, model: String) {
+        self.commit_message_model = Some(LanguageModelSelection {
+            provider: provider.into(),
+            model,
+        });
+    }
+
+    pub fn set_thread_summary_model(&mut self, provider: String, model: String) {
+        self.thread_summary_model = Some(LanguageModelSelection {
+            provider: provider.into(),
+            model,
+        });
+    }
+
+    pub fn set_always_allow_tool_actions(&mut self, allow: bool) {
+        self.always_allow_tool_actions = Some(allow);
+    }
+
+    pub fn set_play_sound_when_agent_done(&mut self, allow: bool) {
+        self.play_sound_when_agent_done = Some(allow);
+    }
+
+    pub fn set_single_file_review(&mut self, allow: bool) {
+        self.single_file_review = Some(allow);
+    }
+
+    pub fn set_profile(&mut self, profile_id: AgentProfileId) {
+        self.default_profile = Some(profile_id);
+    }
+
+    pub fn create_profile(
+        &mut self,
+        profile_id: AgentProfileId,
+        profile_settings: AgentProfileSettings,
+    ) -> Result<()> {
+        let profiles = self.profiles.get_or_insert_default();
+        if profiles.contains_key(&profile_id) {
+            bail!("profile with ID '{profile_id}' already exists");
+        }
+
+        profiles.insert(
+            profile_id,
+            AgentProfileContent {
+                name: profile_settings.name.into(),
+                tools: profile_settings.tools,
+                enable_all_context_servers: Some(profile_settings.enable_all_context_servers),
+                context_servers: profile_settings
+                    .context_servers
+                    .into_iter()
+                    .map(|(server_id, preset)| {
+                        (
+                            server_id,
+                            ContextServerPresetContent {
+                                tools: preset.tools,
+                            },
+                        )
+                    })
+                    .collect(),
+            },
+        );
+
+        Ok(())
+    }
+}
+
+#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug, Default)]
+#[schemars(deny_unknown_fields)]
+pub struct AgentSettingsContent {
+    /// Whether the Agent is enabled.
+    ///
+    /// Default: true
+    enabled: Option<bool>,
+    /// Whether to show the agent panel button in the status bar.
+    ///
+    /// Default: true
+    button: Option<bool>,
+    /// Where to dock the agent panel.
+    ///
+    /// Default: right
+    dock: Option<AgentDockPosition>,
+    /// Default width in pixels when the agent panel is docked to the left or right.
+    ///
+    /// Default: 640
+    default_width: Option<f32>,
+    /// Default height in pixels when the agent panel is docked to the bottom.
+    ///
+    /// Default: 320
+    default_height: Option<f32>,
+    /// The default model to use when creating new chats and for other features when a specific model is not specified.
+    default_model: Option<LanguageModelSelection>,
+    /// Model to use for the inline assistant. Defaults to default_model when not specified.
+    inline_assistant_model: Option<LanguageModelSelection>,
+    /// Model to use for generating git commit messages. Defaults to default_model when not specified.
+    commit_message_model: Option<LanguageModelSelection>,
+    /// Model to use for generating thread summaries. Defaults to default_model when not specified.
+    thread_summary_model: Option<LanguageModelSelection>,
+    /// Additional models with which to generate alternatives when performing inline assists.
+    inline_alternatives: Option<Vec<LanguageModelSelection>>,
+    /// The default profile to use in the Agent.
+    ///
+    /// Default: write
+    default_profile: Option<AgentProfileId>,
+    /// Which view type to show by default in the agent panel.
+    ///
+    /// Default: "thread"
+    default_view: Option<DefaultView>,
+    /// The available agent profiles.
+    pub profiles: Option<IndexMap<AgentProfileId, AgentProfileContent>>,
+    /// Whenever a tool action would normally wait for your confirmation
+    /// that you allow it, always choose to allow it.
+    ///
+    /// Default: false
+    always_allow_tool_actions: Option<bool>,
+    /// Where to show a popup notification when the agent is waiting for user input.
+    ///
+    /// Default: "primary_screen"
+    notify_when_agent_waiting: Option<NotifyWhenAgentWaiting>,
+    /// Whether to play a sound when the agent has either completed its response, or needs user input.
+    ///
+    /// Default: false
+    play_sound_when_agent_done: Option<bool>,
+    /// Whether to stream edits from the agent as they are received.
+    ///
+    /// Default: false
+    stream_edits: Option<bool>,
+    /// Whether to display agent edits in single-file editors in addition to the review multibuffer pane.
+    ///
+    /// Default: true
+    single_file_review: Option<bool>,
+    /// Additional parameters for language model requests. When making a request
+    /// to a model, parameters will be taken from the last entry in this list
+    /// that matches the model's provider and name. In each entry, both provider
+    /// and model are optional, so that you can specify parameters for either
+    /// one.
+    ///
+    /// Default: []
+    #[serde(default)]
+    model_parameters: Vec<LanguageModelParameters>,
+    /// What completion mode to enable for new threads
+    ///
+    /// Default: normal
+    preferred_completion_mode: Option<CompletionMode>,
+    /// Whether to show thumb buttons for feedback in the agent panel.
+    ///
+    /// Default: true
+    enable_feedback: Option<bool>,
+}
+
+#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum CompletionMode {
+    #[default]
+    Normal,
+    #[serde(alias = "max")]
+    Burn,
+}
+
+impl From<CompletionMode> for zed_llm_client::CompletionMode {
+    fn from(value: CompletionMode) -> Self {
+        match value {
+            CompletionMode::Normal => zed_llm_client::CompletionMode::Normal,
+            CompletionMode::Burn => zed_llm_client::CompletionMode::Max,
+        }
+    }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
+pub struct LanguageModelSelection {
+    pub provider: LanguageModelProviderSetting,
+    pub model: String,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
+pub struct LanguageModelProviderSetting(pub String);
+
+impl JsonSchema for LanguageModelProviderSetting {
+    fn schema_name() -> String {
+        "LanguageModelProviderSetting".into()
+    }
+
+    fn json_schema(_: &mut schemars::r#gen::SchemaGenerator) -> Schema {
+        schemars::schema::SchemaObject {
+            enum_values: Some(vec![
+                "anthropic".into(),
+                "amazon-bedrock".into(),
+                "google".into(),
+                "lmstudio".into(),
+                "ollama".into(),
+                "openai".into(),
+                "zed.dev".into(),
+                "copilot_chat".into(),
+                "deepseek".into(),
+                "openrouter".into(),
+                "mistral".into(),
+                "vercel".into(),
+            ]),
+            ..Default::default()
+        }
+        .into()
+    }
+}
+
+impl From<String> for LanguageModelProviderSetting {
+    fn from(provider: String) -> Self {
+        Self(provider)
+    }
+}
+
+impl From<&str> for LanguageModelProviderSetting {
+    fn from(provider: &str) -> Self {
+        Self(provider.to_string())
+    }
+}
+
+impl Default for LanguageModelSelection {
+    fn default() -> Self {
+        Self {
+            provider: LanguageModelProviderSetting("openai".to_string()),
+            model: "gpt-4".to_string(),
+        }
+    }
+}
+
+#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct AgentProfileContent {
+    pub name: Arc<str>,
+    #[serde(default)]
+    pub tools: IndexMap<Arc<str>, bool>,
+    /// Whether all context servers are enabled by default.
+    pub enable_all_context_servers: Option<bool>,
+    #[serde(default)]
+    pub context_servers: IndexMap<Arc<str>, ContextServerPresetContent>,
+}
+
+#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize, JsonSchema)]
+pub struct ContextServerPresetContent {
+    pub tools: IndexMap<Arc<str>, bool>,
+}
+
+impl Settings for AgentSettings {
+    const KEY: Option<&'static str> = Some("agent");
+
+    const FALLBACK_KEY: Option<&'static str> = Some("assistant");
+
+    const PRESERVED_KEYS: Option<&'static [&'static str]> = Some(&["version"]);
+
+    type FileContent = AgentSettingsContent;
+
+    fn load(
+        sources: SettingsSources<Self::FileContent>,
+        _: &mut gpui::App,
+    ) -> anyhow::Result<Self> {
+        let mut settings = AgentSettings::default();
+
+        for value in sources.defaults_and_customizations() {
+            merge(&mut settings.enabled, value.enabled);
+            merge(&mut settings.button, value.button);
+            merge(&mut settings.dock, value.dock);
+            merge(
+                &mut settings.default_width,
+                value.default_width.map(Into::into),
+            );
+            merge(
+                &mut settings.default_height,
+                value.default_height.map(Into::into),
+            );
+            merge(&mut settings.default_model, value.default_model.clone());
+            settings.inline_assistant_model = value
+                .inline_assistant_model
+                .clone()
+                .or(settings.inline_assistant_model.take());
+            settings.commit_message_model = value
+                .clone()
+                .commit_message_model
+                .or(settings.commit_message_model.take());
+            settings.thread_summary_model = value
+                .clone()
+                .thread_summary_model
+                .or(settings.thread_summary_model.take());
+            merge(
+                &mut settings.inline_alternatives,
+                value.inline_alternatives.clone(),
+            );
+            merge(
+                &mut settings.always_allow_tool_actions,
+                value.always_allow_tool_actions,
+            );
+            merge(
+                &mut settings.notify_when_agent_waiting,
+                value.notify_when_agent_waiting,
+            );
+            merge(
+                &mut settings.play_sound_when_agent_done,
+                value.play_sound_when_agent_done,
+            );
+            merge(&mut settings.stream_edits, value.stream_edits);
+            merge(&mut settings.single_file_review, value.single_file_review);
+            merge(&mut settings.default_profile, value.default_profile.clone());
+            merge(&mut settings.default_view, value.default_view);
+            merge(
+                &mut settings.preferred_completion_mode,
+                value.preferred_completion_mode,
+            );
+            merge(&mut settings.enable_feedback, value.enable_feedback);
+
+            settings
+                .model_parameters
+                .extend_from_slice(&value.model_parameters);
+
+            if let Some(profiles) = value.profiles.as_ref() {
+                settings
+                    .profiles
+                    .extend(profiles.into_iter().map(|(id, profile)| {
+                        (
+                            id.clone(),
+                            AgentProfileSettings {
+                                name: profile.name.clone().into(),
+                                tools: profile.tools.clone(),
+                                enable_all_context_servers: profile
+                                    .enable_all_context_servers
+                                    .unwrap_or_default(),
+                                context_servers: profile
+                                    .context_servers
+                                    .iter()
+                                    .map(|(context_server_id, preset)| {
+                                        (
+                                            context_server_id.clone(),
+                                            ContextServerPreset {
+                                                tools: preset.tools.clone(),
+                                            },
+                                        )
+                                    })
+                                    .collect(),
+                            },
+                        )
+                    }));
+            }
+        }
+
+        Ok(settings)
+    }
+
+    fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) {
+        if let Some(b) = vscode
+            .read_value("chat.agent.enabled")
+            .and_then(|b| b.as_bool())
+        {
+            current.enabled = Some(b);
+            current.button = Some(b);
+        }
+    }
+}
+
+fn merge<T>(target: &mut T, value: Option<T>) {
+    if let Some(value) = value {
+        *target = value;
+    }
+}

crates/agent_ui/Cargo.toml 🔗

@@ -0,0 +1,110 @@
+[package]
+name = "agent_ui"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/agent_ui.rs"
+doctest = false
+
+[features]
+test-support = [
+    "gpui/test-support",
+    "language/test-support",
+]
+
+[dependencies]
+agent.workspace = true
+agent_settings.workspace = true
+anyhow.workspace = true
+assistant_context.workspace = true
+assistant_slash_command.workspace = true
+assistant_slash_commands.workspace = true
+assistant_tool.workspace = true
+audio.workspace = true
+buffer_diff.workspace = true
+chrono.workspace = true
+client.workspace = true
+collections.workspace = true
+component.workspace = true
+context_server.workspace = true
+db.workspace = true
+editor.workspace = true
+extension.workspace = true
+extension_host.workspace = true
+feature_flags.workspace = true
+file_icons.workspace = true
+fs.workspace = true
+futures.workspace = true
+fuzzy.workspace = true
+gpui.workspace = true
+html_to_markdown.workspace = true
+indoc.workspace = true
+http_client.workspace = true
+indexed_docs.workspace = true
+inventory.workspace = true
+itertools.workspace = true
+jsonschema.workspace = true
+language.workspace = true
+language_model.workspace = true
+log.workspace = true
+lsp.workspace = true
+markdown.workspace = true
+menu.workspace = true
+multi_buffer.workspace = true
+notifications.workspace = true
+ordered-float.workspace = true
+parking_lot.workspace = true
+paths.workspace = true
+picker.workspace = true
+project.workspace = true
+prompt_store.workspace = true
+proto.workspace = true
+release_channel.workspace = true
+rope.workspace = true
+rules_library.workspace = true
+schemars.workspace = true
+search.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+serde_json_lenient.workspace = true
+settings.workspace = true
+smol.workspace = true
+streaming_diff.workspace = true
+telemetry.workspace = true
+telemetry_events.workspace = true
+terminal.workspace = true
+terminal_view.workspace = true
+text.workspace = true
+theme.workspace = true
+time.workspace = true
+time_format.workspace = true
+ui.workspace = true
+urlencoding.workspace = true
+util.workspace = true
+uuid.workspace = true
+watch.workspace = true
+workspace-hack.workspace = true
+workspace.workspace = true
+zed_actions.workspace = true
+zed_llm_client.workspace = true
+
+[dev-dependencies]
+assistant_tools.workspace = true
+buffer_diff = { workspace = true, features = ["test-support"] }
+editor = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, "features" = ["test-support"] }
+indoc.workspace = true
+language = { workspace = true, "features" = ["test-support"] }
+languages = { workspace = true, features = ["test-support"] }
+language_model = { workspace = true, "features" = ["test-support"] }
+pretty_assertions.workspace = true
+project = { workspace = true, features = ["test-support"] }
+rand.workspace = true
+tree-sitter-md.workspace = true
+unindent.workspace = true

crates/agent/src/active_thread.rs → crates/agent_ui/src/active_thread.rs 🔗

@@ -1,29 +1,29 @@
-use crate::AgentPanel;
-use crate::context::{AgentContextHandle, RULES_ICON};
 use crate::context_picker::{ContextPicker, MentionLink};
-use crate::context_store::ContextStore;
 use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
-use crate::message_editor::insert_message_creases;
-use crate::thread::{
-    LastRestoreCheckpoint, MessageCrease, MessageId, MessageSegment, Thread, ThreadError,
-    ThreadEvent, ThreadFeedback, ThreadSummary,
-};
-use crate::thread_store::{RulesLoadingError, TextThreadStore, ThreadStore};
-use crate::tool_use::{PendingToolUseStatus, ToolUse};
+use crate::message_editor::{extract_message_creases, insert_message_creases};
 use crate::ui::{
     AddedContext, AgentNotification, AgentNotificationEvent, AnimatedLabel, ContextPill,
 };
+use crate::{AgentPanel, ModelUsageContext};
+use agent::{
+    ContextStore, LastRestoreCheckpoint, MessageCrease, MessageId, MessageSegment, TextThreadStore,
+    Thread, ThreadError, ThreadEvent, ThreadFeedback, ThreadStore, ThreadSummary,
+    context::{self, AgentContextHandle, RULES_ICON},
+    thread_store::RulesLoadingError,
+    tool_use::{PendingToolUseStatus, ToolUse},
+};
+use agent_settings::{AgentSettings, NotifyWhenAgentWaiting};
 use anyhow::Context as _;
-use assistant_settings::{AssistantSettings, NotifyWhenAgentWaiting};
 use assistant_tool::ToolUseStatus;
+use audio::{Audio, Sound};
 use collections::{HashMap, HashSet};
 use editor::actions::{MoveUp, Paste};
 use editor::scroll::Autoscroll;
-use editor::{Editor, EditorElement, EditorEvent, EditorStyle, MultiBuffer};
+use editor::{Editor, EditorElement, EditorEvent, EditorStyle, MultiBuffer, SelectionEffects};
 use gpui::{
     AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, ClipboardEntry,
     ClipboardItem, DefiniteLength, EdgesRefinement, Empty, Entity, EventEmitter, Focusable, Hsla,
-    ListAlignment, ListState, MouseButton, PlatformDisplay, ScrollHandle, Stateful,
+    ListAlignment, ListOffset, ListState, MouseButton, PlatformDisplay, ScrollHandle, Stateful,
     StyleRefinement, Subscription, Task, TextStyle, TextStyleRefinement, Transformation,
     UnderlineStyle, WeakEntity, WindowHandle, linear_color_stop, linear_gradient, list, percentage,
     pulsating_between,
@@ -33,7 +33,9 @@ use language_model::{
     LanguageModelRequestMessage, LanguageModelToolUseId, MessageContent, Role, StopReason,
 };
 use markdown::parser::{CodeBlockKind, CodeBlockMetadata};
-use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle, ParsedMarkdown};
+use markdown::{
+    HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle, ParsedMarkdown, PathWithRange,
+};
 use project::{ProjectEntryId, ProjectItem as _};
 use rope::Point;
 use settings::{Settings as _, SettingsStore, update_settings_file};
@@ -45,13 +47,17 @@ use std::time::Duration;
 use text::ToPoint;
 use theme::ThemeSettings;
 use ui::{
-    Disclosure, IconButton, KeyBinding, PopoverMenuHandle, Scrollbar, ScrollbarState, TextSize,
-    Tooltip, prelude::*,
+    Disclosure, KeyBinding, PopoverMenuHandle, Scrollbar, ScrollbarState, TextSize, Tooltip,
+    prelude::*,
 };
 use util::ResultExt as _;
 use util::markdown::MarkdownCodeBlock;
-use workspace::Workspace;
+use workspace::{CollaboratorId, Workspace};
 use zed_actions::assistant::OpenRulesLibrary;
+use zed_llm_client::CompletionIntent;
+
+const CODEBLOCK_CONTAINER_GROUP: &str = "codeblock_container";
+const EDIT_PREVIOUS_MESSAGE_MIN_LINES: usize = 1;
 
 pub struct ActiveThread {
     context_store: Entity<ContextStore>,
@@ -183,12 +189,14 @@ pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle
     let ui_font_size = TextSize::Default.rems(cx);
     let buffer_font_size = TextSize::Small.rems(cx);
     let mut text_style = window.text_style();
+    let line_height = buffer_font_size * 1.75;
 
     text_style.refine(&TextStyleRefinement {
         font_family: Some(theme_settings.ui_font.family.clone()),
         font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
         font_features: Some(theme_settings.ui_font.features.clone()),
         font_size: Some(ui_font_size.into()),
+        line_height: Some(line_height.into()),
         color: Some(cx.theme().colors().text),
         ..Default::default()
     });
@@ -196,7 +204,7 @@ pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle
     MarkdownStyle {
         base_text_style: text_style.clone(),
         syntax: cx.theme().syntax().clone(),
-        selection_background_color: cx.theme().players().local().selection,
+        selection_background_color: cx.theme().colors().element_selection_background,
         code_block_overflow_x_scroll: true,
         table_overflow_x_scroll: true,
         heading_level_styles: Some(HeadingLevelStyles {
@@ -293,8 +301,8 @@ fn tool_use_markdown_style(window: &Window, cx: &mut App) -> MarkdownStyle {
     MarkdownStyle {
         base_text_style: text_style,
         syntax: cx.theme().syntax().clone(),
-        selection_background_color: cx.theme().players().local().selection,
-        code_block_overflow_x_scroll: true,
+        selection_background_color: cx.theme().colors().element_selection_background,
+        code_block_overflow_x_scroll: false,
         code_block: StyleRefinement {
             margin: EdgesRefinement::default(),
             padding: EdgesRefinement::default(),
@@ -328,9 +336,6 @@ fn tool_use_markdown_style(window: &Window, cx: &mut App) -> MarkdownStyle {
     }
 }
 
-const CODEBLOCK_CONTAINER_GROUP: &str = "codeblock_container";
-const MAX_UNCOLLAPSED_LINES_IN_CODE_BLOCK: usize = 10;
-
 fn render_markdown_code_block(
     message_id: MessageId,
     ix: usize,
@@ -342,17 +347,20 @@ fn render_markdown_code_block(
     _window: &Window,
     cx: &App,
 ) -> Div {
+    let label_size = rems(0.8125);
+
     let label = match kind {
         CodeBlockKind::Indented => None,
         CodeBlockKind::Fenced => Some(
             h_flex()
+                .px_1()
                 .gap_1()
                 .child(
                     Icon::new(IconName::Code)
                         .color(Color::Muted)
                         .size(IconSize::XSmall),
                 )
-                .child(Label::new("untitled").size(LabelSize::Small))
+                .child(div().text_size(label_size).child("Plain Text"))
                 .into_any_element(),
         ),
         CodeBlockKind::FencedLang(raw_language_name) => Some(render_code_language(
@@ -381,28 +389,36 @@ fn render_markdown_code_block(
                 )
             } else {
                 let content = if let Some(parent) = path_range.path.parent() {
+                    let file_name = file_name.to_string_lossy().to_string();
+                    let path = parent.to_string_lossy().to_string();
+                    let path_and_file = format!("{}/{}", path, file_name);
+
                     h_flex()
+                        .id(("code-block-header-label", ix))
                         .ml_1()
                         .gap_1()
-                        .child(
-                            Label::new(file_name.to_string_lossy().to_string())
-                                .size(LabelSize::Small),
-                        )
-                        .child(
-                            Label::new(parent.to_string_lossy().to_string())
-                                .color(Color::Muted)
-                                .size(LabelSize::Small),
-                        )
+                        .child(div().text_size(label_size).child(file_name))
+                        .child(Label::new(path).color(Color::Muted).size(LabelSize::Small))
+                        .tooltip(move |window, cx| {
+                            Tooltip::with_meta(
+                                "Jump to File",
+                                None,
+                                path_and_file.clone(),
+                                window,
+                                cx,
+                            )
+                        })
                         .into_any_element()
                 } else {
-                    Label::new(path_range.path.to_string_lossy().to_string())
-                        .size(LabelSize::Small)
+                    div()
                         .ml_1()
+                        .text_size(label_size)
+                        .child(path_range.path.to_string_lossy().to_string())
                         .into_any_element()
                 };
 
                 h_flex()
-                    .id(("code-block-header-label", ix))
+                    .id(("code-block-header-button", ix))
                     .w_full()
                     .max_w_full()
                     .px_1()
@@ -410,7 +426,6 @@ fn render_markdown_code_block(
                     .cursor_pointer()
                     .rounded_sm()
                     .hover(|item| item.bg(cx.theme().colors().element_hover.opacity(0.5)))
-                    .tooltip(Tooltip::text("Jump to File"))
                     .child(
                         h_flex()
                             .gap_0p5()
@@ -430,49 +445,8 @@ fn render_markdown_code_block(
                         let path_range = path_range.clone();
                         move |_, window, cx| {
                             workspace
-                                .update(cx, {
-                                    |workspace, cx| {
-                                        let Some(project_path) = workspace
-                                            .project()
-                                            .read(cx)
-                                            .find_project_path(&path_range.path, cx)
-                                        else {
-                                            return;
-                                        };
-                                        let Some(target) = path_range.range.as_ref().map(|range| {
-                                            Point::new(
-                                                // Line number is 1-based
-                                                range.start.line.saturating_sub(1),
-                                                range.start.col.unwrap_or(0),
-                                            )
-                                        }) else {
-                                            return;
-                                        };
-                                        let open_task = workspace.open_path(
-                                            project_path,
-                                            None,
-                                            true,
-                                            window,
-                                            cx,
-                                        );
-                                        window
-                                            .spawn(cx, async move |cx| {
-                                                let item = open_task.await?;
-                                                if let Some(active_editor) =
-                                                    item.downcast::<Editor>()
-                                                {
-                                                    active_editor
-                                                        .update_in(cx, |editor, window, cx| {
-                                                            editor.go_to_singleton_buffer_point(
-                                                                target, window, cx,
-                                                            );
-                                                        })
-                                                        .ok();
-                                                }
-                                                anyhow::Ok(())
-                                            })
-                                            .detach_and_log_err(cx);
-                                    }
+                                .update(cx, |workspace, cx| {
+                                    open_path(&path_range, window, workspace, cx)
                                 })
                                 .ok();
                         }
@@ -487,124 +461,157 @@ fn render_markdown_code_block(
         .copied_code_block_ids
         .contains(&(message_id, ix));
 
-    let can_expand = metadata.line_count > MAX_UNCOLLAPSED_LINES_IN_CODE_BLOCK;
-
-    let is_expanded = if can_expand {
-        active_thread
-            .read(cx)
-            .expanded_code_blocks
-            .get(&(message_id, ix))
-            .copied()
-            .unwrap_or(false)
-    } else {
-        false
-    };
+    let is_expanded = active_thread.read(cx).is_codeblock_expanded(message_id, ix);
 
     let codeblock_header_bg = cx
         .theme()
         .colors()
         .element_background
-        .blend(cx.theme().colors().editor_foreground.opacity(0.01));
+        .blend(cx.theme().colors().editor_foreground.opacity(0.025));
+
+    let control_buttons = h_flex()
+        .visible_on_hover(CODEBLOCK_CONTAINER_GROUP)
+        .absolute()
+        .top_0()
+        .right_0()
+        .h_full()
+        .bg(codeblock_header_bg)
+        .rounded_tr_md()
+        .px_1()
+        .gap_1()
+        .child(
+            IconButton::new(
+                ("copy-markdown-code", ix),
+                if codeblock_was_copied {
+                    IconName::Check
+                } else {
+                    IconName::Copy
+                },
+            )
+            .icon_color(Color::Muted)
+            .shape(ui::IconButtonShape::Square)
+            .tooltip(Tooltip::text("Copy Code"))
+            .on_click({
+                let active_thread = active_thread.clone();
+                let parsed_markdown = parsed_markdown.clone();
+                let code_block_range = metadata.content_range.clone();
+                move |_event, _window, cx| {
+                    active_thread.update(cx, |this, cx| {
+                        this.copied_code_block_ids.insert((message_id, ix));
+
+                        let code = parsed_markdown.source()[code_block_range.clone()].to_string();
+                        cx.write_to_clipboard(ClipboardItem::new_string(code));
+
+                        cx.spawn(async move |this, cx| {
+                            cx.background_executor().timer(Duration::from_secs(2)).await;
+
+                            cx.update(|cx| {
+                                this.update(cx, |this, cx| {
+                                    this.copied_code_block_ids.remove(&(message_id, ix));
+                                    cx.notify();
+                                })
+                            })
+                            .ok();
+                        })
+                        .detach();
+                    });
+                }
+            }),
+        )
+        .child(
+            IconButton::new(
+                ("expand-collapse-code", ix),
+                if is_expanded {
+                    IconName::ChevronUp
+                } else {
+                    IconName::ChevronDown
+                },
+            )
+            .icon_color(Color::Muted)
+            .shape(ui::IconButtonShape::Square)
+            .tooltip(Tooltip::text(if is_expanded {
+                "Collapse Code"
+            } else {
+                "Expand Code"
+            }))
+            .on_click({
+                let active_thread = active_thread.clone();
+                move |_event, _window, cx| {
+                    active_thread.update(cx, |this, cx| {
+                        this.toggle_codeblock_expanded(message_id, ix);
+                        cx.notify();
+                    });
+                }
+            }),
+        );
 
     let codeblock_header = h_flex()
-        .py_1()
-        .pl_1p5()
-        .pr_1()
+        .relative()
+        .p_1()
         .gap_1()
         .justify_between()
-        .border_b_1()
-        .border_color(cx.theme().colors().border.opacity(0.6))
         .bg(codeblock_header_bg)
-        .rounded_t_md()
+        .map(|this| {
+            if !is_expanded {
+                this.rounded_md()
+            } else {
+                this.rounded_t_md()
+                    .border_b_1()
+                    .border_color(cx.theme().colors().border.opacity(0.6))
+            }
+        })
         .children(label)
-        .child(
-            h_flex()
-                .visible_on_hover(CODEBLOCK_CONTAINER_GROUP)
-                .gap_1()
-                .child(
-                    IconButton::new(
-                        ("copy-markdown-code", ix),
-                        if codeblock_was_copied {
-                            IconName::Check
-                        } else {
-                            IconName::Copy
-                        },
-                    )
-                    .icon_color(Color::Muted)
-                    .shape(ui::IconButtonShape::Square)
-                    .tooltip(Tooltip::text("Copy Code"))
-                    .on_click({
-                        let active_thread = active_thread.clone();
-                        let parsed_markdown = parsed_markdown.clone();
-                        let code_block_range = metadata.content_range.clone();
-                        move |_event, _window, cx| {
-                            active_thread.update(cx, |this, cx| {
-                                this.copied_code_block_ids.insert((message_id, ix));
-
-                                let code =
-                                    parsed_markdown.source()[code_block_range.clone()].to_string();
-                                cx.write_to_clipboard(ClipboardItem::new_string(code));
-
-                                cx.spawn(async move |this, cx| {
-                                    cx.background_executor().timer(Duration::from_secs(2)).await;
-
-                                    cx.update(|cx| {
-                                        this.update(cx, |this, cx| {
-                                            this.copied_code_block_ids.remove(&(message_id, ix));
-                                            cx.notify();
-                                        })
-                                    })
-                                    .ok();
-                                })
-                                .detach();
-                            });
-                        }
-                    }),
-                )
-                .when(can_expand, |header| {
-                    header.child(
-                        IconButton::new(
-                            ("expand-collapse-code", ix),
-                            if is_expanded {
-                                IconName::ChevronUp
-                            } else {
-                                IconName::ChevronDown
-                            },
-                        )
-                        .icon_color(Color::Muted)
-                        .shape(ui::IconButtonShape::Square)
-                        .tooltip(Tooltip::text(if is_expanded {
-                            "Collapse Code"
-                        } else {
-                            "Expand Code"
-                        }))
-                        .on_click({
-                            let active_thread = active_thread.clone();
-                            move |_event, _window, cx| {
-                                active_thread.update(cx, |this, cx| {
-                                    let is_expanded = this
-                                        .expanded_code_blocks
-                                        .entry((message_id, ix))
-                                        .or_insert(true);
-                                    *is_expanded = !*is_expanded;
-                                    cx.notify();
-                                });
-                            }
-                        }),
-                    )
-                }),
-        );
+        .child(control_buttons);
 
     v_flex()
         .group(CODEBLOCK_CONTAINER_GROUP)
         .my_2()
         .overflow_hidden()
-        .rounded_lg()
+        .rounded_md()
         .border_1()
         .border_color(cx.theme().colors().border.opacity(0.6))
         .bg(cx.theme().colors().editor_background)
         .child(codeblock_header)
-        .when(can_expand && !is_expanded, |this| this.max_h_80())
+        .when(!is_expanded, |this| this.h(rems_from_px(31.)))
+}
+
+fn open_path(
+    path_range: &PathWithRange,
+    window: &mut Window,
+    workspace: &mut Workspace,
+    cx: &mut Context<'_, Workspace>,
+) {
+    let Some(project_path) = workspace
+        .project()
+        .read(cx)
+        .find_project_path(&path_range.path, cx)
+    else {
+        return; // TODO instead of just bailing out, open that path in a buffer.
+    };
+
+    let Some(target) = path_range.range.as_ref().map(|range| {
+        Point::new(
+            // Line number is 1-based
+            range.start.line.saturating_sub(1),
+            range.start.col.unwrap_or(0),
+        )
+    }) else {
+        return;
+    };
+    let open_task = workspace.open_path(project_path, None, true, window, cx);
+    window
+        .spawn(cx, async move |cx| {
+            let item = open_task.await?;
+            if let Some(active_editor) = item.downcast::<Editor>() {
+                active_editor
+                    .update_in(cx, |editor, window, cx| {
+                        editor.go_to_singleton_buffer_point(target, window, cx);
+                    })
+                    .ok();
+            }
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
 }
 
 fn render_code_language(
@@ -626,10 +633,13 @@ fn render_code_language(
         .map(|language| language.name().into())
         .unwrap_or(name_fallback);
 
+    let label_size = rems(0.8125);
+
     h_flex()
-        .gap_1()
-        .children(icon_path.map(|icon| icon.color(Color::Muted).size(IconSize::Small)))
-        .child(Label::new(language_label).size(LabelSize::Small))
+        .px_1()
+        .gap_1p5()
+        .children(icon_path.map(|icon| icon.color(Color::Muted).size(IconSize::XSmall)))
+        .child(div().text_size(label_size).child(language_label))
         .into_any_element()
 }
 
@@ -679,9 +689,12 @@ fn open_markdown_link(
                             })
                             .context("Could not find matching symbol")?;
 
-                        editor.change_selections(Some(Autoscroll::center()), window, cx, |s| {
-                            s.select_anchor_ranges([symbol_range.start..symbol_range.start])
-                        });
+                        editor.change_selections(
+                            SelectionEffects::scroll(Autoscroll::center()),
+                            window,
+                            cx,
+                            |s| s.select_anchor_ranges([symbol_range.start..symbol_range.start]),
+                        );
                         anyhow::Ok(())
                     })
                 })
@@ -698,10 +711,15 @@ fn open_markdown_link(
                         .downcast::<Editor>()
                         .context("Item is not an editor")?;
                     active_editor.update_in(cx, |editor, window, cx| {
-                        editor.change_selections(Some(Autoscroll::center()), window, cx, |s| {
-                            s.select_ranges([Point::new(line_range.start as u32, 0)
-                                ..Point::new(line_range.start as u32, 0)])
-                        });
+                        editor.change_selections(
+                            SelectionEffects::scroll(Autoscroll::center()),
+                            window,
+                            cx,
+                            |s| {
+                                s.select_ranges([Point::new(line_range.start as u32, 0)
+                                    ..Point::new(line_range.start as u32, 0)])
+                            },
+                        );
                         anyhow::Ok(())
                     })
                 })
@@ -740,7 +758,7 @@ struct EditingMessageState {
     editor: Entity<Editor>,
     context_strip: Entity<ContextStrip>,
     context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
-    last_estimated_token_count: Option<usize>,
+    last_estimated_token_count: Option<u64>,
     _subscriptions: [Subscription; 2],
     _update_token_count_task: Option<Task<()>>,
 }
@@ -799,7 +817,12 @@ impl ActiveThread {
         };
 
         for message in thread.read(cx).messages().cloned().collect::<Vec<_>>() {
-            this.push_message(&message.id, &message.segments, window, cx);
+            let rendered_message = RenderedMessage::from_segments(
+                &message.segments,
+                this.language_registry.clone(),
+                cx,
+            );
+            this.push_rendered_message(message.id, rendered_message);
 
             for tool_use in thread.read(cx).tool_uses_for_message(message.id, cx) {
                 this.render_tool_use_markdown(
@@ -847,7 +870,7 @@ impl ActiveThread {
     }
 
     /// Returns the editing message id and the estimated token count in the content
-    pub fn editing_message_id(&self) -> Option<(MessageId, usize)> {
+    pub fn editing_message_id(&self) -> Option<(MessageId, u64)> {
         self.editing_message
             .as_ref()
             .map(|(id, state)| (*id, state.last_estimated_token_count.unwrap_or(0)))
@@ -865,36 +888,11 @@ impl ActiveThread {
         &self.text_thread_store
     }
 
-    fn push_message(
-        &mut self,
-        id: &MessageId,
-        segments: &[MessageSegment],
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
+    fn push_rendered_message(&mut self, id: MessageId, rendered_message: RenderedMessage) {
         let old_len = self.messages.len();
-        self.messages.push(*id);
+        self.messages.push(id);
         self.list_state.splice(old_len..old_len, 1);
-
-        let rendered_message =
-            RenderedMessage::from_segments(segments, self.language_registry.clone(), cx);
-        self.rendered_messages_by_id.insert(*id, rendered_message);
-    }
-
-    fn edited_message(
-        &mut self,
-        id: &MessageId,
-        segments: &[MessageSegment],
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let Some(index) = self.messages.iter().position(|message_id| message_id == id) else {
-            return;
-        };
-        self.list_state.splice(index..index + 1, 1);
-        let rendered_message =
-            RenderedMessage::from_segments(segments, self.language_registry.clone(), cx);
-        self.rendered_messages_by_id.insert(*id, rendered_message);
+        self.rendered_messages_by_id.insert(id, rendered_message);
     }
 
     fn deleted_message(&mut self, id: &MessageId) {
@@ -963,7 +961,22 @@ impl ActiveThread {
             ThreadEvent::ShowError(error) => {
                 self.last_error = Some(error.clone());
             }
-            ThreadEvent::NewRequest | ThreadEvent::CompletionCanceled => {
+            ThreadEvent::NewRequest => {
+                cx.notify();
+            }
+            ThreadEvent::CompletionCanceled => {
+                self.thread.update(cx, |thread, cx| {
+                    thread.project().update(cx, |project, cx| {
+                        project.set_agent_location(None, cx);
+                    })
+                });
+                self.workspace
+                    .update(cx, |workspace, cx| {
+                        if workspace.is_being_followed(CollaboratorId::Agent) {
+                            workspace.unfollow(CollaboratorId::Agent, window, cx);
+                        }
+                    })
+                    .ok();
                 cx.notify();
             }
             ThreadEvent::StreamedCompletion
@@ -973,9 +986,10 @@ impl ActiveThread {
             }
             ThreadEvent::Stopped(reason) => match reason {
                 Ok(StopReason::EndTurn | StopReason::MaxTokens) => {
-                    let thread = self.thread.read(cx);
+                    let used_tools = self.thread.read(cx).used_tools_since_last_user_message();
+                    self.play_notification_sound(window, cx);
                     self.show_notification(
-                        if thread.used_tools_since_last_user_message() {
+                        if used_tools {
                             "Finished running tools"
                         } else {
                             "New message"
@@ -988,8 +1002,18 @@ impl ActiveThread {
                 _ => {}
             },
             ThreadEvent::ToolConfirmationNeeded => {
+                self.play_notification_sound(window, cx);
                 self.show_notification("Waiting for tool confirmation", IconName::Info, window, cx);
             }
+            ThreadEvent::ToolUseLimitReached => {
+                self.play_notification_sound(window, cx);
+                self.show_notification(
+                    "Consecutive tool use limit reached.",
+                    IconName::Warning,
+                    window,
+                    cx,
+                );
+            }
             ThreadEvent::StreamedAssistantText(message_id, text) => {
                 if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) {
                     rendered_message.append_text(text, cx);
@@ -1001,30 +1025,43 @@ impl ActiveThread {
                 }
             }
             ThreadEvent::MessageAdded(message_id) => {
-                if let Some(message_segments) = self
-                    .thread
-                    .read(cx)
-                    .message(*message_id)
-                    .map(|message| message.segments.clone())
-                {
-                    self.push_message(message_id, &message_segments, window, cx);
+                if let Some(rendered_message) = self.thread.update(cx, |thread, cx| {
+                    thread.message(*message_id).map(|message| {
+                        RenderedMessage::from_segments(
+                            &message.segments,
+                            self.language_registry.clone(),
+                            cx,
+                        )
+                    })
+                }) {
+                    self.push_rendered_message(*message_id, rendered_message);
                 }
 
                 self.save_thread(cx);
                 cx.notify();
             }
             ThreadEvent::MessageEdited(message_id) => {
-                if let Some(message_segments) = self
-                    .thread
-                    .read(cx)
-                    .message(*message_id)
-                    .map(|message| message.segments.clone())
-                {
-                    self.edited_message(message_id, &message_segments, window, cx);
+                if let Some(index) = self.messages.iter().position(|id| id == message_id) {
+                    if let Some(rendered_message) = self.thread.update(cx, |thread, cx| {
+                        thread.message(*message_id).map(|message| {
+                            let mut rendered_message = RenderedMessage {
+                                language_registry: self.language_registry.clone(),
+                                segments: Vec::with_capacity(message.segments.len()),
+                            };
+                            for segment in &message.segments {
+                                rendered_message.push_segment(segment, cx);
+                            }
+                            rendered_message
+                        })
+                    }) {
+                        self.list_state.splice(index..index + 1, 1);
+                        self.rendered_messages_by_id
+                            .insert(*message_id, rendered_message);
+                        self.scroll_to_bottom(cx);
+                        self.save_thread(cx);
+                        cx.notify();
+                    }
                 }
-
-                self.save_thread(cx);
-                cx.notify();
             }
             ThreadEvent::MessageDeleted(message_id) => {
                 self.deleted_message(message_id);
@@ -1107,6 +1144,13 @@ impl ActiveThread {
                     cx,
                 );
             }
+            ThreadEvent::ProfileChanged => {
+                self.save_thread(cx);
+                cx.notify();
+            }
+            ThreadEvent::RetriesFailed { message } => {
+                self.show_notification(message, ui::IconName::Warning, window, cx);
+            }
         }
     }
 
@@ -1123,6 +1167,13 @@ impl ActiveThread {
         cx.notify();
     }
 
+    fn play_notification_sound(&self, window: &Window, cx: &mut App) {
+        let settings = AgentSettings::get_global(cx);
+        if settings.play_sound_when_agent_done && !window.is_window_active() {
+            Audio::play_sound(Sound::AgentDone, cx);
+        }
+    }
+
     fn show_notification(
         &mut self,
         caption: impl Into<SharedString>,
@@ -1136,7 +1187,7 @@ impl ActiveThread {
 
         let title = self.thread.read(cx).summary().unwrap_or("Agent Panel");
 
-        match AssistantSettings::get_global(cx).notify_when_agent_waiting {
+        match AgentSettings::get_global(cx).notify_when_agent_waiting {
             NotifyWhenAgentWaiting::PrimaryScreen => {
                 if let Some(primary) = cx.primary_display() {
                     self.pop_up(icon, caption.into(), title.clone(), window, primary, cx);
@@ -1263,27 +1314,23 @@ impl ActiveThread {
     fn start_editing_message(
         &mut self,
         message_id: MessageId,
-        message_segments: &[MessageSegment],
+        message_text: impl Into<Arc<str>>,
         message_creases: &[MessageCrease],
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        // User message should always consist of a single text segment,
-        // therefore we can skip returning early if it's not a text segment.
-        let Some(MessageSegment::Text(message_text)) = message_segments.first() else {
-            return;
-        };
-
         let editor = crate::message_editor::create_editor(
             self.workspace.clone(),
             self.context_store.downgrade(),
             self.thread_store.downgrade(),
             self.text_thread_store.downgrade(),
+            EDIT_PREVIOUS_MESSAGE_MIN_LINES,
+            None,
             window,
             cx,
         );
         editor.update(cx, |editor, cx| {
-            editor.set_text(message_text.clone(), window, cx);
+            editor.set_text(message_text, window, cx);
             insert_message_creases(editor, message_creases, &self.context_store, window, cx);
             editor.focus_handle(cx).focus(window);
             editor.move_to_end(&editor::actions::MoveToEnd, window, cx);
@@ -1304,6 +1351,7 @@ impl ActiveThread {
                 Some(self.text_thread_store.downgrade()),
                 context_picker_menu_handle.clone(),
                 SuggestContextKind::File,
+                ModelUsageContext::Thread(self.thread.clone()),
                 window,
                 cx,
             )
@@ -1402,12 +1450,13 @@ impl ActiveThread {
                     let request = language_model::LanguageModelRequest {
                         thread_id: None,
                         prompt_id: None,
+                        intent: None,
                         mode: None,
                         messages: vec![request_message],
                         tools: vec![],
                         tool_choice: None,
                         stop: vec![],
-                        temperature: AssistantSettings::temperature_for_model(
+                        temperature: AgentSettings::temperature_for_model(
                             &configured_model.model,
                             cx,
                         ),
@@ -1457,7 +1506,7 @@ impl ActiveThread {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.context_store.update(cx, |store, _cx| store.clear());
+        self.context_store.update(cx, |store, cx| store.clear(cx));
         cx.notify();
     }
 
@@ -1472,36 +1521,25 @@ impl ActiveThread {
     }
 
     fn paste(&mut self, _: &Paste, _window: &mut Window, cx: &mut Context<Self>) {
-        let images = cx
-            .read_from_clipboard()
-            .map(|item| {
-                item.into_entries()
-                    .filter_map(|entry| {
-                        if let ClipboardEntry::Image(image) = entry {
-                            Some(image)
-                        } else {
-                            None
-                        }
-                    })
-                    .collect::<Vec<_>>()
-            })
-            .unwrap_or_default();
-
-        if images.is_empty() {
-            return;
-        }
-        cx.stop_propagation();
-
-        self.context_store.update(cx, |store, cx| {
-            for image in images {
-                store.add_image_instance(Arc::new(image), cx);
-            }
-        });
+        attach_pasted_images_as_context(&self.context_store, cx);
     }
 
-    fn cancel_editing_message(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+    fn cancel_editing_message(
+        &mut self,
+        _: &menu::Cancel,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         self.editing_message.take();
         cx.notify();
+
+        if let Some(workspace) = self.workspace.upgrade() {
+            workspace.update(cx, |workspace, cx| {
+                if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                    panel.focus_handle(cx).focus(window);
+                }
+            });
+        }
     }
 
     fn confirm_editing_message(
@@ -1528,6 +1566,8 @@ impl ActiveThread {
 
         let edited_text = state.editor.read(cx).text(cx);
 
+        let creases = state.editor.update(cx, extract_message_creases);
+
         let new_context = self
             .context_store
             .read(cx)
@@ -1536,11 +1576,14 @@ impl ActiveThread {
         let project = self.thread.read(cx).project().clone();
         let prompt_store = self.thread_store.read(cx).prompt_store().clone();
 
-        let load_context_task =
-            crate::context::load_context(new_context, &project, &prompt_store, cx);
+        let git_store = project.read(cx).git_store().clone();
+        let checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx));
+
+        let load_context_task = context::load_context(new_context, &project, &prompt_store, cx);
         self._load_edited_message_context_task =
             Some(cx.spawn_in(window, async move |this, cx| {
-                let context = load_context_task.await;
+                let (context, checkpoint) =
+                    futures::future::join(load_context_task, checkpoint).await;
                 let _ = this
                     .update_in(cx, |this, window, cx| {
                         this.thread.update(cx, |thread, cx| {
@@ -1548,7 +1591,9 @@ impl ActiveThread {
                                 message_id,
                                 Role::User,
                                 vec![MessageSegment::Text(edited_text)],
+                                creases,
                                 Some(context.loaded_context),
+                                checkpoint.ok(),
                                 cx,
                             );
                             for message_id in this.messages_after(message_id) {
@@ -1558,13 +1603,27 @@ impl ActiveThread {
 
                         this.thread.update(cx, |thread, cx| {
                             thread.advance_prompt_id();
-                            thread.send_to_model(model.model, Some(window.window_handle()), cx);
+                            thread.cancel_last_completion(Some(window.window_handle()), cx);
+                            thread.send_to_model(
+                                model.model,
+                                CompletionIntent::UserPrompt,
+                                Some(window.window_handle()),
+                                cx,
+                            );
                         });
                         this._load_edited_message_context_task = None;
                         cx.notify();
                     })
                     .log_err();
             }));
+
+        if let Some(workspace) = self.workspace.upgrade() {
+            workspace.update(cx, |workspace, cx| {
+                if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                    panel.focus_handle(cx).focus(window);
+                }
+            });
+        }
     }
 
     fn messages_after(&self, message_id: MessageId) -> &[MessageId] {
@@ -1628,7 +1687,10 @@ impl ActiveThread {
 
         let editor = cx.new(|cx| {
             let mut editor = Editor::new(
-                editor::EditorMode::AutoHeight { max_lines: 4 },
+                editor::EditorMode::AutoHeight {
+                    min_lines: 1,
+                    max_lines: Some(4),
+                },
                 buffer,
                 None,
                 window,
@@ -1670,7 +1732,7 @@ impl ActiveThread {
             telemetry::event!(
                 "Assistant Thread Feedback Comments",
                 thread_id,
-                message_id = message_id.0,
+                message_id = message_id.as_usize(),
                 message_content,
                 comments = comments_value
             );
@@ -1718,10 +1780,11 @@ impl ActiveThread {
             .on_action(cx.listener(Self::confirm_editing_message))
             .capture_action(cx.listener(Self::paste))
             .min_h_6()
-            .flex_grow()
             .w_full()
+            .flex_grow()
             .gap_2()
-            .child(EditorElement::new(
+            .child(state.context_strip.clone())
+            .child(div().pt(px(-3.)).px_neg_0p5().child(EditorElement::new(
                 &state.editor,
                 EditorStyle {
                     background: colors.editor_background,

crates/agent_ui/src/agent_configuration.rs 🔗

@@ -0,0 +1,997 @@
+mod configure_context_server_modal;
+mod manage_profiles_modal;
+mod tool_picker;
+
+use std::{sync::Arc, time::Duration};
+
+use agent_settings::AgentSettings;
+use assistant_tool::{ToolSource, ToolWorkingSet};
+use collections::HashMap;
+use context_server::ContextServerId;
+use extension::ExtensionManifest;
+use extension_host::ExtensionStore;
+use fs::Fs;
+use gpui::{
+    Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle,
+    Focusable, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage,
+};
+use language::LanguageRegistry;
+use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry};
+use notifications::status_toast::{StatusToast, ToastIcon};
+use project::{
+    context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
+    project_settings::{ContextServerSettings, ProjectSettings},
+};
+use settings::{Settings, update_settings_file};
+use ui::{
+    ContextMenu, Disclosure, ElevationIndex, Indicator, PopoverMenu, Scrollbar, ScrollbarState,
+    Switch, SwitchColor, Tooltip, prelude::*,
+};
+use util::ResultExt as _;
+use workspace::Workspace;
+use zed_actions::ExtensionCategoryFilter;
+
+pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
+pub(crate) use manage_profiles_modal::ManageProfilesModal;
+
+use crate::AddContextServer;
+
+pub struct AgentConfiguration {
+    fs: Arc<dyn Fs>,
+    language_registry: Arc<LanguageRegistry>,
+    workspace: WeakEntity<Workspace>,
+    focus_handle: FocusHandle,
+    configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
+    context_server_store: Entity<ContextServerStore>,
+    expanded_context_server_tools: HashMap<ContextServerId, bool>,
+    expanded_provider_configurations: HashMap<LanguageModelProviderId, bool>,
+    tools: Entity<ToolWorkingSet>,
+    _registry_subscription: Subscription,
+    scroll_handle: ScrollHandle,
+    scrollbar_state: ScrollbarState,
+}
+
+impl AgentConfiguration {
+    pub fn new(
+        fs: Arc<dyn Fs>,
+        context_server_store: Entity<ContextServerStore>,
+        tools: Entity<ToolWorkingSet>,
+        language_registry: Arc<LanguageRegistry>,
+        workspace: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let focus_handle = cx.focus_handle();
+
+        let registry_subscription = cx.subscribe_in(
+            &LanguageModelRegistry::global(cx),
+            window,
+            |this, _, event: &language_model::Event, window, cx| match event {
+                language_model::Event::AddedProvider(provider_id) => {
+                    let provider = LanguageModelRegistry::read_global(cx).provider(provider_id);
+                    if let Some(provider) = provider {
+                        this.add_provider_configuration_view(&provider, window, cx);
+                    }
+                }
+                language_model::Event::RemovedProvider(provider_id) => {
+                    this.remove_provider_configuration_view(provider_id);
+                }
+                _ => {}
+            },
+        );
+
+        cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify())
+            .detach();
+
+        let scroll_handle = ScrollHandle::new();
+        let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
+
+        let mut this = Self {
+            fs,
+            language_registry,
+            workspace,
+            focus_handle,
+            configuration_views_by_provider: HashMap::default(),
+            context_server_store,
+            expanded_context_server_tools: HashMap::default(),
+            expanded_provider_configurations: HashMap::default(),
+            tools,
+            _registry_subscription: registry_subscription,
+            scroll_handle,
+            scrollbar_state,
+        };
+        this.build_provider_configuration_views(window, cx);
+        this
+    }
+
+    fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let providers = LanguageModelRegistry::read_global(cx).providers();
+        for provider in providers {
+            self.add_provider_configuration_view(&provider, window, cx);
+        }
+    }
+
+    fn remove_provider_configuration_view(&mut self, provider_id: &LanguageModelProviderId) {
+        self.configuration_views_by_provider.remove(provider_id);
+        self.expanded_provider_configurations.remove(provider_id);
+    }
+
+    fn add_provider_configuration_view(
+        &mut self,
+        provider: &Arc<dyn LanguageModelProvider>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let configuration_view = provider.configuration_view(window, cx);
+        self.configuration_views_by_provider
+            .insert(provider.id(), configuration_view);
+    }
+}
+
+impl Focusable for AgentConfiguration {
+    fn focus_handle(&self, _: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+pub enum AssistantConfigurationEvent {
+    NewThread(Arc<dyn LanguageModelProvider>),
+}
+
+impl EventEmitter<AssistantConfigurationEvent> for AgentConfiguration {}
+
+impl AgentConfiguration {
+    fn render_provider_configuration_block(
+        &mut self,
+        provider: &Arc<dyn LanguageModelProvider>,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement + use<> {
+        let provider_id = provider.id().0.clone();
+        let provider_name = provider.name().0.clone();
+        let provider_id_string = SharedString::from(format!("provider-disclosure-{provider_id}"));
+
+        let configuration_view = self
+            .configuration_views_by_provider
+            .get(&provider.id())
+            .cloned();
+
+        let is_expanded = self
+            .expanded_provider_configurations
+            .get(&provider.id())
+            .copied()
+            .unwrap_or(false);
+
+        v_flex()
+            .py_2()
+            .gap_1p5()
+            .border_t_1()
+            .border_color(cx.theme().colors().border.opacity(0.6))
+            .child(
+                h_flex()
+                    .w_full()
+                    .gap_1()
+                    .justify_between()
+                    .child(
+                        h_flex()
+                            .id(provider_id_string.clone())
+                            .cursor_pointer()
+                            .py_0p5()
+                            .w_full()
+                            .justify_between()
+                            .rounded_sm()
+                            .hover(|hover| hover.bg(cx.theme().colors().element_hover))
+                            .child(
+                                h_flex()
+                                    .gap_2()
+                                    .child(
+                                        Icon::new(provider.icon())
+                                            .size(IconSize::Small)
+                                            .color(Color::Muted),
+                                    )
+                                    .child(Label::new(provider_name.clone()).size(LabelSize::Large))
+                                    .when(
+                                        provider.is_authenticated(cx) && !is_expanded,
+                                        |parent| {
+                                            parent.child(
+                                                Icon::new(IconName::Check).color(Color::Success),
+                                            )
+                                        },
+                                    ),
+                            )
+                            .child(
+                                Disclosure::new(provider_id_string, is_expanded)
+                                    .opened_icon(IconName::ChevronUp)
+                                    .closed_icon(IconName::ChevronDown),
+                            )
+                            .on_click(cx.listener({
+                                let provider_id = provider.id().clone();
+                                move |this, _event, _window, _cx| {
+                                    let is_expanded = this
+                                        .expanded_provider_configurations
+                                        .entry(provider_id.clone())
+                                        .or_insert(false);
+
+                                    *is_expanded = !*is_expanded;
+                                }
+                            })),
+                    )
+                    .when(provider.is_authenticated(cx), |parent| {
+                        parent.child(
+                            Button::new(
+                                SharedString::from(format!("new-thread-{provider_id}")),
+                                "Start New Thread",
+                            )
+                            .icon_position(IconPosition::Start)
+                            .icon(IconName::Plus)
+                            .icon_size(IconSize::Small)
+                            .icon_color(Color::Muted)
+                            .label_size(LabelSize::Small)
+                            .on_click(cx.listener({
+                                let provider = provider.clone();
+                                move |_this, _event, _window, cx| {
+                                    cx.emit(AssistantConfigurationEvent::NewThread(
+                                        provider.clone(),
+                                    ))
+                                }
+                            })),
+                        )
+                    }),
+            )
+            .when(is_expanded, |parent| match configuration_view {
+                Some(configuration_view) => parent.child(configuration_view),
+                None => parent.child(Label::new(format!(
+                    "No configuration view for {provider_name}",
+                ))),
+            })
+    }
+
+    fn render_provider_configuration_section(
+        &mut self,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let providers = LanguageModelRegistry::read_global(cx).providers();
+
+        v_flex()
+            .p(DynamicSpacing::Base16.rems(cx))
+            .pr(DynamicSpacing::Base20.rems(cx))
+            .border_b_1()
+            .border_color(cx.theme().colors().border)
+            .child(
+                v_flex()
+                    .mb_2p5()
+                    .gap_0p5()
+                    .child(Headline::new("LLM Providers"))
+                    .child(
+                        Label::new("Add at least one provider to use AI-powered features.")
+                            .color(Color::Muted),
+                    ),
+            )
+            .children(
+                providers
+                    .into_iter()
+                    .map(|provider| self.render_provider_configuration_block(&provider, cx)),
+            )
+    }
+
+    fn render_command_permission(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
+        let always_allow_tool_actions = AgentSettings::get_global(cx).always_allow_tool_actions;
+
+        h_flex()
+            .gap_4()
+            .justify_between()
+            .flex_wrap()
+            .child(
+                v_flex()
+                    .gap_0p5()
+                    .max_w_5_6()
+                    .child(Label::new("Allow running editing tools without asking for confirmation"))
+                    .child(
+                        Label::new(
+                            "The agent can perform potentially destructive actions without asking for your confirmation.",
+                        )
+                        .color(Color::Muted),
+                    ),
+            )
+            .child(
+                Switch::new(
+                    "always-allow-tool-actions-switch",
+                    always_allow_tool_actions.into(),
+                )
+                .color(SwitchColor::Accent)
+                .on_click({
+                    let fs = self.fs.clone();
+                    move |state, _window, cx| {
+                        let allow = state == &ToggleState::Selected;
+                        update_settings_file::<AgentSettings>(
+                            fs.clone(),
+                            cx,
+                            move |settings, _| {
+                                settings.set_always_allow_tool_actions(allow);
+                            },
+                        );
+                    }
+                }),
+            )
+    }
+
+    fn render_single_file_review(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
+        let single_file_review = AgentSettings::get_global(cx).single_file_review;
+
+        h_flex()
+            .gap_4()
+            .justify_between()
+            .flex_wrap()
+            .child(
+                v_flex()
+                    .gap_0p5()
+                    .max_w_5_6()
+                    .child(Label::new("Enable single-file agent reviews"))
+                    .child(
+                        Label::new(
+                            "Agent edits are also displayed in single-file editors for review.",
+                        )
+                        .color(Color::Muted),
+                    ),
+            )
+            .child(
+                Switch::new("single-file-review-switch", single_file_review.into())
+                    .color(SwitchColor::Accent)
+                    .on_click({
+                        let fs = self.fs.clone();
+                        move |state, _window, cx| {
+                            let allow = state == &ToggleState::Selected;
+                            update_settings_file::<AgentSettings>(
+                                fs.clone(),
+                                cx,
+                                move |settings, _| {
+                                    settings.set_single_file_review(allow);
+                                },
+                            );
+                        }
+                    }),
+            )
+    }
+
+    fn render_sound_notification(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
+        let play_sound_when_agent_done = AgentSettings::get_global(cx).play_sound_when_agent_done;
+
+        h_flex()
+            .gap_4()
+            .justify_between()
+            .flex_wrap()
+            .child(
+                v_flex()
+                    .gap_0p5()
+                    .max_w_5_6()
+                    .child(Label::new("Play sound when finished generating"))
+                    .child(
+                        Label::new(
+                            "Hear a notification sound when the agent is done generating changes or needs your input.",
+                        )
+                        .color(Color::Muted),
+                    ),
+            )
+            .child(
+                Switch::new("play-sound-notification-switch", play_sound_when_agent_done.into())
+                    .color(SwitchColor::Accent)
+                    .on_click({
+                        let fs = self.fs.clone();
+                        move |state, _window, cx| {
+                            let allow = state == &ToggleState::Selected;
+                            update_settings_file::<AgentSettings>(
+                                fs.clone(),
+                                cx,
+                                move |settings, _| {
+                                    settings.set_play_sound_when_agent_done(allow);
+                                },
+                            );
+                        }
+                    }),
+            )
+    }
+
+    fn render_general_settings_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex()
+            .p(DynamicSpacing::Base16.rems(cx))
+            .pr(DynamicSpacing::Base20.rems(cx))
+            .gap_2p5()
+            .border_b_1()
+            .border_color(cx.theme().colors().border)
+            .child(Headline::new("General Settings"))
+            .child(self.render_command_permission(cx))
+            .child(self.render_single_file_review(cx))
+            .child(self.render_sound_notification(cx))
+    }
+
+    fn render_context_servers_section(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let context_server_ids = self.context_server_store.read(cx).all_server_ids().clone();
+
+        v_flex()
+            .p(DynamicSpacing::Base16.rems(cx))
+            .pr(DynamicSpacing::Base20.rems(cx))
+            .gap_2()
+            .border_b_1()
+            .border_color(cx.theme().colors().border)
+            .child(
+                v_flex()
+                    .gap_0p5()
+                    .child(Headline::new("Model Context Protocol (MCP) Servers"))
+                    .child(Label::new("Connect to context servers via the Model Context Protocol either via Zed extensions or directly.").color(Color::Muted)),
+            )
+            .children(
+                context_server_ids.into_iter().map(|context_server_id| {
+                    self.render_context_server(context_server_id, window, cx)
+                }),
+            )
+            .child(
+                h_flex()
+                    .justify_between()
+                    .gap_2()
+                    .child(
+                        h_flex().w_full().child(
+                            Button::new("add-context-server", "Add Custom Server")
+                                .style(ButtonStyle::Filled)
+                                .layer(ElevationIndex::ModalSurface)
+                                .full_width()
+                                .icon(IconName::Plus)
+                                .icon_size(IconSize::Small)
+                                .icon_position(IconPosition::Start)
+                                .on_click(|_event, window, cx| {
+                                    window.dispatch_action(AddContextServer.boxed_clone(), cx)
+                                }),
+                        ),
+                    )
+                    .child(
+                        h_flex().w_full().child(
+                            Button::new(
+                                "install-context-server-extensions",
+                                "Install MCP Extensions",
+                            )
+                            .style(ButtonStyle::Filled)
+                            .layer(ElevationIndex::ModalSurface)
+                            .full_width()
+                            .icon(IconName::Hammer)
+                            .icon_size(IconSize::Small)
+                            .icon_position(IconPosition::Start)
+                            .on_click(|_event, window, cx| {
+                                window.dispatch_action(
+                                    zed_actions::Extensions {
+                                        category_filter: Some(
+                                            ExtensionCategoryFilter::ContextServers,
+                                        ),
+                                    }
+                                    .boxed_clone(),
+                                    cx,
+                                )
+                            }),
+                        ),
+                    ),
+            )
+    }
+
+    fn render_context_server(
+        &self,
+        context_server_id: ContextServerId,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl use<> + IntoElement {
+        let tools_by_source = self.tools.read(cx).tools_by_source(cx);
+        let server_status = self
+            .context_server_store
+            .read(cx)
+            .status_for_server(&context_server_id)
+            .unwrap_or(ContextServerStatus::Stopped);
+        let server_configuration = self
+            .context_server_store
+            .read(cx)
+            .configuration_for_server(&context_server_id);
+
+        let is_running = matches!(server_status, ContextServerStatus::Running);
+        let item_id = SharedString::from(context_server_id.0.clone());
+        let is_from_extension = server_configuration
+            .as_ref()
+            .map(|config| {
+                matches!(
+                    config.as_ref(),
+                    ContextServerConfiguration::Extension { .. }
+                )
+            })
+            .unwrap_or(false);
+
+        let error = if let ContextServerStatus::Error(error) = server_status.clone() {
+            Some(error)
+        } else {
+            None
+        };
+
+        let are_tools_expanded = self
+            .expanded_context_server_tools
+            .get(&context_server_id)
+            .copied()
+            .unwrap_or_default();
+        let tools = tools_by_source
+            .get(&ToolSource::ContextServer {
+                id: context_server_id.0.clone().into(),
+            })
+            .map_or([].as_slice(), |tools| tools.as_slice());
+        let tool_count = tools.len();
+
+        let border_color = cx.theme().colors().border.opacity(0.6);
+
+        let (source_icon, source_tooltip) = if is_from_extension {
+            (
+                IconName::ZedMcpExtension,
+                "This MCP server was installed from an extension.",
+            )
+        } else {
+            (
+                IconName::ZedMcpCustom,
+                "This custom MCP server was installed directly.",
+            )
+        };
+
+        let (status_indicator, tooltip_text) = match server_status {
+            ContextServerStatus::Starting => (
+                Icon::new(IconName::LoadCircle)
+                    .size(IconSize::XSmall)
+                    .color(Color::Accent)
+                    .with_animation(
+                        SharedString::from(format!("{}-starting", context_server_id.0.clone(),)),
+                        Animation::new(Duration::from_secs(3)).repeat(),
+                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
+                    )
+                    .into_any_element(),
+                "Server is starting.",
+            ),
+            ContextServerStatus::Running => (
+                Indicator::dot().color(Color::Success).into_any_element(),
+                "Server is active.",
+            ),
+            ContextServerStatus::Error(_) => (
+                Indicator::dot().color(Color::Error).into_any_element(),
+                "Server has an error.",
+            ),
+            ContextServerStatus::Stopped => (
+                Indicator::dot().color(Color::Muted).into_any_element(),
+                "Server is stopped.",
+            ),
+        };
+
+        let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu")
+            .trigger_with_tooltip(
+                IconButton::new("context-server-config-menu", IconName::Settings)
+                    .icon_color(Color::Muted)
+                    .icon_size(IconSize::Small),
+                Tooltip::text("Open MCP server options"),
+            )
+            .anchor(Corner::TopRight)
+            .menu({
+                let fs = self.fs.clone();
+                let context_server_id = context_server_id.clone();
+                let language_registry = self.language_registry.clone();
+                let context_server_store = self.context_server_store.clone();
+                let workspace = self.workspace.clone();
+                move |window, cx| {
+                    Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
+                        menu.entry("Configure Server", None, {
+                            let context_server_id = context_server_id.clone();
+                            let language_registry = language_registry.clone();
+                            let workspace = workspace.clone();
+                            move |window, cx| {
+                                ConfigureContextServerModal::show_modal_for_existing_server(
+                                    context_server_id.clone(),
+                                    language_registry.clone(),
+                                    workspace.clone(),
+                                    window,
+                                    cx,
+                                )
+                                .detach_and_log_err(cx);
+                            }
+                        })
+                        .separator()
+                        .entry("Uninstall", None, {
+                            let fs = fs.clone();
+                            let context_server_id = context_server_id.clone();
+                            let context_server_store = context_server_store.clone();
+                            let workspace = workspace.clone();
+                            move |_, cx| {
+                                let is_provided_by_extension = context_server_store
+                                    .read(cx)
+                                    .configuration_for_server(&context_server_id)
+                                    .as_ref()
+                                    .map(|config| {
+                                        matches!(
+                                            config.as_ref(),
+                                            ContextServerConfiguration::Extension { .. }
+                                        )
+                                    })
+                                    .unwrap_or(false);
+
+                                let uninstall_extension_task = match (
+                                    is_provided_by_extension,
+                                    resolve_extension_for_context_server(&context_server_id, cx),
+                                ) {
+                                    (true, Some((id, manifest))) => {
+                                        if extension_only_provides_context_server(manifest.as_ref())
+                                        {
+                                            ExtensionStore::global(cx).update(cx, |store, cx| {
+                                                store.uninstall_extension(id, cx)
+                                            })
+                                        } else {
+                                            workspace.update(cx, |workspace, cx| {
+                                                show_unable_to_uninstall_extension_with_context_server(workspace, context_server_id.clone(), cx);
+                                            }).log_err();
+                                            Task::ready(Ok(()))
+                                        }
+                                    }
+                                    _ => Task::ready(Ok(())),
+                                };
+
+                                cx.spawn({
+                                    let fs = fs.clone();
+                                    let context_server_id = context_server_id.clone();
+                                    async move |cx| {
+                                        uninstall_extension_task.await?;
+                                        cx.update(|cx| {
+                                            update_settings_file::<ProjectSettings>(
+                                                fs.clone(),
+                                                cx,
+                                                {
+                                                    let context_server_id =
+                                                        context_server_id.clone();
+                                                    move |settings, _| {
+                                                        settings
+                                                            .context_servers
+                                                            .remove(&context_server_id.0);
+                                                    }
+                                                },
+                                            )
+                                        })
+                                    }
+                                })
+                                .detach_and_log_err(cx);
+                            }
+                        })
+                    }))
+                }
+            });
+
+        v_flex()
+            .id(item_id.clone())
+            .border_1()
+            .rounded_md()
+            .border_color(border_color)
+            .bg(cx.theme().colors().background.opacity(0.2))
+            .overflow_hidden()
+            .child(
+                h_flex()
+                    .p_1()
+                    .justify_between()
+                    .when(
+                        error.is_some() || are_tools_expanded && tool_count >= 1,
+                        |element| element.border_b_1().border_color(border_color),
+                    )
+                    .child(
+                        h_flex()
+                            .child(
+                                Disclosure::new(
+                                    "tool-list-disclosure",
+                                    are_tools_expanded || error.is_some(),
+                                )
+                                .disabled(tool_count == 0)
+                                .on_click(cx.listener({
+                                    let context_server_id = context_server_id.clone();
+                                    move |this, _event, _window, _cx| {
+                                        let is_open = this
+                                            .expanded_context_server_tools
+                                            .entry(context_server_id.clone())
+                                            .or_insert(false);
+
+                                        *is_open = !*is_open;
+                                    }
+                                })),
+                            )
+                            .child(
+                                h_flex()
+                                    .id(SharedString::from(format!("tooltip-{}", item_id)))
+                                    .h_full()
+                                    .w_3()
+                                    .mx_1()
+                                    .justify_center()
+                                    .tooltip(Tooltip::text(tooltip_text))
+                                    .child(status_indicator),
+                            )
+                            .child(Label::new(item_id).ml_0p5())
+                            .child(
+                                div()
+                                    .id("extension-source")
+                                    .mt_0p5()
+                                    .mx_1()
+                                    .tooltip(Tooltip::text(source_tooltip))
+                                    .child(
+                                        Icon::new(source_icon)
+                                            .size(IconSize::Small)
+                                            .color(Color::Muted),
+                                    ),
+                            )
+                            .when(is_running, |this| {
+                                this.child(
+                                    Label::new(if tool_count == 1 {
+                                        SharedString::from("1 tool")
+                                    } else {
+                                        SharedString::from(format!("{} tools", tool_count))
+                                    })
+                                    .color(Color::Muted)
+                                    .size(LabelSize::Small),
+                                )
+                            }),
+                    )
+                    .child(
+                        h_flex()
+                            .gap_1()
+                            .child(context_server_configuration_menu)
+                            .child(
+                                Switch::new("context-server-switch", is_running.into())
+                                    .color(SwitchColor::Accent)
+                                    .on_click({
+                                        let context_server_manager =
+                                            self.context_server_store.clone();
+                                        let context_server_id = context_server_id.clone();
+                                        let fs = self.fs.clone();
+
+                                        move |state, _window, cx| {
+                                            let is_enabled = match state {
+                                                ToggleState::Unselected
+                                                | ToggleState::Indeterminate => {
+                                                    context_server_manager.update(
+                                                        cx,
+                                                        |this, cx| {
+                                                            this.stop_server(
+                                                                &context_server_id,
+                                                                cx,
+                                                            )
+                                                            .log_err();
+                                                        },
+                                                    );
+                                                    false
+                                                }
+                                                ToggleState::Selected => {
+                                                    context_server_manager.update(
+                                                        cx,
+                                                        |this, cx| {
+                                                            if let Some(server) =
+                                                                this.get_server(&context_server_id)
+                                                            {
+                                                                this.start_server(server, cx);
+                                                            }
+                                                        },
+                                                    );
+                                                    true
+                                                }
+                                            };
+                                            update_settings_file::<ProjectSettings>(
+                                                fs.clone(),
+                                                cx,
+                                                {
+                                                    let context_server_id =
+                                                        context_server_id.clone();
+
+                                                    move |settings, _| {
+                                                        settings
+                                                            .context_servers
+                                                            .entry(context_server_id.0)
+                                                            .or_insert_with(|| {
+                                                                ContextServerSettings::Extension {
+                                                                    enabled: is_enabled,
+                                                                    settings: serde_json::json!({}),
+                                                                }
+                                                            })
+                                                            .set_enabled(is_enabled);
+                                                    }
+                                                },
+                                            );
+                                        }
+                                    }),
+                            ),
+                    ),
+            )
+            .map(|parent| {
+                if let Some(error) = error {
+                    return parent.child(
+                        h_flex()
+                            .p_2()
+                            .gap_2()
+                            .items_start()
+                            .child(
+                                h_flex()
+                                    .flex_none()
+                                    .h(window.line_height() / 1.6_f32)
+                                    .justify_center()
+                                    .child(
+                                        Icon::new(IconName::XCircle)
+                                            .size(IconSize::XSmall)
+                                            .color(Color::Error),
+                                    ),
+                            )
+                            .child(
+                                div().w_full().child(
+                                    Label::new(error)
+                                        .buffer_font(cx)
+                                        .color(Color::Muted)
+                                        .size(LabelSize::Small),
+                                ),
+                            ),
+                    );
+                }
+
+                if !are_tools_expanded || tools.is_empty() {
+                    return parent;
+                }
+
+                parent.child(v_flex().py_1p5().px_1().gap_1().children(
+                    tools.into_iter().enumerate().map(|(ix, tool)| {
+                        h_flex()
+                            .id(("tool-item", ix))
+                            .px_1()
+                            .gap_2()
+                            .justify_between()
+                            .hover(|style| style.bg(cx.theme().colors().element_hover))
+                            .rounded_sm()
+                            .child(
+                                Label::new(tool.name())
+                                    .buffer_font(cx)
+                                    .size(LabelSize::Small),
+                            )
+                            .child(
+                                Icon::new(IconName::Info)
+                                    .size(IconSize::Small)
+                                    .color(Color::Ignored),
+                            )
+                            .tooltip(Tooltip::text(tool.description()))
+                    }),
+                ))
+            })
+    }
+}
+
+impl Render for AgentConfiguration {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex()
+            .id("assistant-configuration")
+            .key_context("AgentConfiguration")
+            .track_focus(&self.focus_handle(cx))
+            .relative()
+            .size_full()
+            .pb_8()
+            .bg(cx.theme().colors().panel_background)
+            .child(
+                v_flex()
+                    .id("assistant-configuration-content")
+                    .track_scroll(&self.scroll_handle)
+                    .size_full()
+                    .overflow_y_scroll()
+                    .child(self.render_general_settings_section(cx))
+                    .child(self.render_context_servers_section(window, cx))
+                    .child(self.render_provider_configuration_section(cx)),
+            )
+            .child(
+                div()
+                    .id("assistant-configuration-scrollbar")
+                    .occlude()
+                    .absolute()
+                    .right(px(3.))
+                    .top_0()
+                    .bottom_0()
+                    .pb_6()
+                    .w(px(12.))
+                    .cursor_default()
+                    .on_mouse_move(cx.listener(|_, _, _window, cx| {
+                        cx.notify();
+                        cx.stop_propagation()
+                    }))
+                    .on_hover(|_, _window, cx| {
+                        cx.stop_propagation();
+                    })
+                    .on_any_mouse_down(|_, _window, cx| {
+                        cx.stop_propagation();
+                    })
+                    .on_scroll_wheel(cx.listener(|_, _, _window, cx| {
+                        cx.notify();
+                    }))
+                    .children(Scrollbar::vertical(self.scrollbar_state.clone())),
+            )
+    }
+}
+
+fn extension_only_provides_context_server(manifest: &ExtensionManifest) -> bool {
+    manifest.context_servers.len() == 1
+        && manifest.themes.is_empty()
+        && manifest.icon_themes.is_empty()
+        && manifest.languages.is_empty()
+        && manifest.grammars.is_empty()
+        && manifest.language_servers.is_empty()
+        && manifest.slash_commands.is_empty()
+        && manifest.indexed_docs_providers.is_empty()
+        && manifest.snippets.is_none()
+        && manifest.debug_locators.is_empty()
+}
+
+pub(crate) fn resolve_extension_for_context_server(
+    id: &ContextServerId,
+    cx: &App,
+) -> Option<(Arc<str>, Arc<ExtensionManifest>)> {
+    ExtensionStore::global(cx)
+        .read(cx)
+        .installed_extensions()
+        .iter()
+        .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0))
+        .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
+}
+
+// This notification appears when trying to delete
+// an MCP server extension that not only provides
+// the server, but other things, too, like language servers and more.
+fn show_unable_to_uninstall_extension_with_context_server(
+    workspace: &mut Workspace,
+    id: ContextServerId,
+    cx: &mut App,
+) {
+    let workspace_handle = workspace.weak_handle();
+    let context_server_id = id.clone();
+
+    let status_toast = StatusToast::new(
+        format!(
+            "The {} extension provides more than just the MCP server. Proceed to uninstall anyway?",
+            id.0
+        ),
+        cx,
+        move |this, _cx| {
+            let workspace_handle = workspace_handle.clone();
+            let context_server_id = context_server_id.clone();
+
+            this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning))
+                .dismiss_button(true)
+                .action("Uninstall", move |_, _cx| {
+                    if let Some((extension_id, _)) =
+                        resolve_extension_for_context_server(&context_server_id, _cx)
+                    {
+                        ExtensionStore::global(_cx).update(_cx, |store, cx| {
+                            store
+                                .uninstall_extension(extension_id, cx)
+                                .detach_and_log_err(cx);
+                        });
+
+                        workspace_handle
+                            .update(_cx, |workspace, cx| {
+                                let fs = workspace.app_state().fs.clone();
+                                cx.spawn({
+                                    let context_server_id = context_server_id.clone();
+                                    async move |_workspace_handle, cx| {
+                                        cx.update(|cx| {
+                                            update_settings_file::<ProjectSettings>(
+                                                fs,
+                                                cx,
+                                                move |settings, _| {
+                                                    settings
+                                                        .context_servers
+                                                        .remove(&context_server_id.0);
+                                                },
+                                            );
+                                        })?;
+                                        anyhow::Ok(())
+                                    }
+                                })
+                                .detach_and_log_err(cx);
+                            })
+                            .log_err();
+                    }
+                })
+        },
+    );
+
+    workspace.toggle_status_toast(status_toast, cx);
+}

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

@@ -0,0 +1,763 @@
+use std::{
+    sync::{Arc, Mutex},
+    time::Duration,
+};
+
+use anyhow::{Context as _, Result};
+use context_server::{ContextServerCommand, ContextServerId};
+use editor::{Editor, EditorElement, EditorStyle};
+use gpui::{
+    Animation, AnimationExt as _, AsyncWindowContext, DismissEvent, Entity, EventEmitter,
+    FocusHandle, Focusable, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle,
+    WeakEntity, percentage, prelude::*,
+};
+use language::{Language, LanguageRegistry};
+use markdown::{Markdown, MarkdownElement, MarkdownStyle};
+use notifications::status_toast::{StatusToast, ToastIcon};
+use project::{
+    context_server_store::{
+        ContextServerStatus, ContextServerStore, registry::ContextServerDescriptorRegistry,
+    },
+    project_settings::{ContextServerSettings, ProjectSettings},
+    worktree_store::WorktreeStore,
+};
+use settings::{Settings as _, update_settings_file};
+use theme::ThemeSettings;
+use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*};
+use util::ResultExt as _;
+use workspace::{ModalView, Workspace};
+
+use crate::AddContextServer;
+
+enum ConfigurationTarget {
+    New,
+    Existing {
+        id: ContextServerId,
+        command: ContextServerCommand,
+    },
+    Extension {
+        id: ContextServerId,
+        repository_url: Option<SharedString>,
+        installation: Option<extension::ContextServerConfiguration>,
+    },
+}
+
+enum ConfigurationSource {
+    New {
+        editor: Entity<Editor>,
+    },
+    Existing {
+        editor: Entity<Editor>,
+    },
+    Extension {
+        id: ContextServerId,
+        editor: Option<Entity<Editor>>,
+        repository_url: Option<SharedString>,
+        installation_instructions: Option<Entity<markdown::Markdown>>,
+        settings_validator: Option<jsonschema::Validator>,
+    },
+}
+
+impl ConfigurationSource {
+    fn has_configuration_options(&self) -> bool {
+        !matches!(self, ConfigurationSource::Extension { editor: None, .. })
+    }
+
+    fn is_new(&self) -> bool {
+        matches!(self, ConfigurationSource::New { .. })
+    }
+
+    fn from_target(
+        target: ConfigurationTarget,
+        language_registry: Arc<LanguageRegistry>,
+        jsonc_language: Option<Arc<Language>>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Self {
+        fn create_editor(
+            json: String,
+            jsonc_language: Option<Arc<Language>>,
+            window: &mut Window,
+            cx: &mut App,
+        ) -> Entity<Editor> {
+            cx.new(|cx| {
+                let mut editor = Editor::auto_height(4, 16, window, cx);
+                editor.set_text(json, window, cx);
+                editor.set_show_gutter(false, cx);
+                editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx);
+                if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
+                    buffer.update(cx, |buffer, cx| buffer.set_language(jsonc_language, cx))
+                }
+                editor
+            })
+        }
+
+        match target {
+            ConfigurationTarget::New => ConfigurationSource::New {
+                editor: create_editor(context_server_input(None), jsonc_language, window, cx),
+            },
+            ConfigurationTarget::Existing { id, command } => ConfigurationSource::Existing {
+                editor: create_editor(
+                    context_server_input(Some((id, command))),
+                    jsonc_language,
+                    window,
+                    cx,
+                ),
+            },
+            ConfigurationTarget::Extension {
+                id,
+                repository_url,
+                installation,
+            } => {
+                let settings_validator = installation.as_ref().and_then(|installation| {
+                    jsonschema::validator_for(&installation.settings_schema)
+                        .context("Failed to load JSON schema for context server settings")
+                        .log_err()
+                });
+                let installation_instructions = installation.as_ref().map(|installation| {
+                    cx.new(|cx| {
+                        Markdown::new(
+                            installation.installation_instructions.clone().into(),
+                            Some(language_registry.clone()),
+                            None,
+                            cx,
+                        )
+                    })
+                });
+                ConfigurationSource::Extension {
+                    id,
+                    repository_url,
+                    installation_instructions,
+                    settings_validator,
+                    editor: installation.map(|installation| {
+                        create_editor(installation.default_settings, jsonc_language, window, cx)
+                    }),
+                }
+            }
+        }
+    }
+
+    fn output(&self, cx: &mut App) -> Result<(ContextServerId, ContextServerSettings)> {
+        match self {
+            ConfigurationSource::New { editor } | ConfigurationSource::Existing { editor } => {
+                parse_input(&editor.read(cx).text(cx)).map(|(id, command)| {
+                    (
+                        id,
+                        ContextServerSettings::Custom {
+                            enabled: true,
+                            command,
+                        },
+                    )
+                })
+            }
+            ConfigurationSource::Extension {
+                id,
+                editor,
+                settings_validator,
+                ..
+            } => {
+                let text = editor
+                    .as_ref()
+                    .context("No output available")?
+                    .read(cx)
+                    .text(cx);
+                let settings = serde_json_lenient::from_str::<serde_json::Value>(&text)?;
+                if let Some(settings_validator) = settings_validator {
+                    if let Err(error) = settings_validator.validate(&settings) {
+                        return Err(anyhow::anyhow!(error.to_string()));
+                    }
+                }
+                Ok((
+                    id.clone(),
+                    ContextServerSettings::Extension {
+                        enabled: true,
+                        settings,
+                    },
+                ))
+            }
+        }
+    }
+}
+
+fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)>) -> String {
+    let (name, path, args, env) = match existing {
+        Some((id, cmd)) => {
+            let args = serde_json::to_string(&cmd.args).unwrap();
+            let env = serde_json::to_string(&cmd.env.unwrap_or_default()).unwrap();
+            (id.0.to_string(), cmd.path, args, env)
+        }
+        None => (
+            "some-mcp-server".to_string(),
+            "".to_string(),
+            "[]".to_string(),
+            "{}".to_string(),
+        ),
+    };
+
+    format!(
+        r#"{{
+  /// The name of your MCP server
+  "{name}": {{
+    "command": {{
+      /// The path to the executable
+      "path": "{path}",
+      /// The arguments to pass to the executable
+      "args": {args},
+      /// The environment variables to set for the executable
+      "env": {env}
+    }}
+  }}
+}}"#
+    )
+}
+
+fn resolve_context_server_extension(
+    id: ContextServerId,
+    worktree_store: Entity<WorktreeStore>,
+    cx: &mut App,
+) -> Task<Option<ConfigurationTarget>> {
+    let registry = ContextServerDescriptorRegistry::default_global(cx).read(cx);
+
+    let Some(descriptor) = registry.context_server_descriptor(&id.0) else {
+        return Task::ready(None);
+    };
+
+    let extension = crate::agent_configuration::resolve_extension_for_context_server(&id, cx);
+    cx.spawn(async move |cx| {
+        let installation = descriptor
+            .configuration(worktree_store, cx)
+            .await
+            .context("Failed to resolve context server configuration")
+            .log_err()
+            .flatten();
+
+        Some(ConfigurationTarget::Extension {
+            id,
+            repository_url: extension
+                .and_then(|(_, manifest)| manifest.repository.clone().map(SharedString::from)),
+            installation,
+        })
+    })
+}
+
+enum State {
+    Idle,
+    Waiting,
+    Error(SharedString),
+}
+
+pub struct ConfigureContextServerModal {
+    context_server_store: Entity<ContextServerStore>,
+    workspace: WeakEntity<Workspace>,
+    source: ConfigurationSource,
+    state: State,
+}
+
+impl ConfigureContextServerModal {
+    pub fn register(
+        workspace: &mut Workspace,
+        language_registry: Arc<LanguageRegistry>,
+        _window: Option<&mut Window>,
+        _cx: &mut Context<Workspace>,
+    ) {
+        workspace.register_action({
+            let language_registry = language_registry.clone();
+            move |_workspace, _: &AddContextServer, window, cx| {
+                let workspace_handle = cx.weak_entity();
+                let language_registry = language_registry.clone();
+                window
+                    .spawn(cx, async move |cx| {
+                        Self::show_modal(
+                            ConfigurationTarget::New,
+                            language_registry,
+                            workspace_handle,
+                            cx,
+                        )
+                        .await
+                    })
+                    .detach_and_log_err(cx);
+            }
+        });
+    }
+
+    pub fn show_modal_for_existing_server(
+        server_id: ContextServerId,
+        language_registry: Arc<LanguageRegistry>,
+        workspace: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task<Result<()>> {
+        let Some(settings) = ProjectSettings::get_global(cx)
+            .context_servers
+            .get(&server_id.0)
+            .cloned()
+            .or_else(|| {
+                ContextServerDescriptorRegistry::default_global(cx)
+                    .read(cx)
+                    .context_server_descriptor(&server_id.0)
+                    .map(|_| ContextServerSettings::default_extension())
+            })
+        else {
+            return Task::ready(Err(anyhow::anyhow!("Context server not found")));
+        };
+
+        window.spawn(cx, async move |cx| {
+            let target = match settings {
+                ContextServerSettings::Custom {
+                    enabled: _,
+                    command,
+                } => Some(ConfigurationTarget::Existing {
+                    id: server_id,
+                    command,
+                }),
+                ContextServerSettings::Extension { .. } => {
+                    match workspace
+                        .update(cx, |workspace, cx| {
+                            resolve_context_server_extension(
+                                server_id,
+                                workspace.project().read(cx).worktree_store(),
+                                cx,
+                            )
+                        })
+                        .ok()
+                    {
+                        Some(task) => task.await,
+                        None => None,
+                    }
+                }
+            };
+
+            match target {
+                Some(target) => Self::show_modal(target, language_registry, workspace, cx).await,
+                None => Err(anyhow::anyhow!("Failed to resolve context server")),
+            }
+        })
+    }
+
+    fn show_modal(
+        target: ConfigurationTarget,
+        language_registry: Arc<LanguageRegistry>,
+        workspace: WeakEntity<Workspace>,
+        cx: &mut AsyncWindowContext,
+    ) -> Task<Result<()>> {
+        cx.spawn(async move |cx| {
+            let jsonc_language = language_registry.language_for_name("jsonc").await.ok();
+            workspace.update_in(cx, |workspace, window, cx| {
+                let workspace_handle = cx.weak_entity();
+                let context_server_store = workspace.project().read(cx).context_server_store();
+                workspace.toggle_modal(window, cx, |window, cx| Self {
+                    context_server_store,
+                    workspace: workspace_handle,
+                    state: State::Idle,
+                    source: ConfigurationSource::from_target(
+                        target,
+                        language_registry,
+                        jsonc_language,
+                        window,
+                        cx,
+                    ),
+                })
+            })
+        })
+    }
+
+    fn set_error(&mut self, err: impl Into<SharedString>, cx: &mut Context<Self>) {
+        self.state = State::Error(err.into());
+        cx.notify();
+    }
+
+    fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context<Self>) {
+        self.state = State::Idle;
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+
+        let (id, settings) = match self.source.output(cx) {
+            Ok(val) => val,
+            Err(error) => {
+                self.set_error(error.to_string(), cx);
+                return;
+            }
+        };
+
+        self.state = State::Waiting;
+        let wait_for_context_server_task =
+            wait_for_context_server(&self.context_server_store, id.clone(), cx);
+        cx.spawn({
+            let id = id.clone();
+            async move |this, cx| {
+                let result = wait_for_context_server_task.await;
+                this.update(cx, |this, cx| match result {
+                    Ok(_) => {
+                        this.state = State::Idle;
+                        this.show_configured_context_server_toast(id, cx);
+                        cx.emit(DismissEvent);
+                    }
+                    Err(err) => {
+                        this.set_error(err, cx);
+                    }
+                })
+            }
+        })
+        .detach();
+
+        // When we write the settings to the file, the context server will be restarted.
+        workspace.update(cx, |workspace, cx| {
+            let fs = workspace.app_state().fs.clone();
+            update_settings_file::<ProjectSettings>(fs.clone(), cx, |project_settings, _| {
+                project_settings.context_servers.insert(id.0, settings);
+            });
+        });
+    }
+
+    fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context<Self>) {
+        cx.emit(DismissEvent);
+    }
+
+    fn show_configured_context_server_toast(&self, id: ContextServerId, cx: &mut App) {
+        self.workspace
+            .update(cx, {
+                |workspace, cx| {
+                    let status_toast = StatusToast::new(
+                        format!("{} configured successfully.", id.0),
+                        cx,
+                        |this, _cx| {
+                            this.icon(ToastIcon::new(IconName::Hammer).color(Color::Muted))
+                                .action("Dismiss", |_, _| {})
+                        },
+                    );
+
+                    workspace.toggle_status_toast(status_toast, cx);
+                }
+            })
+            .log_err();
+    }
+}
+
+fn parse_input(text: &str) -> Result<(ContextServerId, ContextServerCommand)> {
+    let value: serde_json::Value = serde_json_lenient::from_str(text)?;
+    let object = value.as_object().context("Expected object")?;
+    anyhow::ensure!(object.len() == 1, "Expected exactly one key-value pair");
+    let (context_server_name, value) = object.into_iter().next().unwrap();
+    let command = value.get("command").context("Expected command")?;
+    let command: ContextServerCommand = serde_json::from_value(command.clone())?;
+    Ok((ContextServerId(context_server_name.clone().into()), command))
+}
+
+impl ModalView for ConfigureContextServerModal {}
+
+impl Focusable for ConfigureContextServerModal {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        match &self.source {
+            ConfigurationSource::New { editor } => editor.focus_handle(cx),
+            ConfigurationSource::Existing { editor, .. } => editor.focus_handle(cx),
+            ConfigurationSource::Extension { editor, .. } => editor
+                .as_ref()
+                .map(|editor| editor.focus_handle(cx))
+                .unwrap_or_else(|| cx.focus_handle()),
+        }
+    }
+}
+
+impl EventEmitter<DismissEvent> for ConfigureContextServerModal {}
+
+impl ConfigureContextServerModal {
+    fn render_modal_header(&self) -> ModalHeader {
+        let text: SharedString = match &self.source {
+            ConfigurationSource::New { .. } => "Add MCP Server".into(),
+            ConfigurationSource::Existing { .. } => "Configure MCP Server".into(),
+            ConfigurationSource::Extension { id, .. } => format!("Configure {}", id.0).into(),
+        };
+        ModalHeader::new().headline(text)
+    }
+
+    fn render_modal_description(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
+        const MODAL_DESCRIPTION: &'static str = "Visit the MCP server configuration docs to find all necessary arguments and environment variables.";
+
+        if let ConfigurationSource::Extension {
+            installation_instructions: Some(installation_instructions),
+            ..
+        } = &self.source
+        {
+            div()
+                .pb_2()
+                .text_sm()
+                .child(MarkdownElement::new(
+                    installation_instructions.clone(),
+                    default_markdown_style(window, cx),
+                ))
+                .into_any_element()
+        } else {
+            Label::new(MODAL_DESCRIPTION)
+                .color(Color::Muted)
+                .into_any_element()
+        }
+    }
+
+    fn render_modal_content(&self, cx: &App) -> AnyElement {
+        let editor = match &self.source {
+            ConfigurationSource::New { editor } => editor,
+            ConfigurationSource::Existing { editor } => editor,
+            ConfigurationSource::Extension { editor, .. } => {
+                let Some(editor) = editor else {
+                    return div().into_any_element();
+                };
+                editor
+            }
+        };
+
+        div()
+            .p_2()
+            .rounded_md()
+            .border_1()
+            .border_color(cx.theme().colors().border_variant)
+            .bg(cx.theme().colors().editor_background)
+            .child({
+                let settings = ThemeSettings::get_global(cx);
+                let text_style = TextStyle {
+                    color: cx.theme().colors().text,
+                    font_family: settings.buffer_font.family.clone(),
+                    font_fallbacks: settings.buffer_font.fallbacks.clone(),
+                    font_size: settings.buffer_font_size(cx).into(),
+                    font_weight: settings.buffer_font.weight,
+                    line_height: relative(settings.buffer_line_height.value()),
+                    ..Default::default()
+                };
+                EditorElement::new(
+                    editor,
+                    EditorStyle {
+                        background: cx.theme().colors().editor_background,
+                        local_player: cx.theme().players().local(),
+                        text: text_style,
+                        syntax: cx.theme().syntax().clone(),
+                        ..Default::default()
+                    },
+                )
+            })
+            .into_any_element()
+    }
+
+    fn render_modal_footer(&self, window: &mut Window, cx: &mut Context<Self>) -> ModalFooter {
+        let focus_handle = self.focus_handle(cx);
+        let is_connecting = matches!(self.state, State::Waiting);
+
+        ModalFooter::new()
+            .start_slot::<Button>(
+                if let ConfigurationSource::Extension {
+                    repository_url: Some(repository_url),
+                    ..
+                } = &self.source
+                {
+                    Some(
+                        Button::new("open-repository", "Open Repository")
+                            .icon(IconName::ArrowUpRight)
+                            .icon_color(Color::Muted)
+                            .icon_size(IconSize::XSmall)
+                            .tooltip({
+                                let repository_url = repository_url.clone();
+                                move |window, cx| {
+                                    Tooltip::with_meta(
+                                        "Open Repository",
+                                        None,
+                                        repository_url.clone(),
+                                        window,
+                                        cx,
+                                    )
+                                }
+                            })
+                            .on_click({
+                                let repository_url = repository_url.clone();
+                                move |_, _, cx| cx.open_url(&repository_url)
+                            }),
+                    )
+                } else {
+                    None
+                },
+            )
+            .end_slot(
+                h_flex()
+                    .gap_2()
+                    .child(
+                        Button::new(
+                            "cancel",
+                            if self.source.has_configuration_options() {
+                                "Cancel"
+                            } else {
+                                "Dismiss"
+                            },
+                        )
+                        .key_binding(
+                            KeyBinding::for_action_in(&menu::Cancel, &focus_handle, window, cx)
+                                .map(|kb| kb.size(rems_from_px(12.))),
+                        )
+                        .on_click(
+                            cx.listener(|this, _event, _window, cx| this.cancel(&menu::Cancel, cx)),
+                        ),
+                    )
+                    .children(self.source.has_configuration_options().then(|| {
+                        Button::new(
+                            "add-server",
+                            if self.source.is_new() {
+                                "Add Server"
+                            } else {
+                                "Configure Server"
+                            },
+                        )
+                        .disabled(is_connecting)
+                        .key_binding(
+                            KeyBinding::for_action_in(&menu::Confirm, &focus_handle, window, cx)
+                                .map(|kb| kb.size(rems_from_px(12.))),
+                        )
+                        .on_click(
+                            cx.listener(|this, _event, _window, cx| {
+                                this.confirm(&menu::Confirm, cx)
+                            }),
+                        )
+                    })),
+            )
+    }
+
+    fn render_waiting_for_context_server() -> Div {
+        h_flex()
+            .gap_2()
+            .child(
+                Icon::new(IconName::ArrowCircle)
+                    .size(IconSize::XSmall)
+                    .color(Color::Info)
+                    .with_animation(
+                        "arrow-circle",
+                        Animation::new(Duration::from_secs(2)).repeat(),
+                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
+                    )
+                    .into_any_element(),
+            )
+            .child(
+                Label::new("Waiting for Context Server")
+                    .size(LabelSize::Small)
+                    .color(Color::Muted),
+            )
+    }
+
+    fn render_modal_error(error: SharedString) -> Div {
+        h_flex()
+            .gap_2()
+            .child(
+                Icon::new(IconName::Warning)
+                    .size(IconSize::XSmall)
+                    .color(Color::Warning),
+            )
+            .child(
+                div()
+                    .w_full()
+                    .child(Label::new(error).size(LabelSize::Small).color(Color::Muted)),
+            )
+    }
+}
+
+impl Render for ConfigureContextServerModal {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        div()
+            .elevation_3(cx)
+            .w(rems(34.))
+            .key_context("ConfigureContextServerModal")
+            .on_action(
+                cx.listener(|this, _: &menu::Cancel, _window, cx| this.cancel(&menu::Cancel, cx)),
+            )
+            .on_action(
+                cx.listener(|this, _: &menu::Confirm, _window, cx| {
+                    this.confirm(&menu::Confirm, cx)
+                }),
+            )
+            .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
+                this.focus_handle(cx).focus(window);
+            }))
+            .child(
+                Modal::new("configure-context-server", None)
+                    .header(self.render_modal_header())
+                    .section(
+                        Section::new()
+                            .child(self.render_modal_description(window, cx))
+                            .child(self.render_modal_content(cx))
+                            .child(match &self.state {
+                                State::Idle => div(),
+                                State::Waiting => Self::render_waiting_for_context_server(),
+                                State::Error(error) => Self::render_modal_error(error.clone()),
+                            }),
+                    )
+                    .footer(self.render_modal_footer(window, cx)),
+            )
+    }
+}
+
+fn wait_for_context_server(
+    context_server_store: &Entity<ContextServerStore>,
+    context_server_id: ContextServerId,
+    cx: &mut App,
+) -> Task<Result<(), Arc<str>>> {
+    let (tx, rx) = futures::channel::oneshot::channel();
+    let tx = Arc::new(Mutex::new(Some(tx)));
+
+    let subscription = cx.subscribe(context_server_store, move |_, event, _cx| match event {
+        project::context_server_store::Event::ServerStatusChanged { server_id, status } => {
+            match status {
+                ContextServerStatus::Running => {
+                    if server_id == &context_server_id {
+                        if let Some(tx) = tx.lock().unwrap().take() {
+                            let _ = tx.send(Ok(()));
+                        }
+                    }
+                }
+                ContextServerStatus::Stopped => {
+                    if server_id == &context_server_id {
+                        if let Some(tx) = tx.lock().unwrap().take() {
+                            let _ = tx.send(Err("Context server stopped running".into()));
+                        }
+                    }
+                }
+                ContextServerStatus::Error(error) => {
+                    if server_id == &context_server_id {
+                        if let Some(tx) = tx.lock().unwrap().take() {
+                            let _ = tx.send(Err(error.clone()));
+                        }
+                    }
+                }
+                _ => {}
+            }
+        }
+    });
+
+    cx.spawn(async move |_cx| {
+        let result = rx.await.unwrap();
+        drop(subscription);
+        result
+    })
+}
+
+pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
+    let theme_settings = ThemeSettings::get_global(cx);
+    let colors = cx.theme().colors();
+    let mut text_style = window.text_style();
+    text_style.refine(&TextStyleRefinement {
+        font_family: Some(theme_settings.ui_font.family.clone()),
+        font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
+        font_features: Some(theme_settings.ui_font.features.clone()),
+        font_size: Some(TextSize::XSmall.rems(cx).into()),
+        color: Some(colors.text_muted),
+        ..Default::default()
+    });
+
+    MarkdownStyle {
+        base_text_style: text_style.clone(),
+        selection_background_color: colors.element_selection_background,
+        link: TextStyleRefinement {
+            background_color: Some(colors.editor_foreground.opacity(0.025)),
+            underline: Some(UnderlineStyle {
+                color: Some(colors.text_accent.opacity(0.5)),
+                thickness: px(1.),
+                ..Default::default()
+            }),
+            ..Default::default()
+        },
+        ..Default::default()
+    }
+}

crates/agent/src/agent_configuration/manage_profiles_modal.rs → crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs 🔗

@@ -2,25 +2,21 @@ mod profile_modal_header;
 
 use std::sync::Arc;
 
-use assistant_settings::{AgentProfile, AgentProfileId, AssistantSettings, builtin_profiles};
+use agent_settings::{AgentProfileId, AgentSettings, builtin_profiles};
 use assistant_tool::ToolWorkingSet;
-use convert_case::{Case, Casing as _};
 use editor::Editor;
 use fs::Fs;
-use gpui::{
-    DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, WeakEntity,
-    prelude::*,
-};
-use settings::{Settings as _, update_settings_file};
+use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, prelude::*};
+use settings::Settings as _;
 use ui::{
     KeyBinding, ListItem, ListItemSpacing, ListSeparator, Navigable, NavigableEntry, prelude::*,
 };
-use util::ResultExt as _;
 use workspace::{ModalView, Workspace};
 
 use crate::agent_configuration::manage_profiles_modal::profile_modal_header::ProfileModalHeader;
 use crate::agent_configuration::tool_picker::{ToolPicker, ToolPickerDelegate};
-use crate::{AgentPanel, ManageProfiles, ThreadStore};
+use crate::{AgentPanel, ManageProfiles};
+use agent::agent_profile::AgentProfile;
 
 use super::tool_picker::ToolPickerMode;
 
@@ -42,7 +38,7 @@ enum Mode {
 
 impl Mode {
     pub fn choose_profile(_window: &mut Window, cx: &mut Context<ManageProfilesModal>) -> Self {
-        let settings = AssistantSettings::get_global(cx);
+        let settings = AgentSettings::get_global(cx);
 
         let mut builtin_profiles = Vec::new();
         let mut custom_profiles = Vec::new();
@@ -103,7 +99,6 @@ pub struct NewProfileMode {
 pub struct ManageProfilesModal {
     fs: Arc<dyn Fs>,
     tools: Entity<ToolWorkingSet>,
-    thread_store: WeakEntity<ThreadStore>,
     focus_handle: FocusHandle,
     mode: Mode,
 }
@@ -119,9 +114,8 @@ impl ManageProfilesModal {
                 let fs = workspace.app_state().fs.clone();
                 let thread_store = panel.read(cx).thread_store();
                 let tools = thread_store.read(cx).tools();
-                let thread_store = thread_store.downgrade();
                 workspace.toggle_modal(window, cx, |window, cx| {
-                    let mut this = Self::new(fs, tools, thread_store, window, cx);
+                    let mut this = Self::new(fs, tools, window, cx);
 
                     if let Some(profile_id) = action.customize_tools.clone() {
                         this.configure_builtin_tools(profile_id, window, cx);
@@ -136,7 +130,6 @@ impl ManageProfilesModal {
     pub fn new(
         fs: Arc<dyn Fs>,
         tools: Entity<ToolWorkingSet>,
-        thread_store: WeakEntity<ThreadStore>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -145,7 +138,6 @@ impl ManageProfilesModal {
         Self {
             fs,
             tools,
-            thread_store,
             focus_handle,
             mode: Mode::choose_profile(window, cx),
         }
@@ -196,7 +188,7 @@ impl ManageProfilesModal {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let settings = AssistantSettings::get_global(cx);
+        let settings = AgentSettings::get_global(cx);
         let Some(profile) = settings.profiles.get(&profile_id).cloned() else {
             return;
         };
@@ -206,7 +198,6 @@ impl ManageProfilesModal {
                 ToolPickerMode::McpTools,
                 self.fs.clone(),
                 self.tools.clone(),
-                self.thread_store.clone(),
                 profile_id.clone(),
                 profile,
                 cx,
@@ -234,7 +225,7 @@ impl ManageProfilesModal {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let settings = AssistantSettings::get_global(cx);
+        let settings = AgentSettings::get_global(cx);
         let Some(profile) = settings.profiles.get(&profile_id).cloned() else {
             return;
         };
@@ -244,7 +235,6 @@ impl ManageProfilesModal {
                 ToolPickerMode::BuiltinTools,
                 self.fs.clone(),
                 self.tools.clone(),
-                self.thread_store.clone(),
                 profile_id.clone(),
                 profile,
                 cx,
@@ -270,32 +260,10 @@ impl ManageProfilesModal {
         match &self.mode {
             Mode::ChooseProfile { .. } => {}
             Mode::NewProfile(mode) => {
-                let settings = AssistantSettings::get_global(cx);
-
-                let base_profile = mode
-                    .base_profile_id
-                    .as_ref()
-                    .and_then(|profile_id| settings.profiles.get(profile_id).cloned());
-
                 let name = mode.name_editor.read(cx).text(cx);
-                let profile_id = AgentProfileId(name.to_case(Case::Kebab).into());
-
-                let profile = AgentProfile {
-                    name: name.into(),
-                    tools: base_profile
-                        .as_ref()
-                        .map(|profile| profile.tools.clone())
-                        .unwrap_or_default(),
-                    enable_all_context_servers: base_profile
-                        .as_ref()
-                        .map(|profile| profile.enable_all_context_servers)
-                        .unwrap_or_default(),
-                    context_servers: base_profile
-                        .map(|profile| profile.context_servers)
-                        .unwrap_or_default(),
-                };
-
-                self.create_profile(profile_id.clone(), profile, cx);
+
+                let profile_id =
+                    AgentProfile::create(name, mode.base_profile_id.clone(), self.fs.clone(), cx);
                 self.view_profile(profile_id, window, cx);
             }
             Mode::ViewProfile(_) => {}
@@ -325,19 +293,6 @@ impl ManageProfilesModal {
             }
         }
     }
-
-    fn create_profile(
-        &self,
-        profile_id: AgentProfileId,
-        profile: AgentProfile,
-        cx: &mut Context<Self>,
-    ) {
-        update_settings_file::<AssistantSettings>(self.fs.clone(), cx, {
-            move |settings, _cx| {
-                settings.create_profile(profile_id, profile).log_err();
-            }
-        });
-    }
 }
 
 impl ModalView for ManageProfilesModal {}
@@ -485,7 +440,7 @@ impl ManageProfilesModal {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
-        let settings = AssistantSettings::get_global(cx);
+        let settings = AgentSettings::get_global(cx);
 
         let base_profile_name = mode.base_profile_id.as_ref().map(|base_profile_id| {
             settings
@@ -518,16 +473,15 @@ impl ManageProfilesModal {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
-        let settings = AssistantSettings::get_global(cx);
+        let settings = AgentSettings::get_global(cx);
 
-        let profile_id = &settings.default_profile;
         let profile_name = settings
             .profiles
             .get(&mode.profile_id)
             .map(|profile| profile.name.clone())
             .unwrap_or_else(|| "Unknown".into());
 
-        let icon = match profile_id.as_str() {
+        let icon = match mode.profile_id.as_str() {
             "write" => IconName::Pencil,
             "ask" => IconName::MessageBubbles,
             _ => IconName::UserRoundPen,
@@ -712,7 +666,7 @@ impl ManageProfilesModal {
 
 impl Render for ManageProfilesModal {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let settings = AssistantSettings::get_global(cx);
+        let settings = AgentSettings::get_global(cx);
 
         let go_back_item = div()
             .id("cancel-item")

crates/agent/src/agent_configuration/tool_picker.rs → crates/agent_ui/src/agent_configuration/tool_picker.rs 🔗

@@ -1,19 +1,17 @@
 use std::{collections::BTreeMap, sync::Arc};
 
-use assistant_settings::{
-    AgentProfile, AgentProfileContent, AgentProfileId, AssistantSettings, AssistantSettingsContent,
+use agent_settings::{
+    AgentProfileContent, AgentProfileId, AgentProfileSettings, AgentSettings, AgentSettingsContent,
     ContextServerPresetContent,
 };
 use assistant_tool::{ToolSource, ToolWorkingSet};
 use fs::Fs;
 use gpui::{App, Context, DismissEvent, Entity, EventEmitter, Focusable, Task, WeakEntity, Window};
 use picker::{Picker, PickerDelegate};
-use settings::{Settings as _, update_settings_file};
+use settings::update_settings_file;
 use ui::{ListItem, ListItemSpacing, prelude::*};
 use util::ResultExt as _;
 
-use crate::ThreadStore;
-
 pub struct ToolPicker {
     picker: Entity<Picker<ToolPickerDelegate>>,
 }
@@ -71,11 +69,10 @@ pub enum PickerItem {
 
 pub struct ToolPickerDelegate {
     tool_picker: WeakEntity<ToolPicker>,
-    thread_store: WeakEntity<ThreadStore>,
     fs: Arc<dyn Fs>,
     items: Arc<Vec<PickerItem>>,
     profile_id: AgentProfileId,
-    profile: AgentProfile,
+    profile_settings: AgentProfileSettings,
     filtered_items: Vec<PickerItem>,
     selected_index: usize,
     mode: ToolPickerMode,
@@ -86,20 +83,18 @@ impl ToolPickerDelegate {
         mode: ToolPickerMode,
         fs: Arc<dyn Fs>,
         tool_set: Entity<ToolWorkingSet>,
-        thread_store: WeakEntity<ThreadStore>,
         profile_id: AgentProfileId,
-        profile: AgentProfile,
+        profile_settings: AgentProfileSettings,
         cx: &mut Context<ToolPicker>,
     ) -> Self {
         let items = Arc::new(Self::resolve_items(mode, &tool_set, cx));
 
         Self {
             tool_picker: cx.entity().downgrade(),
-            thread_store,
             fs,
             items,
             profile_id,
-            profile,
+            profile_settings,
             filtered_items: Vec::new(),
             selected_index: 0,
             mode,
@@ -249,67 +244,63 @@ impl PickerDelegate for ToolPickerDelegate {
         };
 
         let is_currently_enabled = if let Some(server_id) = server_id.clone() {
-            let preset = self.profile.context_servers.entry(server_id).or_default();
+            let preset = self
+                .profile_settings
+                .context_servers
+                .entry(server_id)
+                .or_default();
             let is_enabled = *preset.tools.entry(tool_name.clone()).or_default();
             *preset.tools.entry(tool_name.clone()).or_default() = !is_enabled;
             is_enabled
         } else {
-            let is_enabled = *self.profile.tools.entry(tool_name.clone()).or_default();
-            *self.profile.tools.entry(tool_name.clone()).or_default() = !is_enabled;
+            let is_enabled = *self
+                .profile_settings
+                .tools
+                .entry(tool_name.clone())
+                .or_default();
+            *self
+                .profile_settings
+                .tools
+                .entry(tool_name.clone())
+                .or_default() = !is_enabled;
             is_enabled
         };
 
-        let active_profile_id = &AssistantSettings::get_global(cx).default_profile;
-        if active_profile_id == &self.profile_id {
-            self.thread_store
-                .update(cx, |this, cx| {
-                    this.load_profile(self.profile.clone(), cx);
-                })
-                .log_err();
-        }
-
-        update_settings_file::<AssistantSettings>(self.fs.clone(), cx, {
+        update_settings_file::<AgentSettings>(self.fs.clone(), cx, {
             let profile_id = self.profile_id.clone();
-            let default_profile = self.profile.clone();
+            let default_profile = self.profile_settings.clone();
             let server_id = server_id.clone();
             let tool_name = tool_name.clone();
-            move |settings: &mut AssistantSettingsContent, _cx| {
-                settings
-                    .v2_setting(|v2_settings| {
-                        let profiles = v2_settings.profiles.get_or_insert_default();
-                        let profile =
-                            profiles
-                                .entry(profile_id)
-                                .or_insert_with(|| AgentProfileContent {
-                                    name: default_profile.name.into(),
-                                    tools: default_profile.tools,
-                                    enable_all_context_servers: Some(
-                                        default_profile.enable_all_context_servers,
-                                    ),
-                                    context_servers: default_profile
-                                        .context_servers
-                                        .into_iter()
-                                        .map(|(server_id, preset)| {
-                                            (
-                                                server_id,
-                                                ContextServerPresetContent {
-                                                    tools: preset.tools,
-                                                },
-                                            )
-                                        })
-                                        .collect(),
-                                });
-
-                        if let Some(server_id) = server_id {
-                            let preset = profile.context_servers.entry(server_id).or_default();
-                            *preset.tools.entry(tool_name).or_default() = !is_currently_enabled;
-                        } else {
-                            *profile.tools.entry(tool_name).or_default() = !is_currently_enabled;
-                        }
-
-                        Ok(())
-                    })
-                    .ok();
+            move |settings: &mut AgentSettingsContent, _cx| {
+                let profiles = settings.profiles.get_or_insert_default();
+                let profile = profiles
+                    .entry(profile_id)
+                    .or_insert_with(|| AgentProfileContent {
+                        name: default_profile.name.into(),
+                        tools: default_profile.tools,
+                        enable_all_context_servers: Some(
+                            default_profile.enable_all_context_servers,
+                        ),
+                        context_servers: default_profile
+                            .context_servers
+                            .into_iter()
+                            .map(|(server_id, preset)| {
+                                (
+                                    server_id,
+                                    ContextServerPresetContent {
+                                        tools: preset.tools,
+                                    },
+                                )
+                            })
+                            .collect(),
+                    });
+
+                if let Some(server_id) = server_id {
+                    let preset = profile.context_servers.entry(server_id).or_default();
+                    *preset.tools.entry(tool_name).or_default() = !is_currently_enabled;
+                } else {
+                    *profile.tools.entry(tool_name).or_default() = !is_currently_enabled;
+                }
             }
         });
     }
@@ -348,14 +339,18 @@ impl PickerDelegate for ToolPickerDelegate {
             ),
             PickerItem::Tool { name, server_id } => {
                 let is_enabled = if let Some(server_id) = server_id {
-                    self.profile
+                    self.profile_settings
                         .context_servers
                         .get(server_id.as_ref())
                         .and_then(|preset| preset.tools.get(name))
                         .copied()
-                        .unwrap_or(self.profile.enable_all_context_servers)
+                        .unwrap_or(self.profile_settings.enable_all_context_servers)
                 } else {
-                    self.profile.tools.get(name).copied().unwrap_or(false)
+                    self.profile_settings
+                        .tools
+                        .get(name)
+                        .copied()
+                        .unwrap_or(false)
                 };
 
                 Some(

crates/agent/src/agent_diff.rs → crates/agent_ui/src/agent_diff.rs 🔗

@@ -1,10 +1,12 @@
-use crate::{Keep, KeepAll, OpenAgentDiff, Reject, RejectAll, Thread, ThreadEvent};
+use crate::{Keep, KeepAll, OpenAgentDiff, Reject, RejectAll};
+use agent::{Thread, ThreadEvent};
+use agent_settings::AgentSettings;
 use anyhow::Result;
-use assistant_settings::AssistantSettings;
 use buffer_diff::DiffHunkStatus;
 use collections::{HashMap, HashSet};
 use editor::{
-    Direction, Editor, EditorEvent, EditorSettings, MultiBuffer, MultiBufferSnapshot, ToPoint,
+    Direction, Editor, EditorEvent, EditorSettings, MultiBuffer, MultiBufferSnapshot,
+    SelectionEffects, ToPoint,
     actions::{GoToHunk, GoToPreviousHunk},
     scroll::Autoscroll,
 };
@@ -31,7 +33,7 @@ use util::ResultExt;
 use workspace::{
     Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
     Workspace,
-    item::{BreadcrumbText, ItemEvent, TabContentParams},
+    item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
     searchable::SearchableItemHandle,
 };
 use zed_actions::assistant::ToggleFocus;
@@ -170,15 +172,9 @@ impl AgentDiffPane {
 
                     if let Some(first_hunk) = first_hunk {
                         let first_hunk_start = first_hunk.multi_buffer_range().start;
-                        editor.change_selections(
-                            Some(Autoscroll::fit()),
-                            window,
-                            cx,
-                            |selections| {
-                                selections
-                                    .select_anchor_ranges([first_hunk_start..first_hunk_start]);
-                            },
-                        )
+                        editor.change_selections(Default::default(), window, cx, |selections| {
+                            selections.select_anchor_ranges([first_hunk_start..first_hunk_start]);
+                        })
                     }
                 }
 
@@ -241,7 +237,7 @@ impl AgentDiffPane {
 
                 if let Some(first_hunk) = first_hunk {
                     let first_hunk_start = first_hunk.multi_buffer_range().start;
-                    editor.change_selections(Some(Autoscroll::fit()), window, cx, |selections| {
+                    editor.change_selections(Default::default(), window, cx, |selections| {
                         selections.select_anchor_ranges([first_hunk_start..first_hunk_start]);
                     })
                 }
@@ -415,7 +411,7 @@ fn update_editor_selection(
     };
 
     if let Some(target_hunk) = target_hunk {
-        editor.change_selections(Some(Autoscroll::fit()), window, cx, |selections| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
             let next_hunk_start = target_hunk.multi_buffer_range().start;
             selections.select_anchor_ranges([next_hunk_start..next_hunk_start]);
         })
@@ -532,12 +528,12 @@ impl Item for AgentDiffPane {
 
     fn save(
         &mut self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        self.editor.save(format, project, window, cx)
+        self.editor.save(options, project, window, cx)
     }
 
     fn save_as(
@@ -699,7 +695,7 @@ fn render_diff_hunk_controls(
         .rounded_b_md()
         .bg(cx.theme().colors().editor_background)
         .gap_1()
-        .occlude()
+        .block_mouse_except_scroll()
         .shadow_md()
         .children(vec![
             Button::new(("reject", row as u64), "Reject")
@@ -1086,7 +1082,7 @@ impl Render for AgentDiffToolbar {
                     .child(vertical_divider())
                     .when_some(editor.read(cx).workspace(), |this, _workspace| {
                         this.child(
-                            IconButton::new("review", IconName::ListCollapse)
+                            IconButton::new("review", IconName::ListTodo)
                                 .icon_size(IconSize::Small)
                                 .tooltip(Tooltip::for_action_title_in(
                                     "Review All Files",
@@ -1116,8 +1112,13 @@ impl Render for AgentDiffToolbar {
                     return Empty.into_any();
                 };
 
-                let is_generating = agent_diff.read(cx).thread.read(cx).is_generating();
-                if is_generating {
+                let has_pending_edit_tool_use = agent_diff
+                    .read(cx)
+                    .thread
+                    .read(cx)
+                    .has_pending_edit_tool_uses();
+
+                if has_pending_edit_tool_use {
                     return div().px_2().child(spinner_icon).into_any();
                 }
 
@@ -1253,9 +1254,9 @@ impl AgentDiff {
 
         let settings_subscription = cx.observe_global_in::<SettingsStore>(window, {
             let workspace = workspace.clone();
-            let mut was_active = AssistantSettings::get_global(cx).single_file_review;
+            let mut was_active = AgentSettings::get_global(cx).single_file_review;
             move |this, window, cx| {
-                let is_active = AssistantSettings::get_global(cx).single_file_review;
+                let is_active = AgentSettings::get_global(cx).single_file_review;
                 if was_active != is_active {
                     was_active = is_active;
                     this.update_reviewing_editors(&workspace, window, cx);
@@ -1348,6 +1349,7 @@ impl AgentDiff {
             ThreadEvent::NewRequest
             | ThreadEvent::Stopped(Ok(StopReason::EndTurn))
             | ThreadEvent::Stopped(Ok(StopReason::MaxTokens))
+            | ThreadEvent::Stopped(Ok(StopReason::Refusal))
             | ThreadEvent::Stopped(Err(_))
             | ThreadEvent::ShowError(_)
             | ThreadEvent::CompletionCanceled => {
@@ -1371,7 +1373,10 @@ impl AgentDiff {
             | ThreadEvent::ToolFinished { .. }
             | ThreadEvent::CheckpointChanged
             | ThreadEvent::ToolConfirmationNeeded
-            | ThreadEvent::CancelEditing => {}
+            | ThreadEvent::ToolUseLimitReached
+            | ThreadEvent::CancelEditing
+            | ThreadEvent::RetriesFailed { .. }
+            | ThreadEvent::ProfileChanged => {}
         }
     }
 
@@ -1460,10 +1465,13 @@ impl AgentDiff {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if !AssistantSettings::get_global(cx).single_file_review {
+        if !AgentSettings::get_global(cx).single_file_review {
             for (editor, _) in self.reviewing_editors.drain() {
                 editor
-                    .update(cx, |editor, cx| editor.end_temporary_diff_override(cx))
+                    .update(cx, |editor, cx| {
+                        editor.end_temporary_diff_override(cx);
+                        editor.unregister_addon::<EditorAgentDiffAddon>();
+                    })
                     .ok();
             }
             return;
@@ -1531,7 +1539,7 @@ impl AgentDiff {
                             let first_hunk_start = first_hunk.multi_buffer_range().start;
 
                             editor.change_selections(
-                                Some(Autoscroll::center()),
+                                SelectionEffects::scroll(Autoscroll::center()),
                                 window,
                                 cx,
                                 |selections| {
@@ -1559,7 +1567,10 @@ impl AgentDiff {
 
             if in_workspace {
                 editor
-                    .update(cx, |editor, cx| editor.end_temporary_diff_override(cx))
+                    .update(cx, |editor, cx| {
+                        editor.end_temporary_diff_override(cx);
+                        editor.unregister_addon::<EditorAgentDiffAddon>();
+                    })
                     .ok();
                 self.reviewing_editors.remove(&editor);
             }
@@ -1734,8 +1745,9 @@ impl editor::Addon for EditorAgentDiffAddon {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::{Keep, ThreadStore, thread_store};
-    use assistant_settings::AssistantSettings;
+    use crate::Keep;
+    use agent::thread_store::{self, ThreadStore};
+    use agent_settings::AgentSettings;
     use assistant_tool::ToolWorkingSet;
     use editor::EditorSettings;
     use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
@@ -1754,7 +1766,7 @@ mod tests {
             cx.set_global(settings_store);
             language::init(cx);
             Project::init_settings(cx);
-            AssistantSettings::register(cx);
+            AgentSettings::register(cx);
             prompt_store::init(cx);
             thread_store::init(cx);
             workspace::init_settings(cx);
@@ -1851,7 +1863,7 @@ mod tests {
 
         // Rejecting a hunk also moves the cursor to the next hunk, possibly cycling if it's at the end.
         editor.update_in(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |selections| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
                 selections.select_ranges([Point::new(10, 0)..Point::new(10, 0)])
             });
         });
@@ -1910,7 +1922,7 @@ mod tests {
             cx.set_global(settings_store);
             language::init(cx);
             Project::init_settings(cx);
-            AssistantSettings::register(cx);
+            AgentSettings::register(cx);
             prompt_store::init(cx);
             thread_store::init(cx);
             workspace::init_settings(cx);
@@ -2107,7 +2119,7 @@ mod tests {
 
         // Rejecting a hunk also moves the cursor to the next hunk, possibly cycling if it's at the end.
         editor1.update_in(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |selections| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
                 selections.select_ranges([Point::new(10, 0)..Point::new(10, 0)])
             });
         });

crates/agent/src/agent_model_selector.rs → crates/agent_ui/src/agent_model_selector.rs 🔗

@@ -1,21 +1,17 @@
-use assistant_settings::AssistantSettings;
+use crate::{
+    ModelUsageContext,
+    language_model_selector::{
+        LanguageModelSelector, ToggleModelSelector, language_model_selector,
+    },
+};
+use agent_settings::AgentSettings;
 use fs::Fs;
 use gpui::{Entity, FocusHandle, SharedString};
-
-use crate::Thread;
 use language_model::{ConfiguredModel, LanguageModelRegistry};
-use language_model_selector::{
-    LanguageModelSelector, LanguageModelSelectorPopoverMenu, ToggleModelSelector,
-};
+use picker::popover_menu::PickerPopoverMenu;
 use settings::update_settings_file;
 use std::sync::Arc;
-use ui::{PopoverMenuHandle, Tooltip, prelude::*};
-
-#[derive(Clone)]
-pub enum ModelType {
-    Default(Entity<Thread>),
-    InlineAssistant,
-}
+use ui::{ButtonLike, PopoverMenuHandle, Tooltip, prelude::*};
 
 pub struct AgentModelSelector {
     selector: Entity<LanguageModelSelector>,
@@ -28,28 +24,23 @@ impl AgentModelSelector {
         fs: Arc<dyn Fs>,
         menu_handle: PopoverMenuHandle<LanguageModelSelector>,
         focus_handle: FocusHandle,
-        model_type: ModelType,
+        model_usage_context: ModelUsageContext,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
         Self {
             selector: cx.new(move |cx| {
                 let fs = fs.clone();
-                LanguageModelSelector::new(
+                language_model_selector(
                     {
-                        let model_type = model_type.clone();
-                        move |cx| match &model_type {
-                            ModelType::Default(thread) => thread.read(cx).configured_model(),
-                            ModelType::InlineAssistant => {
-                                LanguageModelRegistry::read_global(cx).inline_assistant_model()
-                            }
-                        }
+                        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_type {
-                            ModelType::Default(thread) => {
+                        match &model_usage_context {
+                            ModelUsageContext::Thread(thread) => {
                                 thread.update(cx, |thread, cx| {
                                     let registry = LanguageModelRegistry::read_global(cx);
                                     if let Some(provider) = registry.provider(&model.provider_id())
@@ -63,7 +54,7 @@ impl AgentModelSelector {
                                         );
                                     }
                                 });
-                                update_settings_file::<AssistantSettings>(
+                                update_settings_file::<AgentSettings>(
                                     fs.clone(),
                                     cx,
                                     move |settings, _cx| {
@@ -71,8 +62,8 @@ impl AgentModelSelector {
                                     },
                                 );
                             }
-                            ModelType::InlineAssistant => {
-                                update_settings_file::<AssistantSettings>(
+                            ModelUsageContext::InlineAssistant => {
+                                update_settings_file::<AgentSettings>(
                                     fs.clone(),
                                     cx,
                                     move |settings, _cx| {
@@ -100,23 +91,38 @@ impl AgentModelSelector {
 }
 
 impl Render for AgentModelSelector {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let focus_handle = self.focus_handle.clone();
-
-        let model = self.selector.read(cx).active_model(cx);
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let model = self.selector.read(cx).delegate.active_model(cx);
         let model_name = model
+            .as_ref()
             .map(|model| model.model.name().0)
             .unwrap_or_else(|| SharedString::from("No model selected"));
+        let provider_icon = model
+            .as_ref()
+            .map(|model| model.provider.icon())
+            .unwrap_or_else(|| IconName::Ai);
+
+        let focus_handle = self.focus_handle.clone();
 
-        LanguageModelSelectorPopoverMenu::new(
+        PickerPopoverMenu::new(
             self.selector.clone(),
-            Button::new("active-model", model_name)
-                .label_size(LabelSize::Small)
-                .color(Color::Muted)
-                .icon(IconName::ChevronDown)
-                .icon_size(IconSize::XSmall)
-                .icon_position(IconPosition::End)
-                .icon_color(Color::Muted),
+            ButtonLike::new("active-model")
+                .child(
+                    Icon::new(provider_icon)
+                        .color(Color::Muted)
+                        .size(IconSize::XSmall),
+                )
+                .child(
+                    Label::new(model_name)
+                        .color(Color::Muted)
+                        .size(LabelSize::Small)
+                        .ml_0p5(),
+                )
+                .child(
+                    Icon::new(IconName::ChevronDown)
+                        .color(Color::Muted)
+                        .size(IconSize::XSmall),
+                ),
             move |window, cx| {
                 Tooltip::for_action_in(
                     "Change Model",
@@ -127,7 +133,9 @@ impl Render for AgentModelSelector {
                 )
             },
             gpui::Corner::BottomRight,
+            cx,
         )
         .with_handle(self.menu_handle.clone())
+        .render(window, cx)
     }
 }

crates/agent/src/agent_panel.rs → crates/agent_ui/src/agent_panel.rs 🔗

@@ -1,22 +1,41 @@
 use std::ops::Range;
 use std::path::Path;
+use std::rc::Rc;
 use std::sync::Arc;
 use std::time::Duration;
 
-use db::kvp::KEY_VALUE_STORE;
-use markdown::Markdown;
+use db::kvp::{Dismissable, KEY_VALUE_STORE};
 use serde::{Deserialize, Serialize};
 
-use anyhow::{Result, anyhow};
-use assistant_context_editor::{
-    AgentPanelDelegate, AssistantContext, ConfigurationError, ContextEditor, ContextEvent,
-    ContextSummary, SlashCommandCompletionProvider, humanize_token_count,
-    make_lsp_adapter_delegate, render_remaining_tokens,
+use crate::language_model_selector::ToggleModelSelector;
+use crate::{
+    AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
+    DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
+    NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell,
+    ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu,
+    active_thread::{self, ActiveThread, ActiveThreadEvent},
+    agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
+    agent_diff::AgentDiff,
+    message_editor::{MessageEditor, MessageEditorEvent},
+    slash_command::SlashCommandCompletionProvider,
+    text_thread_editor::{
+        AgentPanelDelegate, TextThreadEditor, humanize_token_count, make_lsp_adapter_delegate,
+        render_remaining_tokens,
+    },
+    thread_history::{HistoryEntryElement, ThreadHistory},
+    ui::AgentOnboardingModal,
+};
+use agent::{
+    Thread, ThreadError, ThreadEvent, ThreadId, ThreadSummary, TokenUsageRatio,
+    context_store::ContextStore,
+    history_store::{HistoryEntryId, HistoryStore},
+    thread_store::{TextThreadStore, ThreadStore},
 };
-use assistant_settings::{AssistantDockPosition, AssistantSettings};
+use agent_settings::{AgentDockPosition, AgentSettings, CompletionMode, DefaultView};
+use anyhow::{Result, anyhow};
+use assistant_context::{AssistantContext, ContextEvent, ContextSummary};
 use assistant_slash_command::SlashCommandWorkingSet;
 use assistant_tool::ToolWorkingSet;
-
 use client::{UserStore, zed_urls};
 use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
 use fs::Fs;
@@ -28,9 +47,8 @@ use gpui::{
 };
 use language::LanguageRegistry;
 use language_model::{
-    LanguageModelProviderTosView, LanguageModelRegistry, RequestUsage, ZED_CLOUD_PROVIDER_ID,
+    ConfigurationError, LanguageModelProviderTosView, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID,
 };
-use language_model_selector::ToggleModelSelector;
 use project::{Project, ProjectPath, Worktree};
 use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
 use proto::Plan;
@@ -41,34 +59,20 @@ use theme::ThemeSettings;
 use time::UtcOffset;
 use ui::utils::WithRemSize;
 use ui::{
-    Banner, CheckboxWithLabel, ContextMenu, KeyBinding, PopoverMenu, PopoverMenuHandle,
-    ProgressBar, Tab, Tooltip, Vector, VectorName, prelude::*,
+    Banner, CheckboxWithLabel, ContextMenu, ElevationIndex, KeyBinding, PopoverMenu,
+    PopoverMenuHandle, ProgressBar, Tab, Tooltip, Vector, VectorName, prelude::*,
 };
-use util::{ResultExt as _, maybe};
-use workspace::dock::{DockPosition, Panel, PanelEvent};
+use util::ResultExt as _;
 use workspace::{
     CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace,
+    dock::{DockPosition, Panel, PanelEvent},
 };
-use zed_actions::agent::{OpenConfiguration, OpenOnboardingModal, ResetOnboarding};
-use zed_actions::assistant::{OpenRulesLibrary, ToggleFocus};
-use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize};
-use zed_llm_client::UsageLimit;
-
-use crate::active_thread::{self, ActiveThread, ActiveThreadEvent};
-use crate::agent_configuration::{AgentConfiguration, AssistantConfigurationEvent};
-use crate::agent_diff::AgentDiff;
-use crate::history_store::{HistoryStore, RecentEntry};
-use crate::message_editor::{MessageEditor, MessageEditorEvent};
-use crate::thread::{Thread, ThreadError, ThreadId, ThreadSummary, TokenUsageRatio};
-use crate::thread_history::{HistoryEntryElement, ThreadHistory};
-use crate::thread_store::ThreadStore;
-use crate::ui::AgentOnboardingModal;
-use crate::{
-    AddContextServer, AgentDiffPane, ContextStore, DeleteRecentlyOpenThread, ExpandMessageEditor,
-    Follow, InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff,
-    OpenHistory, ResetTrialUpsell, TextThreadStore, ThreadEvent, ToggleContextPicker,
-    ToggleNavigationMenu, ToggleOptionsMenu,
+use zed_actions::{
+    DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
+    agent::{OpenConfiguration, OpenOnboardingModal, ResetOnboarding},
+    assistant::{OpenRulesLibrary, ToggleFocus},
 };
+use zed_llm_client::{CompletionIntent, UsageLimit};
 
 const AGENT_PANEL_KEY: &str = "agent_panel";
 
@@ -116,22 +120,32 @@ pub fn init(cx: &mut App) {
                 .register_action(|workspace, _: &OpenAgentDiff, window, cx| {
                     if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
                         workspace.focus_panel::<AgentPanel>(window, cx);
-                        let thread = panel.read(cx).thread.read(cx).thread().clone();
-                        AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
+                        match &panel.read(cx).active_view {
+                            ActiveView::Thread { thread, .. } => {
+                                let thread = thread.read(cx).thread().clone();
+                                AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
+                            }
+                            ActiveView::TextThread { .. }
+                            | ActiveView::History
+                            | ActiveView::Configuration => {}
+                        }
                     }
                 })
                 .register_action(|workspace, _: &Follow, window, cx| {
                     workspace.follow(CollaboratorId::Agent, window, cx);
                 })
                 .register_action(|workspace, _: &ExpandMessageEditor, window, cx| {
-                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
-                        workspace.focus_panel::<AgentPanel>(window, cx);
-                        panel.update(cx, |panel, cx| {
-                            panel.message_editor.update(cx, |editor, cx| {
+                    let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
+                        return;
+                    };
+                    workspace.focus_panel::<AgentPanel>(window, cx);
+                    panel.update(cx, |panel, cx| {
+                        if let Some(message_editor) = panel.active_message_editor() {
+                            message_editor.update(cx, |editor, cx| {
                                 editor.expand_message_editor(&ExpandMessageEditor, window, cx);
                             });
-                        });
-                    }
+                        }
+                    });
                 })
                 .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| {
                     if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
@@ -157,7 +171,10 @@ pub fn init(cx: &mut App) {
                     window.refresh();
                 })
                 .register_action(|_workspace, _: &ResetTrialUpsell, _window, cx| {
-                    set_trial_upsell_dismissed(false, cx);
+                    Upsell::set_dismissed(false, cx);
+                })
+                .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| {
+                    TrialEndUpsell::set_dismissed(false, cx);
                 });
         },
     )
@@ -166,12 +183,13 @@ pub fn init(cx: &mut App) {
 
 enum ActiveView {
     Thread {
+        thread: Entity<ActiveThread>,
         change_title_editor: Entity<Editor>,
-        thread: WeakEntity<Thread>,
+        message_editor: Entity<MessageEditor>,
         _subscriptions: Vec<gpui::Subscription>,
     },
-    PromptEditor {
-        context_editor: Entity<ContextEditor>,
+    TextThread {
+        context_editor: Entity<TextThreadEditor>,
         title_editor: Entity<Editor>,
         buffer_search_bar: Entity<BufferSearchBar>,
         _subscriptions: Vec<gpui::Subscription>,
@@ -190,13 +208,18 @@ impl ActiveView {
     pub fn which_font_size_used(&self) -> WhichFontSize {
         match self {
             ActiveView::Thread { .. } | ActiveView::History => WhichFontSize::AgentFont,
-            ActiveView::PromptEditor { .. } => WhichFontSize::BufferFont,
+            ActiveView::TextThread { .. } => WhichFontSize::BufferFont,
             ActiveView::Configuration => WhichFontSize::None,
         }
     }
 
-    pub fn thread(thread: Entity<Thread>, window: &mut Window, cx: &mut App) -> Self {
-        let summary = thread.read(cx).summary().or_default();
+    pub fn thread(
+        active_thread: Entity<ActiveThread>,
+        message_editor: Entity<MessageEditor>,
+        window: &mut Window,
+        cx: &mut Context<AgentPanel>,
+    ) -> Self {
+        let summary = active_thread.read(cx).summary(cx).or_default();
 
         let editor = cx.new(|cx| {
             let mut editor = Editor::single_line(window, cx);
@@ -205,20 +228,37 @@ impl ActiveView {
         });
 
         let subscriptions = vec![
+            cx.subscribe(&message_editor, |this, _, event, cx| match event {
+                MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => {
+                    cx.notify();
+                }
+                MessageEditorEvent::ScrollThreadToBottom => match &this.active_view {
+                    ActiveView::Thread { thread, .. } => {
+                        thread.update(cx, |thread, cx| {
+                            thread.scroll_to_bottom(cx);
+                        });
+                    }
+                    ActiveView::TextThread { .. }
+                    | ActiveView::History
+                    | ActiveView::Configuration => {}
+                },
+            }),
             window.subscribe(&editor, cx, {
                 {
-                    let thread = thread.clone();
+                    let thread = active_thread.clone();
                     move |editor, event, window, cx| match event {
                         EditorEvent::BufferEdited => {
                             let new_summary = editor.read(cx).text(cx);
 
                             thread.update(cx, |thread, cx| {
-                                thread.set_summary(new_summary, cx);
+                                thread.thread().update(cx, |thread, cx| {
+                                    thread.set_summary(new_summary, cx);
+                                });
                             })
                         }
                         EditorEvent::Blurred => {
                             if editor.read(cx).text(cx).is_empty() {
-                                let summary = thread.read(cx).summary().or_default();
+                                let summary = thread.read(cx).summary(cx).or_default();
 
                                 editor.update(cx, |editor, cx| {
                                     editor.set_text(summary, window, cx);
@@ -229,9 +269,14 @@ impl ActiveView {
                     }
                 }
             }),
-            window.subscribe(&thread, cx, {
+            cx.subscribe(&active_thread, |_, _, event, cx| match &event {
+                ActiveThreadEvent::EditingMessageTokenCountChanged => {
+                    cx.notify();
+                }
+            }),
+            cx.subscribe_in(&active_thread.read(cx).thread().clone(), window, {
                 let editor = editor.clone();
-                move |thread, event, window, cx| match event {
+                move |_, thread, event, window, cx| match event {
                     ThreadEvent::SummaryGenerated => {
                         let summary = thread.read(cx).summary().or_default();
 
@@ -239,6 +284,9 @@ impl ActiveView {
                             editor.set_text(summary, window, cx);
                         })
                     }
+                    ThreadEvent::MessageAdded(_) => {
+                        cx.notify();
+                    }
                     _ => {}
                 }
             }),
@@ -246,13 +294,15 @@ impl ActiveView {
 
         Self::Thread {
             change_title_editor: editor,
-            thread: thread.downgrade(),
+            thread: active_thread,
+            message_editor: message_editor,
             _subscriptions: subscriptions,
         }
     }
 
     pub fn prompt_editor(
-        context_editor: Entity<ContextEditor>,
+        context_editor: Entity<TextThreadEditor>,
+        history_store: Entity<HistoryStore>,
         language_registry: Arc<LanguageRegistry>,
         window: &mut Window,
         cx: &mut App,
@@ -318,6 +368,19 @@ impl ActiveView {
                             editor.set_text(summary, window, cx);
                         })
                     }
+                    ContextEvent::PathChanged { old_path, new_path } => {
+                        history_store.update(cx, |history_store, cx| {
+                            if let Some(old_path) = old_path {
+                                history_store
+                                    .replace_recently_opened_text_thread(old_path, new_path, cx);
+                            } else {
+                                history_store.push_recently_opened_entry(
+                                    HistoryEntryId::Context(new_path.clone()),
+                                    cx,
+                                );
+                            }
+                        });
+                    }
                     _ => {}
                 }
             }),
@@ -329,7 +392,7 @@ impl ActiveView {
             buffer_search_bar.set_active_pane_item(Some(&context_editor), window, cx)
         });
 
-        Self::PromptEditor {
+        Self::TextThread {
             context_editor,
             title_editor: editor,
             buffer_search_bar,
@@ -345,13 +408,10 @@ pub struct AgentPanel {
     fs: Arc<dyn Fs>,
     language_registry: Arc<LanguageRegistry>,
     thread_store: Entity<ThreadStore>,
-    thread: Entity<ActiveThread>,
-    message_editor: Entity<MessageEditor>,
-    _active_thread_subscriptions: Vec<Subscription>,
     _default_model_subscription: Subscription,
     context_store: Entity<TextThreadStore>,
     prompt_store: Option<Entity<PromptStore>>,
-    inline_assist_context_store: Entity<crate::context_store::ContextStore>,
+    inline_assist_context_store: Entity<ContextStore>,
     configuration: Option<Entity<AgentConfiguration>>,
     configuration_subscription: Option<Subscription>,
     local_timezone: UtcOffset,
@@ -367,8 +427,7 @@ pub struct AgentPanel {
     height: Option<Pixels>,
     zoomed: bool,
     pending_serialization: Option<Task<Result<()>>>,
-    hide_trial_upsell: bool,
-    _trial_markdown: Entity<Markdown>,
+    hide_upsell: bool,
 }
 
 impl AgentPanel {
@@ -413,7 +472,7 @@ impl AgentPanel {
             let context_store = workspace
                 .update(cx, |workspace, cx| {
                     let project = workspace.project().clone();
-                    assistant_context_editor::ContextStore::new(
+                    assistant_context::ContextStore::new(
                         project,
                         prompt_builder.clone(),
                         slash_commands,
@@ -473,18 +532,10 @@ impl AgentPanel {
         let workspace = workspace.weak_handle();
         let weak_self = cx.entity().downgrade();
 
-        let message_editor_context_store = cx.new(|_cx| {
-            crate::context_store::ContextStore::new(
-                project.downgrade(),
-                Some(thread_store.downgrade()),
-            )
-        });
-        let inline_assist_context_store = cx.new(|_cx| {
-            crate::context_store::ContextStore::new(
-                project.downgrade(),
-                Some(thread_store.downgrade()),
-            )
-        });
+        let message_editor_context_store =
+            cx.new(|_cx| ContextStore::new(project.downgrade(), Some(thread_store.downgrade())));
+        let inline_assist_context_store =
+            cx.new(|_cx| ContextStore::new(project.downgrade(), Some(thread_store.downgrade())));
 
         let message_editor = cx.new(|cx| {
             MessageEditor::new(
@@ -501,33 +552,18 @@ impl AgentPanel {
             )
         });
 
-        let message_editor_subscription =
-            cx.subscribe(&message_editor, |_, _, event, cx| match event {
-                MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => {
-                    cx.notify();
-                }
-            });
-
         let thread_id = thread.read(cx).id().clone();
         let history_store = cx.new(|cx| {
             HistoryStore::new(
                 thread_store.clone(),
                 context_store.clone(),
-                [RecentEntry::Thread(thread_id, thread.clone())],
-                window,
+                [HistoryEntryId::Thread(thread_id)],
                 cx,
             )
         });
 
         cx.observe(&history_store, |_, _, cx| cx.notify()).detach();
 
-        let active_view = ActiveView::thread(thread.clone(), window, cx);
-        let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| {
-            if let ThreadEvent::MessageAdded(_) = &event {
-                // needed to leave empty state
-                cx.notify();
-            }
-        });
         let active_thread = cx.new(|cx| {
             ActiveThread::new(
                 thread.clone(),
@@ -540,14 +576,38 @@ impl AgentPanel {
                 cx,
             )
         });
-        AgentDiff::set_active_thread(&workspace, &thread, window, cx);
 
-        let active_thread_subscription =
-            cx.subscribe(&active_thread, |_, _, event, cx| match &event {
-                ActiveThreadEvent::EditingMessageTokenCountChanged => {
-                    cx.notify();
-                }
-            });
+        let panel_type = AgentSettings::get_global(cx).default_view;
+        let active_view = match panel_type {
+            DefaultView::Thread => ActiveView::thread(active_thread, message_editor, window, cx),
+            DefaultView::TextThread => {
+                let context =
+                    context_store.update(cx, |context_store, cx| context_store.create(cx));
+                let lsp_adapter_delegate = make_lsp_adapter_delegate(&project.clone(), cx).unwrap();
+                let context_editor = cx.new(|cx| {
+                    let mut editor = TextThreadEditor::for_context(
+                        context,
+                        fs.clone(),
+                        workspace.clone(),
+                        project.clone(),
+                        lsp_adapter_delegate,
+                        window,
+                        cx,
+                    );
+                    editor.insert_default_prompt(window, cx);
+                    editor
+                });
+                ActiveView::prompt_editor(
+                    context_editor,
+                    history_store.clone(),
+                    language_registry.clone(),
+                    window,
+                    cx,
+                )
+            }
+        };
+
+        AgentDiff::set_active_thread(&workspace, &thread, window, cx);
 
         let weak_panel = weak_self.clone();
 
@@ -555,77 +615,9 @@ impl AgentPanel {
             let panel = weak_panel.clone();
             let assistant_navigation_menu =
                 ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| {
-                    let recently_opened = panel
-                        .update(cx, |this, cx| {
-                            this.history_store.update(cx, |history_store, cx| {
-                                history_store.recently_opened_entries(cx)
-                            })
-                        })
-                        .unwrap_or_default();
-
-                    if !recently_opened.is_empty() {
-                        menu = menu.header("Recently Opened");
-
-                        for entry in recently_opened.iter() {
-                            let summary = entry.summary(cx);
-
-                            menu = menu.entry_with_end_slot_on_hover(
-                                summary,
-                                None,
-                                {
-                                    let panel = panel.clone();
-                                    let entry = entry.clone();
-                                    move |window, cx| {
-                                        panel
-                                            .update(cx, {
-                                                let entry = entry.clone();
-                                                move |this, cx| match entry {
-                                                    RecentEntry::Thread(_, thread) => {
-                                                        this.open_thread(thread, window, cx)
-                                                    }
-                                                    RecentEntry::Context(context) => {
-                                                        let Some(path) = context.read(cx).path()
-                                                        else {
-                                                            return;
-                                                        };
-                                                        this.open_saved_prompt_editor(
-                                                            path.clone(),
-                                                            window,
-                                                            cx,
-                                                        )
-                                                        .detach_and_log_err(cx)
-                                                    }
-                                                }
-                                            })
-                                            .ok();
-                                    }
-                                },
-                                IconName::Close,
-                                "Close Entry".into(),
-                                {
-                                    let panel = panel.clone();
-                                    let entry = entry.clone();
-                                    move |_window, cx| {
-                                        panel
-                                            .update(cx, |this, cx| {
-                                                this.history_store.update(
-                                                    cx,
-                                                    |history_store, cx| {
-                                                        history_store.remove_recently_opened_entry(
-                                                            &entry, cx,
-                                                        );
-                                                    },
-                                                );
-                                            })
-                                            .ok();
-                                    }
-                                },
-                            );
-                        }
-
-                        menu = menu.separator();
+                    if let Some(panel) = panel.upgrade() {
+                        menu = Self::populate_recently_opened_menu_section(menu, panel, cx);
                     }
-
                     menu.action("View All", Box::new(OpenHistory))
                         .end_slot_action(DeleteRecentlyOpenThread.boxed_clone())
                         .fixed_width(px(320.).into())
@@ -653,26 +645,22 @@ impl AgentPanel {
         let _default_model_subscription = cx.subscribe(
             &LanguageModelRegistry::global(cx),
             |this, _, event: &language_model::Event, cx| match event {
-                language_model::Event::DefaultModelChanged => {
-                    this.thread
-                        .read(cx)
-                        .thread()
-                        .clone()
-                        .update(cx, |thread, cx| thread.get_or_init_configured_model(cx));
-                }
+                language_model::Event::DefaultModelChanged => match &this.active_view {
+                    ActiveView::Thread { thread, .. } => {
+                        thread
+                            .read(cx)
+                            .thread()
+                            .clone()
+                            .update(cx, |thread, cx| thread.get_or_init_configured_model(cx));
+                    }
+                    ActiveView::TextThread { .. }
+                    | ActiveView::History
+                    | ActiveView::Configuration => {}
+                },
                 _ => {}
             },
         );
 
-        let trial_markdown = cx.new(|cx| {
-            Markdown::new(
-                include_str!("trial_markdown.md").into(),
-                Some(language_registry.clone()),
-                None,
-                cx,
-            )
-        });
-
         Self {
             active_view,
             workspace,
@@ -681,13 +669,6 @@ impl AgentPanel {
             fs: fs.clone(),
             language_registry,
             thread_store: thread_store.clone(),
-            thread: active_thread,
-            message_editor,
-            _active_thread_subscriptions: vec![
-                thread_subscription,
-                active_thread_subscription,
-                message_editor_subscription,
-            ],
             _default_model_subscription,
             context_store,
             prompt_store,
@@ -709,8 +690,7 @@ impl AgentPanel {
             height: None,
             zoomed: false,
             pending_serialization: None,
-            hide_trial_upsell: false,
-            _trial_markdown: trial_markdown,
+            hide_upsell: false,
         }
     }
 
@@ -736,9 +716,7 @@ impl AgentPanel {
         &self.prompt_store
     }
 
-    pub(crate) fn inline_assist_context_store(
-        &self,
-    ) -> &Entity<crate::context_store::ContextStore> {
+    pub(crate) fn inline_assist_context_store(&self) -> &Entity<ContextStore> {
         &self.inline_assist_context_store
     }
 
@@ -751,20 +729,36 @@ impl AgentPanel {
     }
 
     fn cancel(&mut self, _: &editor::actions::Cancel, window: &mut Window, cx: &mut Context<Self>) {
-        self.thread
-            .update(cx, |thread, cx| thread.cancel_last_completion(window, cx));
+        match &self.active_view {
+            ActiveView::Thread { thread, .. } => {
+                thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx));
+            }
+            ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
+        }
+    }
+
+    fn active_message_editor(&self) -> Option<&Entity<MessageEditor>> {
+        match &self.active_view {
+            ActiveView::Thread { message_editor, .. } => Some(message_editor),
+            ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None,
+        }
     }
 
     fn new_thread(&mut self, action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
+        // Preserve chat box text when using creating new thread from summary'
+        let preserved_text = if action.from_thread_id.is_some() {
+            self.active_message_editor()
+                .map(|editor| editor.read(cx).get_text(cx).trim().to_string())
+        } else {
+            None
+        };
+
         let thread = self
             .thread_store
             .update(cx, |this, cx| this.create_thread(cx));
 
-        let thread_view = ActiveView::thread(thread.clone(), window, cx);
-        self.set_active_view(thread_view, window, cx);
-
         let context_store = cx.new(|_cx| {
-            crate::context_store::ContextStore::new(
+            ContextStore::new(
                 self.project.downgrade(),
                 Some(self.thread_store.downgrade()),
             )
@@ -790,14 +784,7 @@ impl AgentPanel {
             .detach_and_log_err(cx);
         }
 
-        let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| {
-            if let ThreadEvent::MessageAdded(_) = &event {
-                // needed to leave empty state
-                cx.notify();
-            }
-        });
-
-        self.thread = cx.new(|cx| {
+        let active_thread = cx.new(|cx| {
             ActiveThread::new(
                 thread.clone(),
                 self.thread_store.clone(),
@@ -809,43 +796,34 @@ impl AgentPanel {
                 cx,
             )
         });
-        AgentDiff::set_active_thread(&self.workspace, &thread, window, cx);
-
-        let active_thread_subscription =
-            cx.subscribe(&self.thread, |_, _, event, cx| match &event {
-                ActiveThreadEvent::EditingMessageTokenCountChanged => {
-                    cx.notify();
-                }
-            });
 
-        self.message_editor = cx.new(|cx| {
+        let message_editor = cx.new(|cx| {
             MessageEditor::new(
                 self.fs.clone(),
                 self.workspace.clone(),
                 self.user_store.clone(),
-                context_store,
+                context_store.clone(),
                 self.prompt_store.clone(),
                 self.thread_store.downgrade(),
                 self.context_store.downgrade(),
-                thread,
+                thread.clone(),
                 window,
                 cx,
             )
         });
-        self.message_editor.focus_handle(cx).focus(window);
 
-        let message_editor_subscription =
-            cx.subscribe(&self.message_editor, |_, _, event, cx| match event {
-                MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => {
-                    cx.notify();
-                }
+        if let Some(text) = preserved_text {
+            message_editor.update(cx, |editor, cx| {
+                editor.set_text(text, window, cx);
             });
+        }
 
-        self._active_thread_subscriptions = vec![
-            thread_subscription,
-            active_thread_subscription,
-            message_editor_subscription,
-        ];
+        message_editor.focus_handle(cx).focus(window);
+
+        let thread_view = ActiveView::thread(active_thread.clone(), message_editor, window, cx);
+        self.set_active_view(thread_view, window, cx);
+
+        AgentDiff::set_active_thread(&self.workspace, &thread, window, cx);
     }
 
     fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -857,7 +835,7 @@ impl AgentPanel {
             .flatten();
 
         let context_editor = cx.new(|cx| {
-            let mut editor = ContextEditor::for_context(
+            let mut editor = TextThreadEditor::for_context(
                 context,
                 self.fs.clone(),
                 self.workspace.clone(),
@@ -873,6 +851,7 @@ impl AgentPanel {
         self.set_active_view(
             ActiveView::prompt_editor(
                 context_editor.clone(),
+                self.history_store.clone(),
                 self.language_registry.clone(),
                 window,
                 cx,
@@ -892,8 +871,8 @@ impl AgentPanel {
         open_rules_library(
             self.language_registry.clone(),
             Box::new(PromptLibraryInlineAssist::new(self.workspace.clone())),
-            Arc::new(|| {
-                Box::new(SlashCommandCompletionProvider::new(
+            Rc::new(|| {
+                Rc::new(SlashCommandCompletionProvider::new(
                     Arc::new(SlashCommandWorkingSet::default()),
                     None,
                     None,
@@ -948,7 +927,7 @@ impl AgentPanel {
             .log_err()
             .flatten();
         let editor = cx.new(|cx| {
-            ContextEditor::for_context(
+            TextThreadEditor::for_context(
                 context,
                 self.fs.clone(),
                 self.workspace.clone(),
@@ -959,7 +938,13 @@ impl AgentPanel {
             )
         });
         self.set_active_view(
-            ActiveView::prompt_editor(editor.clone(), self.language_registry.clone(), window, cx),
+            ActiveView::prompt_editor(
+                editor.clone(),
+                self.history_store.clone(),
+                self.language_registry.clone(),
+                window,
+                cx,
+            ),
             window,
             cx,
         );
@@ -990,22 +975,14 @@ impl AgentPanel {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let thread_view = ActiveView::thread(thread.clone(), window, cx);
-        self.set_active_view(thread_view, window, cx);
         let context_store = cx.new(|_cx| {
-            crate::context_store::ContextStore::new(
+            ContextStore::new(
                 self.project.downgrade(),
                 Some(self.thread_store.downgrade()),
             )
         });
-        let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| {
-            if let ThreadEvent::MessageAdded(_) = &event {
-                // needed to leave empty state
-                cx.notify();
-            }
-        });
 
-        self.thread = cx.new(|cx| {
+        let active_thread = cx.new(|cx| {
             ActiveThread::new(
                 thread.clone(),
                 self.thread_store.clone(),
@@ -1017,16 +994,7 @@ impl AgentPanel {
                 cx,
             )
         });
-        AgentDiff::set_active_thread(&self.workspace, &thread, window, cx);
-
-        let active_thread_subscription =
-            cx.subscribe(&self.thread, |_, _, event, cx| match &event {
-                ActiveThreadEvent::EditingMessageTokenCountChanged => {
-                    cx.notify();
-                }
-            });
-
-        self.message_editor = cx.new(|cx| {
+        let message_editor = cx.new(|cx| {
             MessageEditor::new(
                 self.fs.clone(),
                 self.workspace.clone(),
@@ -1035,33 +1003,34 @@ impl AgentPanel {
                 self.prompt_store.clone(),
                 self.thread_store.downgrade(),
                 self.context_store.downgrade(),
-                thread,
+                thread.clone(),
                 window,
                 cx,
             )
         });
-        self.message_editor.focus_handle(cx).focus(window);
-
-        let message_editor_subscription =
-            cx.subscribe(&self.message_editor, |_, _, event, cx| match event {
-                MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => {
-                    cx.notify();
-                }
-            });
+        message_editor.focus_handle(cx).focus(window);
 
-        self._active_thread_subscriptions = vec![
-            thread_subscription,
-            active_thread_subscription,
-            message_editor_subscription,
-        ];
+        let thread_view = ActiveView::thread(active_thread.clone(), message_editor, window, cx);
+        self.set_active_view(thread_view, window, cx);
+        AgentDiff::set_active_thread(&self.workspace, &thread, window, cx);
     }
 
     pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
         match self.active_view {
             ActiveView::Configuration | ActiveView::History => {
-                self.active_view =
-                    ActiveView::thread(self.thread.read(cx).thread().clone(), window, cx);
-                self.message_editor.focus_handle(cx).focus(window);
+                if let Some(previous_view) = self.previous_view.take() {
+                    self.active_view = previous_view;
+
+                    match &self.active_view {
+                        ActiveView::Thread { message_editor, .. } => {
+                            message_editor.focus_handle(cx).focus(window);
+                        }
+                        ActiveView::TextThread { context_editor, .. } => {
+                            context_editor.focus_handle(cx).focus(window);
+                        }
+                        ActiveView::History | ActiveView::Configuration => {}
+                    }
+                }
                 cx.notify();
             }
             _ => {}
@@ -1166,12 +1135,17 @@ impl AgentPanel {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let thread = self.thread.read(cx).thread().clone();
-        self.workspace
-            .update(cx, |workspace, cx| {
-                AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx)
-            })
-            .log_err();
+        match &self.active_view {
+            ActiveView::Thread { thread, .. } => {
+                let thread = thread.read(cx).thread().clone();
+                self.workspace
+                    .update(cx, |workspace, cx| {
+                        AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx)
+                    })
+                    .log_err();
+            }
+            ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
+        }
     }
 
     pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -1180,8 +1154,17 @@ impl AgentPanel {
         let fs = self.fs.clone();
 
         self.set_active_view(ActiveView::Configuration, window, cx);
-        self.configuration =
-            Some(cx.new(|cx| AgentConfiguration::new(fs, context_server_store, tools, window, cx)));
+        self.configuration = Some(cx.new(|cx| {
+            AgentConfiguration::new(
+                fs,
+                context_server_store,
+                tools,
+                self.language_registry.clone(),
+                self.workspace.clone(),
+                window,
+                cx,
+            )
+        }));
 
         if let Some(configuration) = self.configuration.as_ref() {
             self.configuration_subscription = Some(cx.subscribe_in(
@@ -1200,21 +1183,22 @@ impl AgentPanel {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let Some(workspace) = self
-            .workspace
-            .upgrade()
-            .ok_or_else(|| anyhow!("workspace dropped"))
-            .log_err()
-        else {
-            return;
-        };
-
-        let Some(thread) = self.active_thread() else {
+        let Some(workspace) = self.workspace.upgrade() else {
             return;
         };
 
-        active_thread::open_active_thread_as_markdown(thread, workspace, window, cx)
-            .detach_and_log_err(cx);
+        match &self.active_view {
+            ActiveView::Thread { thread, .. } => {
+                active_thread::open_active_thread_as_markdown(
+                    thread.read(cx).thread().clone(),
+                    workspace,
+                    window,
+                    cx,
+                )
+                .detach_and_log_err(cx);
+            }
+            ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
+        }
     }
 
     fn handle_agent_configuration_event(
@@ -1231,7 +1215,7 @@ impl AgentPanel {
                     .map_or(true, |model| model.provider.id() != provider.id())
                 {
                     if let Some(model) = provider.default_model(cx) {
-                        update_settings_file::<AssistantSettings>(
+                        update_settings_file::<AgentSettings>(
                             self.fs.clone(),
                             cx,
                             move |settings, _| settings.set_model(model),
@@ -1244,9 +1228,9 @@ impl AgentPanel {
         }
     }
 
-    pub(crate) fn active_thread(&self) -> Option<Entity<Thread>> {
+    pub(crate) fn active_thread(&self, cx: &App) -> Option<Entity<Thread>> {
         match &self.active_view {
-            ActiveView::Thread { thread, .. } => thread.upgrade(),
+            ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()),
             _ => None,
         }
     }
@@ -1260,13 +1244,60 @@ impl AgentPanel {
             .update(cx, |this, cx| this.delete_thread(thread_id, cx))
     }
 
-    pub(crate) fn has_active_thread(&self) -> bool {
-        matches!(self.active_view, ActiveView::Thread { .. })
+    fn continue_conversation(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let ActiveView::Thread { thread, .. } = &self.active_view else {
+            return;
+        };
+
+        let thread_state = thread.read(cx).thread().read(cx);
+        if !thread_state.tool_use_limit_reached() {
+            return;
+        }
+
+        let model = thread_state.configured_model().map(|cm| cm.model.clone());
+        if let Some(model) = model {
+            thread.update(cx, |active_thread, cx| {
+                active_thread.thread().update(cx, |thread, cx| {
+                    thread.insert_invisible_continue_message(cx);
+                    thread.advance_prompt_id();
+                    thread.send_to_model(
+                        model,
+                        CompletionIntent::UserPrompt,
+                        Some(window.window_handle()),
+                        cx,
+                    );
+                });
+            });
+        } else {
+            log::warn!("No configured model available for continuation");
+        }
+    }
+
+    fn toggle_burn_mode(
+        &mut self,
+        _: &ToggleBurnMode,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let ActiveView::Thread { thread, .. } = &self.active_view else {
+            return;
+        };
+
+        thread.update(cx, |active_thread, cx| {
+            active_thread.thread().update(cx, |thread, _cx| {
+                let current_mode = thread.completion_mode();
+
+                thread.set_completion_mode(match current_mode {
+                    CompletionMode::Burn => CompletionMode::Normal,
+                    CompletionMode::Normal => CompletionMode::Burn,
+                });
+            });
+        });
     }
 
-    pub(crate) fn active_context_editor(&self) -> Option<Entity<ContextEditor>> {
+    pub(crate) fn active_context_editor(&self) -> Option<Entity<TextThreadEditor>> {
         match &self.active_view {
-            ActiveView::PromptEditor { context_editor, .. } => Some(context_editor.clone()),
+            ActiveView::TextThread { context_editor, .. } => Some(context_editor.clone()),
             _ => None,
         }
     }

crates/agent_ui/src/agent_ui.rs 🔗

@@ -0,0 +1,292 @@
+mod active_thread;
+mod agent_configuration;
+mod agent_diff;
+mod agent_model_selector;
+mod agent_panel;
+mod buffer_codegen;
+mod burn_mode_tooltip;
+mod context_picker;
+mod context_server_configuration;
+mod context_strip;
+mod debug;
+mod inline_assistant;
+mod inline_prompt_editor;
+mod language_model_selector;
+mod message_editor;
+mod profile_selector;
+mod slash_command;
+mod slash_command_picker;
+mod slash_command_settings;
+mod terminal_codegen;
+mod terminal_inline_assistant;
+mod text_thread_editor;
+mod thread_history;
+mod tool_compatibility;
+mod ui;
+
+use std::sync::Arc;
+
+use agent::{Thread, ThreadId};
+use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection};
+use assistant_slash_command::SlashCommandRegistry;
+use client::Client;
+use feature_flags::FeatureFlagAppExt as _;
+use fs::Fs;
+use gpui::{Action, App, Entity, actions};
+use language::LanguageRegistry;
+use language_model::{
+    ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry,
+};
+use prompt_store::PromptBuilder;
+use schemars::JsonSchema;
+use serde::Deserialize;
+use settings::{Settings as _, SettingsStore};
+
+pub use crate::active_thread::ActiveThread;
+use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
+pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate};
+pub use crate::inline_assistant::InlineAssistant;
+use crate::slash_command_settings::SlashCommandSettings;
+pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
+pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor};
+pub use ui::preview::{all_agent_previews, get_agent_preview};
+
+actions!(
+    agent,
+    [
+        NewTextThread,
+        ToggleContextPicker,
+        ToggleNavigationMenu,
+        ToggleOptionsMenu,
+        DeleteRecentlyOpenThread,
+        ToggleProfileSelector,
+        RemoveAllContext,
+        ExpandMessageEditor,
+        OpenHistory,
+        AddContextServer,
+        RemoveSelectedThread,
+        Chat,
+        ChatWithFollow,
+        CycleNextInlineAssist,
+        CyclePreviousInlineAssist,
+        FocusUp,
+        FocusDown,
+        FocusLeft,
+        FocusRight,
+        RemoveFocusedContext,
+        AcceptSuggestedContext,
+        OpenActiveThreadAsMarkdown,
+        OpenAgentDiff,
+        Keep,
+        Reject,
+        RejectAll,
+        KeepAll,
+        Follow,
+        ResetTrialUpsell,
+        ResetTrialEndUpsell,
+        ContinueThread,
+        ContinueWithBurnMode,
+        ToggleBurnMode,
+    ]
+);
+
+#[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)]
+#[action(namespace = agent)]
+pub struct NewThread {
+    #[serde(default)]
+    from_thread_id: Option<ThreadId>,
+}
+
+#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
+#[action(namespace = agent)]
+pub struct ManageProfiles {
+    #[serde(default)]
+    pub customize_tools: Option<AgentProfileId>,
+}
+
+impl ManageProfiles {
+    pub fn customize_tools(profile_id: AgentProfileId) -> Self {
+        Self {
+            customize_tools: Some(profile_id),
+        }
+    }
+}
+
+#[derive(Clone)]
+pub(crate) enum ModelUsageContext {
+    Thread(Entity<Thread>),
+    InlineAssistant,
+}
+
+impl ModelUsageContext {
+    pub fn configured_model(&self, cx: &App) -> Option<ConfiguredModel> {
+        match self {
+            Self::Thread(thread) => thread.read(cx).configured_model(),
+            Self::InlineAssistant => {
+                LanguageModelRegistry::read_global(cx).inline_assistant_model()
+            }
+        }
+    }
+
+    pub fn language_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
+        self.configured_model(cx)
+            .map(|configured_model| configured_model.model)
+    }
+}
+
+/// Initializes the `agent` crate.
+pub fn init(
+    fs: Arc<dyn Fs>,
+    client: Arc<Client>,
+    prompt_builder: Arc<PromptBuilder>,
+    language_registry: Arc<LanguageRegistry>,
+    is_eval: bool,
+    cx: &mut App,
+) {
+    AgentSettings::register(cx);
+    SlashCommandSettings::register(cx);
+
+    assistant_context::init(client.clone(), cx);
+    rules_library::init(cx);
+    if !is_eval {
+        // Initializing the language model from the user settings messes with the eval, so we only initialize them when
+        // we're not running inside of the eval.
+        init_language_model_settings(cx);
+    }
+    assistant_slash_command::init(cx);
+    agent::init(cx);
+    agent_panel::init(cx);
+    context_server_configuration::init(language_registry.clone(), fs.clone(), cx);
+    TextThreadEditor::init(cx);
+
+    register_slash_commands(cx);
+    inline_assistant::init(
+        fs.clone(),
+        prompt_builder.clone(),
+        client.telemetry().clone(),
+        cx,
+    );
+    terminal_inline_assistant::init(
+        fs.clone(),
+        prompt_builder.clone(),
+        client.telemetry().clone(),
+        cx,
+    );
+    indexed_docs::init(cx);
+    cx.observe_new(move |workspace, window, cx| {
+        ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx)
+    })
+    .detach();
+    cx.observe_new(ManageProfilesModal::register).detach();
+}
+
+fn init_language_model_settings(cx: &mut App) {
+    update_active_language_model_from_settings(cx);
+
+    cx.observe_global::<SettingsStore>(update_active_language_model_from_settings)
+        .detach();
+    cx.subscribe(
+        &LanguageModelRegistry::global(cx),
+        |_, event: &language_model::Event, cx| match event {
+            language_model::Event::ProviderStateChanged
+            | language_model::Event::AddedProvider(_)
+            | language_model::Event::RemovedProvider(_) => {
+                update_active_language_model_from_settings(cx);
+            }
+            _ => {}
+        },
+    )
+    .detach();
+}
+
+fn update_active_language_model_from_settings(cx: &mut App) {
+    let settings = AgentSettings::get_global(cx);
+
+    fn to_selected_model(selection: &LanguageModelSelection) -> language_model::SelectedModel {
+        language_model::SelectedModel {
+            provider: LanguageModelProviderId::from(selection.provider.0.clone()),
+            model: LanguageModelId::from(selection.model.clone()),
+        }
+    }
+
+    let default = to_selected_model(&settings.default_model);
+    let inline_assistant = settings
+        .inline_assistant_model
+        .as_ref()
+        .map(to_selected_model);
+    let commit_message = settings
+        .commit_message_model
+        .as_ref()
+        .map(to_selected_model);
+    let thread_summary = settings
+        .thread_summary_model
+        .as_ref()
+        .map(to_selected_model);
+    let inline_alternatives = settings
+        .inline_alternatives
+        .iter()
+        .map(to_selected_model)
+        .collect::<Vec<_>>();
+
+    LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
+        registry.select_default_model(Some(&default), cx);
+        registry.select_inline_assistant_model(inline_assistant.as_ref(), cx);
+        registry.select_commit_message_model(commit_message.as_ref(), cx);
+        registry.select_thread_summary_model(thread_summary.as_ref(), cx);
+        registry.select_inline_alternative_models(inline_alternatives, cx);
+    });
+}
+
+fn register_slash_commands(cx: &mut App) {
+    let slash_command_registry = SlashCommandRegistry::global(cx);
+
+    slash_command_registry.register_command(assistant_slash_commands::FileSlashCommand, true);
+    slash_command_registry.register_command(assistant_slash_commands::DeltaSlashCommand, true);
+    slash_command_registry.register_command(assistant_slash_commands::OutlineSlashCommand, true);
+    slash_command_registry.register_command(assistant_slash_commands::TabSlashCommand, true);
+    slash_command_registry
+        .register_command(assistant_slash_commands::CargoWorkspaceSlashCommand, true);
+    slash_command_registry.register_command(assistant_slash_commands::PromptSlashCommand, true);
+    slash_command_registry.register_command(assistant_slash_commands::SelectionCommand, true);
+    slash_command_registry.register_command(assistant_slash_commands::DefaultSlashCommand, false);
+    slash_command_registry.register_command(assistant_slash_commands::NowSlashCommand, false);
+    slash_command_registry
+        .register_command(assistant_slash_commands::DiagnosticsSlashCommand, true);
+    slash_command_registry.register_command(assistant_slash_commands::FetchSlashCommand, true);
+
+    cx.observe_flag::<assistant_slash_commands::StreamingExampleSlashCommandFeatureFlag, _>({
+        let slash_command_registry = slash_command_registry.clone();
+        move |is_enabled, _cx| {
+            if is_enabled {
+                slash_command_registry.register_command(
+                    assistant_slash_commands::StreamingExampleSlashCommand,
+                    false,
+                );
+            }
+        }
+    })
+    .detach();
+
+    update_slash_commands_from_settings(cx);
+    cx.observe_global::<SettingsStore>(update_slash_commands_from_settings)
+        .detach();
+}
+
+fn update_slash_commands_from_settings(cx: &mut App) {
+    let slash_command_registry = SlashCommandRegistry::global(cx);
+    let settings = SlashCommandSettings::get_global(cx);
+
+    if settings.docs.enabled {
+        slash_command_registry.register_command(assistant_slash_commands::DocsSlashCommand, true);
+    } else {
+        slash_command_registry.unregister_command(assistant_slash_commands::DocsSlashCommand);
+    }
+
+    if settings.cargo_workspace.enabled {
+        slash_command_registry
+            .register_command(assistant_slash_commands::CargoWorkspaceSlashCommand, true);
+    } else {
+        slash_command_registry
+            .unregister_command(assistant_slash_commands::CargoWorkspaceSlashCommand);
+    }
+}

crates/agent/src/buffer_codegen.rs → crates/agent_ui/src/buffer_codegen.rs 🔗

@@ -1,8 +1,10 @@
-use crate::context::ContextLoadResult;
 use crate::inline_prompt_editor::CodegenStatus;
-use crate::{context::load_context, context_store::ContextStore};
-use anyhow::Result;
-use assistant_settings::AssistantSettings;
+use agent::{
+    ContextStore,
+    context::{ContextLoadResult, load_context},
+};
+use agent_settings::AgentSettings;
+use anyhow::{Context as _, Result};
 use client::telemetry::Telemetry;
 use collections::HashSet;
 use editor::{Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset as _, ToPoint};
@@ -18,8 +20,7 @@ use language_model::{
 use multi_buffer::MultiBufferRow;
 use parking_lot::Mutex;
 use project::Project;
-use prompt_store::PromptBuilder;
-use prompt_store::PromptStore;
+use prompt_store::{PromptBuilder, PromptStore};
 use rope::Rope;
 use smol::future::FutureExt;
 use std::{
@@ -34,6 +35,7 @@ use std::{
 };
 use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff};
 use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
+use zed_llm_client::CompletionIntent;
 
 pub struct BufferCodegen {
     alternatives: Vec<Entity<CodegenAlternative>>,
@@ -385,8 +387,10 @@ impl CodegenAlternative {
                 async { Ok(LanguageModelTextStream::default()) }.boxed_local()
             } else {
                 let request = self.build_request(&model, user_prompt, cx)?;
-                cx.spawn(async move |_, cx| model.stream_completion_text(request.await, &cx).await)
-                    .boxed_local()
+                cx.spawn(async move |_, cx| {
+                    Ok(model.stream_completion_text(request.await, &cx).await?)
+                })
+                .boxed_local()
             };
         self.handle_stream(telemetry_id, provider_id.to_string(), api_key, stream, cx);
         Ok(())
@@ -419,16 +423,16 @@ impl CodegenAlternative {
             if start_buffer.remote_id() == end_buffer.remote_id() {
                 (start_buffer.clone(), start_buffer_offset..end_buffer_offset)
             } else {
-                return Err(anyhow::anyhow!("invalid transformation range"));
+                anyhow::bail!("invalid transformation range");
             }
         } else {
-            return Err(anyhow::anyhow!("invalid transformation range"));
+            anyhow::bail!("invalid transformation range");
         };
 
         let prompt = self
             .builder
             .generate_inline_transformation_prompt(user_prompt, language_name, buffer, range)
-            .map_err(|e| anyhow::anyhow!("Failed to generate content prompt: {}", e))?;
+            .context("generating content prompt")?;
 
         let context_task = self.context_store.as_ref().map(|context_store| {
             if let Some(project) = self.project.upgrade() {
@@ -443,7 +447,7 @@ impl CodegenAlternative {
             }
         });
 
-        let temperature = AssistantSettings::temperature_for_model(&model, cx);
+        let temperature = AgentSettings::temperature_for_model(&model, cx);
 
         Ok(cx.spawn(async move |_cx| {
             let mut request_message = LanguageModelRequestMessage {
@@ -464,6 +468,7 @@ impl CodegenAlternative {
             LanguageModelRequest {
                 thread_id: None,
                 prompt_id: None,
+                intent: Some(CompletionIntent::InlineAssist),
                 mode: None,
                 tools: Vec::new(),
                 tool_choice: None,
@@ -1089,15 +1094,9 @@ mod tests {
     };
     use language_model::{LanguageModelRegistry, TokenUsage};
     use rand::prelude::*;
-    use serde::Serialize;
     use settings::SettingsStore;
     use std::{future, sync::Arc};
 
-    #[derive(Serialize)]
-    pub struct DummyCompletionRequest {
-        pub name: String,
-    }
-
     #[gpui::test(iterations = 10)]
     async fn test_transform_autoindent(cx: &mut TestAppContext, mut rng: StdRng) {
         init_test(cx);

crates/agent_ui/src/burn_mode_tooltip.rs 🔗

@@ -0,0 +1,61 @@
+use gpui::{Context, FontWeight, IntoElement, Render, Window};
+use ui::{prelude::*, tooltip_container};
+
+pub struct BurnModeTooltip {
+    selected: bool,
+}
+
+impl BurnModeTooltip {
+    pub fn new() -> Self {
+        Self { selected: false }
+    }
+
+    pub fn selected(mut self, selected: bool) -> Self {
+        self.selected = selected;
+        self
+    }
+}
+
+impl Render for BurnModeTooltip {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let (icon, color) = if self.selected {
+            (IconName::ZedBurnModeOn, Color::Error)
+        } else {
+            (IconName::ZedBurnMode, Color::Default)
+        };
+
+        let turned_on = h_flex()
+            .h_4()
+            .px_1()
+            .border_1()
+            .border_color(cx.theme().colors().border)
+            .bg(cx.theme().colors().text_accent.opacity(0.1))
+            .rounded_sm()
+            .child(
+                Label::new("ON")
+                    .size(LabelSize::XSmall)
+                    .weight(FontWeight::SEMIBOLD)
+                    .color(Color::Accent),
+            );
+
+        let title = h_flex()
+            .gap_1p5()
+            .child(Icon::new(icon).size(IconSize::Small).color(color))
+            .child(Label::new("Burn Mode"))
+            .when(self.selected, |title| title.child(turned_on));
+
+        tooltip_container(window, cx, |this, _, _| {
+            this
+                .child(title)
+                .child(
+                    div()
+                        .max_w_64()
+                        .child(
+                            Label::new("Enables models to use large context windows, unlimited tool calls, and other capabilities for expanded reasoning.")
+                                .size(LabelSize::Small)
+                                .color(Color::Muted)
+                        )
+                )
+        })
+    }
+}

crates/agent/src/context_picker.rs → crates/agent_ui/src/context_picker.rs 🔗

@@ -37,10 +37,12 @@ use uuid::Uuid;
 use workspace::{Workspace, notifications::NotifyResultExt};
 
 use crate::AgentPanel;
-use crate::context::RULES_ICON;
-use crate::context_store::ContextStore;
-use crate::thread::ThreadId;
-use crate::thread_store::{TextThreadStore, ThreadStore};
+use agent::{
+    ThreadId,
+    context::RULES_ICON,
+    context_store::ContextStore,
+    thread_store::{TextThreadStore, ThreadStore},
+};
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 enum ContextPickerEntry {
@@ -659,7 +661,7 @@ fn recent_context_picker_entries(
 
     let active_thread_id = workspace
         .panel::<AgentPanel>(cx)
-        .and_then(|panel| Some(panel.read(cx).active_thread()?.read(cx).id()));
+        .and_then(|panel| Some(panel.read(cx).active_thread(cx)?.read(cx).id()));
 
     if let Some((thread_store, text_thread_store)) = thread_store
         .and_then(|store| store.upgrade())
@@ -766,6 +768,7 @@ pub(crate) fn insert_crease_for_mention(
 
         let ids = editor.insert_creases(vec![crease.clone()], cx);
         editor.fold_creases(vec![crease], false, window, cx);
+
         Some(ids[0])
     })
 }
@@ -927,8 +930,8 @@ impl MentionLink {
         format!(
             "[@{} ({}-{})]({}:{}:{}-{})",
             file_name,
-            line_range.start,
-            line_range.end,
+            line_range.start + 1,
+            line_range.end + 1,
             Self::SELECTION,
             full_path,
             line_range.start,
@@ -942,8 +945,8 @@ impl MentionLink {
                 format!("[@{}]({}:{})", title, Self::THREAD, id)
             }
             ThreadContextEntry::Context { path, title } => {
-                let filename = path.file_name().unwrap_or_default();
-                let escaped_filename = urlencoding::encode(&filename.to_string_lossy()).to_string();
+                let filename = path.file_name().unwrap_or_default().to_string_lossy();
+                let escaped_filename = urlencoding::encode(&filename);
                 format!(
                     "[@{}]({}:{}{})",
                     title,

crates/agent/src/context_picker/completion_provider.rs → crates/agent_ui/src/context_picker/completion_provider.rs 🔗

@@ -1,10 +1,9 @@
-use std::cell::RefCell;
 use std::ops::Range;
 use std::path::{Path, PathBuf};
-use std::rc::Rc;
 use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
 
+use agent::context_store::ContextStore;
 use anyhow::Result;
 use editor::{CompletionProvider, Editor, ExcerptId, ToOffset as _};
 use file_icons::FileIcons;
@@ -14,7 +13,7 @@ use http_client::HttpClientWithUrl;
 use itertools::Itertools;
 use language::{Buffer, CodeLabel, HighlightId};
 use lsp::CompletionContext;
-use project::{Completion, CompletionIntent, ProjectPath, Symbol, WorktreeId};
+use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, Symbol, WorktreeId};
 use prompt_store::PromptStore;
 use rope::Point;
 use text::{Anchor, OffsetRangeExt, ToPoint};
@@ -22,10 +21,11 @@ use ui::prelude::*;
 use util::ResultExt as _;
 use workspace::Workspace;
 
-use crate::Thread;
-use crate::context::{AgentContextHandle, AgentContextKey, ContextCreasesAddon, RULES_ICON};
-use crate::context_store::ContextStore;
-use crate::thread_store::{TextThreadStore, ThreadStore};
+use agent::{
+    Thread,
+    context::{AgentContextHandle, AgentContextKey, RULES_ICON},
+    thread_store::{TextThreadStore, ThreadStore},
+};
 
 use super::fetch_context_picker::fetch_url_content;
 use super::file_context_picker::{FileMatch, search_files};
@@ -37,6 +37,7 @@ use super::{
     ContextPickerAction, ContextPickerEntry, ContextPickerMode, MentionLink, RecentEntry,
     available_context_picker_entries, recent_context_picker_entries, selection_ranges,
 };
+use crate::message_editor::ContextCreasesAddon;
 
 pub(crate) enum Match {
     File(FileMatch),
@@ -72,7 +73,7 @@ fn search(
     recent_entries: Vec<RecentEntry>,
     prompt_store: Option<Entity<PromptStore>>,
     thread_store: Option<WeakEntity<ThreadStore>>,
-    text_thread_context_store: Option<WeakEntity<assistant_context_editor::ContextStore>>,
+    text_thread_context_store: Option<WeakEntity<assistant_context::ContextStore>>,
     workspace: Entity<Workspace>,
     cx: &mut App,
 ) -> Task<Vec<Match>> {
@@ -216,6 +217,7 @@ fn search(
                         &entry_candidates,
                         &query,
                         false,
+                        true,
                         100,
                         &Arc::new(AtomicBool::default()),
                         executor,
@@ -322,7 +324,10 @@ impl ContextPickerCompletionProvider {
                             })
                             .collect::<Vec<_>>();
 
-                        let new_text = selection_infos.iter().map(|(_, link, _)| link).join(" ");
+                        let new_text = format!(
+                            "{} ",
+                            selection_infos.iter().map(|(_, link, _)| link).join(" ")
+                        );
 
                         let callback = Arc::new({
                             let context_store = context_store.clone();
@@ -420,7 +425,7 @@ impl ContextPickerCompletionProvider {
         } else {
             IconName::MessageBubbles
         };
-        let new_text = MentionLink::for_thread(&thread_entry);
+        let new_text = format!("{} ", MentionLink::for_thread(&thread_entry));
         let new_text_len = new_text.len();
         Completion {
             replace_range: source_range.clone(),
@@ -435,7 +440,7 @@ impl ContextPickerCompletionProvider {
                 thread_entry.title().clone(),
                 excerpt_id,
                 source_range.start,
-                new_text_len,
+                new_text_len - 1,
                 editor.clone(),
                 context_store.clone(),
                 move |window, cx| match &thread_entry {
@@ -489,7 +494,7 @@ impl ContextPickerCompletionProvider {
         editor: Entity<Editor>,
         context_store: Entity<ContextStore>,
     ) -> Completion {
-        let new_text = MentionLink::for_rule(&rules);
+        let new_text = format!("{} ", MentionLink::for_rule(&rules));
         let new_text_len = new_text.len();
         Completion {
             replace_range: source_range.clone(),
@@ -504,7 +509,7 @@ impl ContextPickerCompletionProvider {
                 rules.title.clone(),
                 excerpt_id,
                 source_range.start,
-                new_text_len,
+                new_text_len - 1,
                 editor.clone(),
                 context_store.clone(),
                 move |_, cx| {
@@ -526,7 +531,7 @@ impl ContextPickerCompletionProvider {
         context_store: Entity<ContextStore>,
         http_client: Arc<HttpClientWithUrl>,
     ) -> Completion {
-        let new_text = MentionLink::for_fetch(&url_to_fetch);
+        let new_text = format!("{} ", MentionLink::for_fetch(&url_to_fetch));
         let new_text_len = new_text.len();
         Completion {
             replace_range: source_range.clone(),
@@ -541,7 +546,7 @@ impl ContextPickerCompletionProvider {
                 url_to_fetch.clone(),
                 excerpt_id,
                 source_range.start,
-                new_text_len,
+                new_text_len - 1,
                 editor.clone(),
                 context_store.clone(),
                 move |_, cx| {
@@ -550,7 +555,7 @@ impl ContextPickerCompletionProvider {
                     let url_to_fetch = url_to_fetch.clone();
                     cx.spawn(async move |cx| {
                         if let Some(context) = context_store
-                            .update(cx, |context_store, _| {
+                            .read_with(cx, |context_store, _| {
                                 context_store.get_url_context(url_to_fetch.clone())
                             })
                             .ok()?
@@ -611,7 +616,7 @@ impl ContextPickerCompletionProvider {
             crease_icon_path.clone()
         };
 
-        let new_text = MentionLink::for_file(&file_name, &full_path);
+        let new_text = format!("{} ", MentionLink::for_file(&file_name, &full_path));
         let new_text_len = new_text.len();
         Completion {
             replace_range: source_range.clone(),
@@ -626,7 +631,7 @@ impl ContextPickerCompletionProvider {
                 file_name,
                 excerpt_id,
                 source_range.start,
-                new_text_len,
+                new_text_len - 1,
                 editor,
                 context_store.clone(),
                 move |_, cx| {
@@ -682,7 +687,7 @@ impl ContextPickerCompletionProvider {
         label.push_str(" ", None);
         label.push_str(&file_name, comment_id);
 
-        let new_text = MentionLink::for_symbol(&symbol.name, &full_path);
+        let new_text = format!("{} ", MentionLink::for_symbol(&symbol.name, &full_path));
         let new_text_len = new_text.len();
         Some(Completion {
             replace_range: source_range.clone(),
@@ -697,7 +702,7 @@ impl ContextPickerCompletionProvider {
                 symbol.name.clone().into(),
                 excerpt_id,
                 source_range.start,
-                new_text_len,
+                new_text_len - 1,
                 editor.clone(),
                 context_store.clone(),
                 move |_, cx| {
@@ -743,7 +748,7 @@ impl CompletionProvider for ContextPickerCompletionProvider {
         _trigger: CompletionContext,
         _window: &mut Window,
         cx: &mut Context<Editor>,
-    ) -> Task<Result<Option<Vec<Completion>>>> {
+    ) -> Task<Result<Vec<CompletionResponse>>> {
         let state = buffer.update(cx, |buffer, _cx| {
             let position = buffer_position.to_point(buffer);
             let line_start = Point::new(position.row, 0);
@@ -753,18 +758,18 @@ impl CompletionProvider for ContextPickerCompletionProvider {
             MentionCompletion::try_parse(line, offset_to_line)
         });
         let Some(state) = state else {
-            return Task::ready(Ok(None));
+            return Task::ready(Ok(Vec::new()));
         };
 
         let Some((workspace, context_store)) =
             self.workspace.upgrade().zip(self.context_store.upgrade())
         else {
-            return Task::ready(Ok(None));
+            return Task::ready(Ok(Vec::new()));
         };
 
         let snapshot = buffer.read(cx).snapshot();
         let source_range = snapshot.anchor_before(state.source_range.start)
-            ..snapshot.anchor_before(state.source_range.end);
+            ..snapshot.anchor_after(state.source_range.end);
 
         let thread_store = self.thread_store.clone();
         let text_thread_store = self.text_thread_store.clone();
@@ -812,10 +817,10 @@ impl CompletionProvider for ContextPickerCompletionProvider {
         cx.spawn(async move |_, cx| {
             let matches = search_task.await;
             let Some(editor) = editor.upgrade() else {
-                return Ok(None);
+                return Ok(Vec::new());
             };
 
-            Ok(Some(cx.update(|cx| {
+            let completions = cx.update(|cx| {
                 matches
                     .into_iter()
                     .filter_map(|mat| match mat {
@@ -898,26 +903,24 @@ impl CompletionProvider for ContextPickerCompletionProvider {
                         ),
                     })
                     .collect()
-            })?))
+            })?;
+
+            Ok(vec![CompletionResponse {
+                completions,
+                // Since this does its own filtering (see `filter_completions()` returns false),
+                // there is no benefit to computing whether this set of completions is incomplete.
+                is_incomplete: true,
+            }])
         })
     }
 
-    fn resolve_completions(
-        &self,
-        _buffer: Entity<Buffer>,
-        _completion_indices: Vec<usize>,
-        _completions: Rc<RefCell<Box<[Completion]>>>,
-        _cx: &mut Context<Editor>,
-    ) -> Task<Result<bool>> {
-        Task::ready(Ok(true))
-    }
-
     fn is_completion_trigger(
         &self,
         buffer: &Entity<language::Buffer>,
         position: language::Anchor,
-        _: &str,
-        _: bool,
+        _text: &str,
+        _trigger_in_words: bool,
+        _menu_is_open: bool,
         cx: &mut Context<Editor>,
     ) -> bool {
         let buffer = buffer.read(cx);
@@ -1066,8 +1069,8 @@ mod tests {
     use project::{Project, ProjectPath};
     use serde_json::json;
     use settings::SettingsStore;
-    use std::ops::Deref;
-    use util::{path, separator};
+    use std::{ops::Deref, rc::Rc};
+    use util::path;
     use workspace::{AppState, Item};
 
     #[test]
@@ -1213,19 +1216,19 @@ mod tests {
             assert_eq!(worktrees.len(), 1);
             worktrees.pop().unwrap()
         });
-        let worktree_id = worktree.update(cx, |worktree, _| worktree.id());
+        let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
 
         let mut cx = VisualTestContext::from_window(*window.deref(), cx);
 
         let paths = vec![
-            separator!("a/one.txt"),
-            separator!("a/two.txt"),
-            separator!("a/three.txt"),
-            separator!("a/four.txt"),
-            separator!("b/five.txt"),
-            separator!("b/six.txt"),
-            separator!("b/seven.txt"),
-            separator!("b/eight.txt"),
+            path!("a/one.txt"),
+            path!("a/two.txt"),
+            path!("a/three.txt"),
+            path!("a/four.txt"),
+            path!("b/five.txt"),
+            path!("b/six.txt"),
+            path!("b/seven.txt"),
+            path!("b/eight.txt"),
         ];
 
         let mut opened_editors = Vec::new();
@@ -1286,7 +1289,7 @@ mod tests {
                     .map(Entity::downgrade)
             });
             window.focus(&editor.focus_handle(cx));
-            editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new(
+            editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
                 workspace.downgrade(),
                 context_store.downgrade(),
                 None,
@@ -1353,7 +1356,7 @@ mod tests {
         });
 
         editor.update(&mut cx, |editor, cx| {
-            assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt)",);
+            assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) ");
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 fold_ranges(editor, cx),
@@ -1364,7 +1367,7 @@ mod tests {
         cx.simulate_input(" ");
 
         editor.update(&mut cx, |editor, cx| {
-            assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) ",);
+            assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt)  ");
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 fold_ranges(editor, cx),
@@ -1377,7 +1380,7 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum ",
+                "Lorem [@one.txt](@file:dir/a/one.txt)  Ipsum ",
             );
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
@@ -1391,7 +1394,7 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum @file ",
+                "Lorem [@one.txt](@file:dir/a/one.txt)  Ipsum @file ",
             );
             assert!(editor.has_visible_completions_menu());
             assert_eq!(
@@ -1409,14 +1412,14 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt)"
+                "Lorem [@one.txt](@file:dir/a/one.txt)  Ipsum [@seven.txt](@file:dir/b/seven.txt) "
             );
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 fold_ranges(editor, cx),
                 vec![
                     Point::new(0, 6)..Point::new(0, 37),
-                    Point::new(0, 44)..Point::new(0, 79)
+                    Point::new(0, 45)..Point::new(0, 80)
                 ]
             );
         });
@@ -1426,14 +1429,14 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt)\n@"
+                "Lorem [@one.txt](@file:dir/a/one.txt)  Ipsum [@seven.txt](@file:dir/b/seven.txt) \n@"
             );
             assert!(editor.has_visible_completions_menu());
             assert_eq!(
                 fold_ranges(editor, cx),
                 vec![
                     Point::new(0, 6)..Point::new(0, 37),
-                    Point::new(0, 44)..Point::new(0, 79)
+                    Point::new(0, 45)..Point::new(0, 80)
                 ]
             );
         });
@@ -1447,14 +1450,14 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt)\n[@six.txt](@file:dir/b/six.txt)"
+                "Lorem [@one.txt](@file:dir/a/one.txt)  Ipsum [@seven.txt](@file:dir/b/seven.txt) \n[@six.txt](@file:dir/b/six.txt) "
             );
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 fold_ranges(editor, cx),
                 vec![
                     Point::new(0, 6)..Point::new(0, 37),
-                    Point::new(0, 44)..Point::new(0, 79),
+                    Point::new(0, 45)..Point::new(0, 80),
                     Point::new(1, 0)..Point::new(1, 31)
                 ]
             );

crates/agent/src/context_picker/fetch_context_picker.rs → crates/agent_ui/src/context_picker/fetch_context_picker.rs 🔗

@@ -2,6 +2,7 @@ use std::cell::RefCell;
 use std::rc::Rc;
 use std::sync::Arc;
 
+use agent::context_store::ContextStore;
 use anyhow::{Context as _, Result, bail};
 use futures::AsyncReadExt as _;
 use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity};
@@ -12,7 +13,6 @@ use ui::{Context, ListItem, Window, prelude::*};
 use workspace::Workspace;
 
 use crate::context_picker::ContextPicker;
-use crate::context_store::ContextStore;
 
 pub struct FetchContextPicker {
     picker: Entity<Picker<FetchContextPickerDelegate>>,

crates/agent/src/context_picker/file_context_picker.rs → crates/agent_ui/src/context_picker/file_context_picker.rs 🔗

@@ -14,7 +14,7 @@ use util::ResultExt as _;
 use workspace::Workspace;
 
 use crate::context_picker::ContextPicker;
-use crate::context_store::{ContextStore, FileInclusion};
+use agent::context_store::{ContextStore, FileInclusion};
 
 pub struct FileContextPicker {
     picker: Entity<Picker<FileContextPickerDelegate>>,

crates/agent/src/context_picker/rules_context_picker.rs → crates/agent_ui/src/context_picker/rules_context_picker.rs 🔗

@@ -7,9 +7,9 @@ use prompt_store::{PromptId, PromptStore, UserPromptId};
 use ui::{ListItem, prelude::*};
 use util::ResultExt as _;
 
-use crate::context::RULES_ICON;
 use crate::context_picker::ContextPicker;
-use crate::context_store::{self, ContextStore};
+use agent::context::RULES_ICON;
+use agent::context_store::{self, ContextStore};
 
 pub struct RulesContextPicker {
     picker: Entity<Picker<RulesContextPickerDelegate>>,

crates/agent/src/context_picker/symbol_context_picker.rs → crates/agent_ui/src/context_picker/symbol_context_picker.rs 🔗

@@ -14,9 +14,9 @@ use ui::{ListItem, prelude::*};
 use util::ResultExt as _;
 use workspace::Workspace;
 
-use crate::context::AgentContextHandle;
 use crate::context_picker::ContextPicker;
-use crate::context_store::ContextStore;
+use agent::context::AgentContextHandle;
+use agent::context_store::ContextStore;
 
 pub struct SymbolContextPicker {
     picker: Entity<Picker<SymbolContextPickerDelegate>>,
@@ -307,6 +307,7 @@ pub(crate) fn search_symbols(
             &visible_match_candidates,
             &query,
             false,
+            true,
             MAX_MATCHES,
             &cancellation_flag,
             cx.background_executor().clone(),
@@ -315,6 +316,7 @@ pub(crate) fn search_symbols(
             &external_match_candidates,
             &query,
             false,
+            true,
             MAX_MATCHES - visible_matches.len().min(MAX_MATCHES),
             &cancellation_flag,
             cx.background_executor().clone(),

crates/agent/src/context_picker/thread_context_picker.rs → crates/agent_ui/src/context_picker/thread_context_picker.rs 🔗

@@ -9,9 +9,11 @@ use picker::{Picker, PickerDelegate};
 use ui::{ListItem, prelude::*};
 
 use crate::context_picker::ContextPicker;
-use crate::context_store::{self, ContextStore};
-use crate::thread::ThreadId;
-use crate::thread_store::{TextThreadStore, ThreadStore};
+use agent::{
+    ThreadId,
+    context_store::{self, ContextStore},
+    thread_store::{TextThreadStore, ThreadStore},
+};
 
 pub struct ThreadContextPicker {
     picker: Entity<Picker<ThreadContextPickerDelegate>>,
@@ -282,15 +284,18 @@ pub fn unordered_thread_entries(
     text_thread_store: Entity<TextThreadStore>,
     cx: &App,
 ) -> impl Iterator<Item = (DateTime<Utc>, ThreadContextEntry)> {
-    let threads = thread_store.read(cx).unordered_threads().map(|thread| {
-        (
-            thread.updated_at,
-            ThreadContextEntry::Thread {
-                id: thread.id.clone(),
-                title: thread.summary.clone(),
-            },
-        )
-    });
+    let threads = thread_store
+        .read(cx)
+        .reverse_chronological_threads()
+        .map(|thread| {
+            (
+                thread.updated_at,
+                ThreadContextEntry::Thread {
+                    id: thread.id.clone(),
+                    title: thread.summary.clone(),
+                },
+            )
+        });
 
     let text_threads = text_thread_store
         .read(cx)
@@ -300,7 +305,7 @@ pub fn unordered_thread_entries(
                 context.mtime.to_utc(),
                 ThreadContextEntry::Context {
                     path: context.path.clone(),
-                    title: context.title.clone().into(),
+                    title: context.title.clone(),
                 },
             )
         });
@@ -339,6 +344,7 @@ pub(crate) fn search_threads(
                 &candidates,
                 &query,
                 false,
+                true,
                 100,
                 &cancellation_flag,
                 executor,

crates/agent_ui/src/context_server_configuration.rs 🔗

@@ -0,0 +1,116 @@
+use std::sync::Arc;
+
+use context_server::ContextServerId;
+use extension::ExtensionManifest;
+use fs::Fs;
+use gpui::WeakEntity;
+use language::LanguageRegistry;
+use project::project_settings::ProjectSettings;
+use settings::update_settings_file;
+use ui::prelude::*;
+use util::ResultExt;
+use workspace::Workspace;
+
+use crate::agent_configuration::ConfigureContextServerModal;
+
+pub(crate) fn init(language_registry: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, cx: &mut App) {
+    cx.observe_new(move |_: &mut Workspace, window, cx| {
+        let Some(window) = window else {
+            return;
+        };
+
+        if let Some(extension_events) = extension::ExtensionEvents::try_global(cx).as_ref() {
+            cx.subscribe_in(extension_events, window, {
+                let language_registry = language_registry.clone();
+                let fs = fs.clone();
+                move |_, _, event, window, cx| match event {
+                    extension::Event::ExtensionInstalled(manifest) => {
+                        show_configure_mcp_modal(
+                            language_registry.clone(),
+                            manifest,
+                            cx.weak_entity(),
+                            window,
+                            cx,
+                        );
+                    }
+                    extension::Event::ExtensionUninstalled(manifest) => {
+                        remove_context_server_settings(
+                            manifest.context_servers.keys().cloned().collect(),
+                            fs.clone(),
+                            cx,
+                        );
+                    }
+                    extension::Event::ConfigureExtensionRequested(manifest) => {
+                        if !manifest.context_servers.is_empty() {
+                            show_configure_mcp_modal(
+                                language_registry.clone(),
+                                manifest,
+                                cx.weak_entity(),
+                                window,
+                                cx,
+                            );
+                        }
+                    }
+                    _ => {}
+                }
+            })
+            .detach();
+        } else {
+            log::info!(
+                "No extension events global found. Skipping context server configuration wizard"
+            );
+        }
+    })
+    .detach();
+}
+
+fn remove_context_server_settings(
+    context_server_ids: Vec<Arc<str>>,
+    fs: Arc<dyn Fs>,
+    cx: &mut App,
+) {
+    update_settings_file::<ProjectSettings>(fs, cx, move |settings, _| {
+        settings
+            .context_servers
+            .retain(|server_id, _| !context_server_ids.contains(server_id));
+    });
+}
+
+fn show_configure_mcp_modal(
+    language_registry: Arc<LanguageRegistry>,
+    manifest: &Arc<ExtensionManifest>,
+    workspace: WeakEntity<Workspace>,
+    window: &mut Window,
+    cx: &mut Context<'_, Workspace>,
+) {
+    if !window.is_window_active() {
+        return;
+    }
+
+    let ids = manifest.context_servers.keys().cloned().collect::<Vec<_>>();
+    if ids.is_empty() {
+        return;
+    }
+
+    window
+        .spawn(cx, async move |cx| {
+            for id in ids {
+                let Some(task) = cx
+                    .update(|window, cx| {
+                        ConfigureContextServerModal::show_modal_for_existing_server(
+                            ContextServerId(id.clone()),
+                            language_registry.clone(),
+                            workspace.clone(),
+                            window,
+                            cx,
+                        )
+                    })
+                    .ok()
+                else {
+                    continue;
+                };
+                task.await.log_err();
+            }
+        })
+        .detach();
+}

crates/agent/src/context_strip.rs → crates/agent_ui/src/context_strip.rs 🔗

@@ -1,7 +1,15 @@
-use std::path::Path;
-use std::rc::Rc;
-
-use assistant_context_editor::AssistantContext;
+use crate::{
+    AcceptSuggestedContext, AgentPanel, FocusDown, FocusLeft, FocusRight, FocusUp,
+    ModelUsageContext, RemoveAllContext, RemoveFocusedContext, ToggleContextPicker,
+    context_picker::ContextPicker,
+    ui::{AddedContext, ContextPill},
+};
+use agent::context_store::SuggestedContext;
+use agent::{
+    context::AgentContextHandle,
+    context_store::ContextStore,
+    thread_store::{TextThreadStore, ThreadStore},
+};
 use collections::HashSet;
 use editor::Editor;
 use file_icons::FileIcons;
@@ -10,22 +18,11 @@ use gpui::{
     Subscription, WeakEntity,
 };
 use itertools::Itertools;
-use language::Buffer;
 use project::ProjectItem;
+use std::{path::Path, rc::Rc};
 use ui::{PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
 use workspace::Workspace;
 
-use crate::context::{AgentContextHandle, ContextKind};
-use crate::context_picker::ContextPicker;
-use crate::context_store::ContextStore;
-use crate::thread::Thread;
-use crate::thread_store::{TextThreadStore, ThreadStore};
-use crate::ui::{AddedContext, ContextPill};
-use crate::{
-    AcceptSuggestedContext, AgentPanel, FocusDown, FocusLeft, FocusRight, FocusUp,
-    RemoveAllContext, RemoveFocusedContext, ToggleContextPicker,
-};
-
 pub struct ContextStrip {
     context_store: Entity<ContextStore>,
     context_picker: Entity<ContextPicker>,
@@ -37,6 +34,7 @@ pub struct ContextStrip {
     _subscriptions: Vec<Subscription>,
     focused_index: Option<usize>,
     children_bounds: Option<Vec<Bounds<Pixels>>>,
+    model_usage_context: ModelUsageContext,
 }
 
 impl ContextStrip {
@@ -47,6 +45,7 @@ impl ContextStrip {
         text_thread_store: Option<WeakEntity<TextThreadStore>>,
         context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
         suggest_context_kind: SuggestContextKind,
+        model_usage_context: ModelUsageContext,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -81,9 +80,16 @@ impl ContextStrip {
             _subscriptions: subscriptions,
             focused_index: None,
             children_bounds: None,
+            model_usage_context,
         }
     }
 
+    /// Whether or not the context strip has items to display
+    pub fn has_context_items(&self, cx: &App) -> bool {
+        self.context_store.read(cx).context().next().is_some()
+            || self.suggested_context(cx).is_some()
+    }
+
     fn added_contexts(&self, cx: &App) -> Vec<AddedContext> {
         if let Some(workspace) = self.workspace.upgrade() {
             let project = workspace.read(cx).project().read(cx);
@@ -92,11 +98,20 @@ impl ContextStrip {
                 .as_ref()
                 .and_then(|thread_store| thread_store.upgrade())
                 .and_then(|thread_store| thread_store.read(cx).prompt_store().as_ref());
+
+            let current_model = self.model_usage_context.language_model(cx);
+
             self.context_store
                 .read(cx)
                 .context()
                 .flat_map(|context| {
-                    AddedContext::new_pending(context.clone(), prompt_store, project, cx)
+                    AddedContext::new_pending(
+                        context.clone(),
+                        prompt_store,
+                        project,
+                        current_model.as_ref(),
+                        cx,
+                    )
                 })
                 .collect::<Vec<_>>()
         } else {
@@ -104,14 +119,14 @@ impl ContextStrip {
         }
     }
 
-    fn suggested_context(&self, cx: &Context<Self>) -> Option<SuggestedContext> {
+    fn suggested_context(&self, cx: &App) -> Option<SuggestedContext> {
         match self.suggest_context_kind {
             SuggestContextKind::File => self.suggested_file(cx),
             SuggestContextKind::Thread => self.suggested_thread(cx),
         }
     }
 
-    fn suggested_file(&self, cx: &Context<Self>) -> Option<SuggestedContext> {
+    fn suggested_file(&self, cx: &App) -> Option<SuggestedContext> {
         let workspace = self.workspace.upgrade()?;
         let active_item = workspace.read(cx).active_item(cx)?;
 
@@ -138,7 +153,7 @@ impl ContextStrip {
         })
     }
 
-    fn suggested_thread(&self, cx: &Context<Self>) -> Option<SuggestedContext> {
+    fn suggested_thread(&self, cx: &App) -> Option<SuggestedContext> {
         if !self.context_picker.read(cx).allow_threads() {
             return None;
         }
@@ -146,7 +161,7 @@ impl ContextStrip {
         let workspace = self.workspace.upgrade()?;
         let panel = workspace.read(cx).panel::<AgentPanel>(cx)?.read(cx);
 
-        if let Some(active_thread) = panel.active_thread() {
+        if let Some(active_thread) = panel.active_thread(cx) {
             let weak_active_thread = active_thread.downgrade();
 
             let active_thread = active_thread.read(cx);
@@ -557,46 +572,3 @@ pub enum SuggestContextKind {
     File,
     Thread,
 }
-
-#[derive(Clone)]
-pub enum SuggestedContext {
-    File {
-        name: SharedString,
-        icon_path: Option<SharedString>,
-        buffer: WeakEntity<Buffer>,
-    },
-    Thread {
-        name: SharedString,
-        thread: WeakEntity<Thread>,
-    },
-    TextThread {
-        name: SharedString,
-        context: WeakEntity<AssistantContext>,
-    },
-}
-
-impl SuggestedContext {
-    pub fn name(&self) -> &SharedString {
-        match self {
-            Self::File { name, .. } => name,
-            Self::Thread { name, .. } => name,
-            Self::TextThread { name, .. } => name,
-        }
-    }
-
-    pub fn icon_path(&self) -> Option<SharedString> {
-        match self {
-            Self::File { icon_path, .. } => icon_path.clone(),
-            Self::Thread { .. } => None,
-            Self::TextThread { .. } => None,
-        }
-    }
-
-    pub fn kind(&self) -> ContextKind {
-        match self {
-            Self::File { .. } => ContextKind::File,
-            Self::Thread { .. } => ContextKind::Thread,
-            Self::TextThread { .. } => ContextKind::TextThread,
-        }
-    }
-}

crates/agent/src/debug.rs → crates/agent_ui/src/debug.rs 🔗

@@ -1,7 +1,7 @@
 #![allow(unused, dead_code)]
 
+use client::{ModelRequestUsage, RequestUsage};
 use gpui::Global;
-use language_model::RequestUsage;
 use std::ops::{Deref, DerefMut};
 use ui::prelude::*;
 use zed_llm_client::{Plan, UsageLimit};
@@ -17,7 +17,7 @@ pub struct DebugAccountState {
     pub enabled: bool,
     pub trial_expired: bool,
     pub plan: Plan,
-    pub custom_prompt_usage: RequestUsage,
+    pub custom_prompt_usage: ModelRequestUsage,
     pub usage_based_billing_enabled: bool,
     pub monthly_spending_cap: i32,
     pub custom_edit_prediction_usage: UsageLimit,
@@ -43,7 +43,7 @@ impl DebugAccountState {
         self
     }
 
-    pub fn set_custom_prompt_usage(&mut self, custom_prompt_usage: RequestUsage) -> &mut Self {
+    pub fn set_custom_prompt_usage(&mut self, custom_prompt_usage: ModelRequestUsage) -> &mut Self {
         self.custom_prompt_usage = custom_prompt_usage;
         self
     }
@@ -76,10 +76,10 @@ impl Default for DebugAccountState {
             enabled: false,
             trial_expired: false,
             plan: Plan::ZedFree,
-            custom_prompt_usage: RequestUsage {
+            custom_prompt_usage: ModelRequestUsage(RequestUsage {
                 limit: UsageLimit::Unlimited,
                 amount: 0,
-            },
+            }),
             usage_based_billing_enabled: false,
             // $50.00
             monthly_spending_cap: 5000,

crates/agent/src/inline_assistant.rs → crates/agent_ui/src/inline_assistant.rs 🔗

@@ -4,18 +4,28 @@ use std::ops::Range;
 use std::rc::Rc;
 use std::sync::Arc;
 
+use crate::{
+    AgentPanel,
+    buffer_codegen::{BufferCodegen, CodegenAlternative, CodegenEvent},
+    inline_prompt_editor::{CodegenStatus, InlineAssistId, PromptEditor, PromptEditorEvent},
+    terminal_inline_assistant::TerminalInlineAssistant,
+};
+use agent::{
+    context_store::ContextStore,
+    thread_store::{TextThreadStore, ThreadStore},
+};
+use agent_settings::AgentSettings;
 use anyhow::{Context as _, Result};
-use assistant_settings::AssistantSettings;
 use client::telemetry::Telemetry;
 use collections::{HashMap, HashSet, VecDeque, hash_map};
-use editor::display_map::EditorMargins;
+use editor::SelectionEffects;
 use editor::{
     Anchor, AnchorRangeExt, CodeActionProvider, Editor, EditorEvent, ExcerptId, ExcerptRange,
     MultiBuffer, MultiBufferSnapshot, ToOffset as _, ToPoint,
     actions::SelectAll,
     display_map::{
-        BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId, RenderBlock,
-        ToDisplayPoint,
+        BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId, EditorMargins,
+        RenderBlock, ToDisplayPoint,
     },
 };
 use fs::Fs;
@@ -24,33 +34,22 @@ use gpui::{
     WeakEntity, Window, point,
 };
 use language::{Buffer, Point, Selection, TransactionId};
-use language_model::ConfiguredModel;
-use language_model::{LanguageModelRegistry, report_assistant_event};
+use language_model::{
+    ConfigurationError, ConfiguredModel, LanguageModelRegistry, report_assistant_event,
+};
 use multi_buffer::MultiBufferRow;
 use parking_lot::Mutex;
-use project::LspAction;
-use project::Project;
-use project::{CodeAction, ProjectTransaction};
-use prompt_store::PromptBuilder;
-use prompt_store::PromptStore;
+use project::{CodeAction, LspAction, Project, ProjectTransaction};
+use prompt_store::{PromptBuilder, PromptStore};
 use settings::{Settings, SettingsStore};
 use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
 use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
 use text::{OffsetRangeExt, ToPoint as _};
 use ui::prelude::*;
-use util::RangeExt;
-use util::ResultExt;
+use util::{RangeExt, ResultExt, maybe};
 use workspace::{ItemHandle, Toast, Workspace, dock::Panel, notifications::NotificationId};
 use zed_actions::agent::OpenConfiguration;
 
-use crate::AgentPanel;
-use crate::buffer_codegen::{BufferCodegen, CodegenAlternative, CodegenEvent};
-use crate::context_store::ContextStore;
-use crate::inline_prompt_editor::{CodegenStatus, InlineAssistId, PromptEditor, PromptEditorEvent};
-use crate::terminal_inline_assistant::TerminalInlineAssistant;
-use crate::thread_store::TextThreadStore;
-use crate::thread_store::ThreadStore;
-
 pub fn init(
     fs: Arc<dyn Fs>,
     prompt_builder: Arc<PromptBuilder>,
@@ -134,7 +133,7 @@ impl InlineAssistant {
             let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
                 return;
             };
-            let enabled = AssistantSettings::get_global(cx).enabled;
+            let enabled = AgentSettings::get_global(cx).enabled;
             terminal_panel.update(cx, |terminal_panel, cx| {
                 terminal_panel.set_assistant_enabled(enabled, cx)
             });
@@ -219,7 +218,7 @@ impl InlineAssistant {
         window: &mut Window,
         cx: &mut Context<Workspace>,
     ) {
-        let settings = AssistantSettings::get_global(cx);
+        let settings = AgentSettings::get_global(cx);
         if !settings.enabled {
             return;
         }
@@ -233,10 +232,9 @@ impl InlineAssistant {
             return;
         };
 
-        let is_authenticated = || {
-            LanguageModelRegistry::read_global(cx)
-                .inline_assistant_model()
-                .map_or(false, |model| model.provider.is_authenticated(cx))
+        let configuration_error = || {
+            let model_registry = LanguageModelRegistry::read_global(cx);
+            model_registry.configuration_error(model_registry.inline_assistant_model(), cx)
         };
 
         let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) else {
@@ -284,20 +282,23 @@ impl InlineAssistant {
                 }
             };
 
-        if is_authenticated() {
-            handle_assist(window, cx);
-        } else {
-            cx.spawn_in(window, async move |_workspace, cx| {
-                let Some(task) = cx.update(|_, cx| {
-                    LanguageModelRegistry::read_global(cx)
-                        .inline_assistant_model()
-                        .map_or(None, |model| Some(model.provider.authenticate(cx)))
-                })?
-                else {
+        if let Some(error) = configuration_error() {
+            if let ConfigurationError::ProviderNotAuthenticated(provider) = error {
+                cx.spawn(async move |_, cx| {
+                    cx.update(|cx| provider.authenticate(cx))?.await?;
+                    anyhow::Ok(())
+                })
+                .detach_and_log_err(cx);
+
+                if configuration_error().is_none() {
+                    handle_assist(window, cx);
+                }
+            } else {
+                cx.spawn_in(window, async move |_, cx| {
                     let answer = cx
                         .prompt(
                             gpui::PromptLevel::Warning,
-                            "No language model provider configured",
+                            &error.to_string(),
                             None,
                             &["Configure", "Cancel"],
                         )
@@ -311,17 +312,12 @@ impl InlineAssistant {
                             .ok();
                         }
                     }
-                    return Ok(());
-                };
-                task.await?;
-
-                anyhow::Ok(())
-            })
-            .detach_and_log_err(cx);
-
-            if is_authenticated() {
-                handle_assist(window, cx);
+                    anyhow::Ok(())
+                })
+                .detach_and_log_err(cx);
             }
+        } else {
+            handle_assist(window, cx);
         }
     }
 
@@ -338,13 +334,27 @@ impl InlineAssistant {
         window: &mut Window,
         cx: &mut App,
     ) {
-        let (snapshot, initial_selections) = editor.update(cx, |editor, cx| {
-            (
-                editor.snapshot(window, cx),
-                editor.selections.all::<Point>(cx),
-            )
+        let (snapshot, initial_selections, newest_selection) = editor.update(cx, |editor, cx| {
+            let selections = editor.selections.all::<Point>(cx);
+            let newest_selection = editor.selections.newest::<Point>(cx);
+            (editor.snapshot(window, cx), selections, newest_selection)
         });
 
+        // Check if there is already an inline assistant that contains the
+        // newest selection, if there is, focus it
+        if let Some(editor_assists) = self.assists_by_editor.get(&editor.downgrade()) {
+            for assist_id in &editor_assists.assist_ids {
+                let assist = &self.assists[assist_id];
+                let range = assist.range.to_point(&snapshot.buffer_snapshot);
+                if range.start.row <= newest_selection.start.row
+                    && newest_selection.end.row <= range.end.row
+                {
+                    self.focus_assist(*assist_id, window, cx);
+                    return;
+                }
+            }
+        }
+
         let mut selections = Vec::<Selection<Point>>::new();
         let mut newest_selection = None;
         for mut selection in initial_selections {
@@ -754,9 +764,6 @@ impl InlineAssistant {
             PromptEditorEvent::CancelRequested => {
                 self.finish_assist(assist_id, true, window, cx);
             }
-            PromptEditorEvent::DismissRequested => {
-                self.dismiss_assist(assist_id, window, cx);
-            }
             PromptEditorEvent::Resized { .. } => {
                 // This only matters for the terminal inline assistant
             }
@@ -997,7 +1004,7 @@ impl InlineAssistant {
                         self.update_editor_highlights(&editor, cx);
                     }
                 } else {
-                    entry.get().highlight_updates.send(()).ok();
+                    entry.get_mut().highlight_updates.send(()).ok();
                 }
             }
 
@@ -1153,31 +1160,35 @@ impl InlineAssistant {
 
         let position = assist.range.start;
         editor.update(cx, |editor, cx| {
-            editor.change_selections(None, window, cx, |selections| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
                 selections.select_anchor_ranges([position..position])
             });
 
-            let mut scroll_target_top;
-            let mut scroll_target_bottom;
+            let mut scroll_target_range = None;
             if let Some(decorations) = assist.decorations.as_ref() {
-                scroll_target_top = editor
-                    .row_for_block(decorations.prompt_block_id, cx)
-                    .unwrap()
-                    .0 as f32;
-                scroll_target_bottom = editor
-                    .row_for_block(decorations.end_block_id, cx)
-                    .unwrap()
-                    .0 as f32;
-            } else {
+                scroll_target_range = maybe!({
+                    let top = editor.row_for_block(decorations.prompt_block_id, cx)?.0 as f32;
+                    let bottom = editor.row_for_block(decorations.end_block_id, cx)?.0 as f32;
+                    Some((top, bottom))
+                });
+                if scroll_target_range.is_none() {
+                    log::error!("bug: failed to find blocks for scrolling to inline assist");
+                }
+            }
+            let scroll_target_range = scroll_target_range.unwrap_or_else(|| {
                 let snapshot = editor.snapshot(window, cx);
                 let start_row = assist
                     .range
                     .start
                     .to_display_point(&snapshot.display_snapshot)
                     .row();
-                scroll_target_top = start_row.0 as f32;
-                scroll_target_bottom = scroll_target_top + 1.;
-            }
+                let top = start_row.0 as f32;
+                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 f32;
             scroll_target_bottom += editor.vertical_scroll_margin() as f32;
 
@@ -1317,7 +1328,7 @@ impl InlineAssistant {
                 editor.clear_gutter_highlights::<GutterPendingRange>(cx);
             } else {
                 editor.highlight_gutter::<GutterPendingRange>(
-                    &gutter_pending_ranges,
+                    gutter_pending_ranges,
                     |cx| cx.theme().status().info_background,
                     cx,
                 )
@@ -1328,7 +1339,7 @@ impl InlineAssistant {
                 editor.clear_gutter_highlights::<GutterTransformedRange>(cx);
             } else {
                 editor.highlight_gutter::<GutterTransformedRange>(
-                    &gutter_transformed_ranges,
+                    gutter_transformed_ranges,
                     |cx| cx.theme().status().info,
                     cx,
                 )
@@ -1431,7 +1442,7 @@ impl InlineAssistant {
                     style: BlockStyle::Flex,
                     render: Arc::new(move |cx| {
                         div()
-                            .block_mouse_down()
+                            .block_mouse_except_scroll()
                             .bg(cx.theme().status().deleted_background)
                             .size_full()
                             .h(height as f32 * cx.window.line_height())
@@ -1505,7 +1516,7 @@ impl InlineAssistant {
 struct EditorInlineAssists {
     assist_ids: Vec<InlineAssistId>,
     scroll_lock: Option<InlineAssistScrollLock>,
-    highlight_updates: async_watch::Sender<()>,
+    highlight_updates: watch::Sender<()>,
     _update_highlights: Task<Result<()>>,
     _subscriptions: Vec<gpui::Subscription>,
 }
@@ -1517,7 +1528,7 @@ struct InlineAssistScrollLock {
 
 impl EditorInlineAssists {
     fn new(editor: &Entity<Editor>, window: &mut Window, cx: &mut App) -> Self {
-        let (highlight_updates_tx, mut highlight_updates_rx) = async_watch::channel(());
+        let (highlight_updates_tx, mut highlight_updates_rx) = watch::channel(());
         Self {
             assist_ids: Vec::new(),
             scroll_lock: None,
@@ -1675,7 +1686,7 @@ impl InlineAssist {
                         if let Some(editor) = editor.upgrade() {
                             InlineAssistant::update_global(cx, |this, cx| {
                                 if let Some(editor_assists) =
-                                    this.assists_by_editor.get(&editor.downgrade())
+                                    this.assists_by_editor.get_mut(&editor.downgrade())
                                 {
                                     editor_assists.highlight_updates.send(()).ok();
                                 }
@@ -1757,7 +1768,7 @@ impl CodeActionProvider for AssistantCodeActionProvider {
         _: &mut Window,
         cx: &mut App,
     ) -> Task<Result<Vec<CodeAction>>> {
-        if !AssistantSettings::get_global(cx).enabled {
+        if !AgentSettings::get_global(cx).enabled {
             return Task::ready(Ok(Vec::new()));
         }
 

crates/agent/src/inline_prompt_editor.rs → crates/agent_ui/src/inline_prompt_editor.rs 🔗

@@ -1,16 +1,20 @@
-use crate::agent_model_selector::{AgentModelSelector, ModelType};
+use crate::agent_model_selector::AgentModelSelector;
 use crate::buffer_codegen::BufferCodegen;
-use crate::context::ContextCreasesAddon;
 use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
-use crate::context_store::ContextStore;
 use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
-use crate::message_editor::{extract_message_creases, insert_message_creases};
+use crate::language_model_selector::ToggleModelSelector;
+use crate::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases};
 use crate::terminal_codegen::TerminalCodegen;
-use crate::thread_store::{TextThreadStore, ThreadStore};
-use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist};
+use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
 use crate::{RemoveAllContext, ToggleContextPicker};
+use agent::{
+    context_store::ContextStore,
+    thread_store::{TextThreadStore, ThreadStore},
+};
 use client::ErrorExt;
 use collections::VecDeque;
+use db::kvp::Dismissable;
+use editor::actions::Paste;
 use editor::display_map::EditorMargins;
 use editor::{
     ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer,
@@ -23,17 +27,16 @@ use gpui::{
     Focusable, FontWeight, Subscription, TextStyle, WeakEntity, Window, anchored, deferred, point,
 };
 use language_model::{LanguageModel, LanguageModelRegistry};
-use language_model_selector::ToggleModelSelector;
 use parking_lot::Mutex;
 use settings::Settings;
 use std::cmp;
+use std::rc::Rc;
 use std::sync::Arc;
 use theme::ThemeSettings;
 use ui::utils::WithRemSize;
 use ui::{
     CheckboxWithLabel, IconButtonShape, KeyBinding, Popover, PopoverMenuHandle, Tooltip, prelude::*,
 };
-use util::ResultExt;
 use workspace::Workspace;
 
 pub struct PromptEditor<T> {
@@ -98,8 +101,9 @@ impl<T: 'static> Render for PromptEditor<T> {
 
         v_flex()
             .key_context("PromptEditor")
+            .capture_action(cx.listener(Self::paste))
             .bg(cx.theme().colors().editor_background)
-            .block_mouse_down()
+            .block_mouse_except_scroll()
             .gap_0p5()
             .border_y_1()
             .border_color(cx.theme().status().info_border)
@@ -258,7 +262,7 @@ impl<T: 'static> PromptEditor<T> {
 
         let focus = self.editor.focus_handle(cx).contains_focused(window, cx);
         self.editor = cx.new(|cx| {
-            let mut editor = Editor::auto_height(Self::MAX_LINES as usize, window, cx);
+            let mut editor = Editor::auto_height(1, Self::MAX_LINES as usize, window, cx);
             editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
             editor.set_placeholder_text("Add a prompt…", cx);
             editor.set_text(prompt, window, cx);
@@ -302,6 +306,10 @@ impl<T: 'static> PromptEditor<T> {
         self.editor.read(cx).text(cx)
     }
 
+    fn paste(&mut self, _: &Paste, _window: &mut Window, cx: &mut Context<Self>) {
+        crate::active_thread::attach_pasted_images_as_context(&self.context_store, cx);
+    }
+
     fn toggle_rate_limit_notice(
         &mut self,
         _: &ClickEvent,
@@ -326,9 +334,7 @@ impl<T: 'static> PromptEditor<T> {
             EditorEvent::Edited { .. } => {
                 if let Some(workspace) = window.root::<Workspace>().flatten() {
                     workspace.update(cx, |workspace, cx| {
-                        let is_via_ssh = workspace
-                            .project()
-                            .update(cx, |project, _| project.is_via_ssh());
+                        let is_via_ssh = workspace.project().read(cx).is_via_ssh();
 
                         workspace
                             .client()
@@ -373,7 +379,7 @@ impl<T: 'static> PromptEditor<T> {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.context_store.update(cx, |store, _cx| store.clear());
+        self.context_store.update(cx, |store, cx| store.clear(cx));
         cx.notify();
     }
 
@@ -398,9 +404,7 @@ impl<T: 'static> PromptEditor<T> {
             CodegenStatus::Idle => {
                 cx.emit(PromptEditorEvent::StartRequested);
             }
-            CodegenStatus::Pending => {
-                cx.emit(PromptEditorEvent::DismissRequested);
-            }
+            CodegenStatus::Pending => {}
             CodegenStatus::Done => {
                 if self.edited_since_done {
                     cx.emit(PromptEditorEvent::StartRequested);
@@ -451,7 +455,7 @@ impl<T: 'static> PromptEditor<T> {
                     editor.move_to_end(&Default::default(), window, cx)
                 });
             }
-        } else {
+        } else if self.context_strip.read(cx).has_context_items(cx) {
             self.context_strip.focus_handle(cx).focus(window);
         }
     }
@@ -722,7 +726,7 @@ impl<T: 'static> PromptEditor<T> {
                         .child(CheckboxWithLabel::new(
                             "dont-show-again",
                             Label::new("Don't show again"),
-                            if dismissed_rate_limit_notice() {
+                            if RateLimitNotice::dismissed() {
                                 ui::ToggleState::Selected
                             } else {
                                 ui::ToggleState::Unselected
@@ -734,7 +738,7 @@ impl<T: 'static> PromptEditor<T> {
                                     ui::ToggleState::Selected => true,
                                 };
 
-                                set_rate_limit_notice_dismissed(is_dismissed, cx)
+                                RateLimitNotice::set_dismissed(is_dismissed, cx);
                             },
                         ))
                         .child(
@@ -826,7 +830,6 @@ pub enum PromptEditorEvent {
     StopRequested,
     ConfirmRequested { execute: bool },
     CancelRequested,
-    DismissRequested,
     Resized { height_in_lines: u8 },
 }
 
@@ -867,7 +870,8 @@ impl PromptEditor<BufferCodegen> {
         let prompt_editor = cx.new(|cx| {
             let mut editor = Editor::new(
                 EditorMode::AutoHeight {
-                    max_lines: Self::MAX_LINES as usize,
+                    min_lines: 1,
+                    max_lines: Some(Self::MAX_LINES as usize),
                 },
                 prompt_buffer,
                 None,
@@ -892,7 +896,7 @@ impl PromptEditor<BufferCodegen> {
 
         let prompt_editor_entity = prompt_editor.downgrade();
         prompt_editor.update(cx, |editor, _| {
-            editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new(
+            editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
                 workspace.clone(),
                 context_store.downgrade(),
                 thread_store.clone(),
@@ -913,6 +917,7 @@ impl PromptEditor<BufferCodegen> {
                 text_thread_store.clone(),
                 context_picker_menu_handle.clone(),
                 SuggestContextKind::Thread,
+                ModelUsageContext::InlineAssistant,
                 window,
                 cx,
             )
@@ -931,7 +936,7 @@ impl PromptEditor<BufferCodegen> {
                     fs,
                     model_selector_menu_handle,
                     prompt_editor.focus_handle(cx),
-                    ModelType::InlineAssistant,
+                    ModelUsageContext::InlineAssistant,
                     window,
                     cx,
                 )
@@ -974,7 +979,7 @@ impl PromptEditor<BufferCodegen> {
             CodegenStatus::Error(error) => {
                 if cx.has_flag::<ZedProFeatureFlag>()
                     && error.error_code() == proto::ErrorCode::RateLimitExceeded
-                    && !dismissed_rate_limit_notice()
+                    && !RateLimitNotice::dismissed()
                 {
                     self.show_rate_limit_notice = true;
                     cx.notify();
@@ -1044,7 +1049,8 @@ impl PromptEditor<TerminalCodegen> {
         let prompt_editor = cx.new(|cx| {
             let mut editor = Editor::new(
                 EditorMode::AutoHeight {
-                    max_lines: Self::MAX_LINES as usize,
+                    min_lines: 1,
+                    max_lines: Some(Self::MAX_LINES as usize),
                 },
                 prompt_buffer,
                 None,
@@ -1063,7 +1069,7 @@ impl PromptEditor<TerminalCodegen> {
 
         let prompt_editor_entity = prompt_editor.downgrade();
         prompt_editor.update(cx, |editor, _| {
-            editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new(
+            editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
                 workspace.clone(),
                 context_store.downgrade(),
                 thread_store.clone(),
@@ -1084,6 +1090,7 @@ impl PromptEditor<TerminalCodegen> {
                 text_thread_store.clone(),
                 context_picker_menu_handle.clone(),
                 SuggestContextKind::Thread,
+                ModelUsageContext::InlineAssistant,
                 window,
                 cx,
             )
@@ -1102,7 +1109,7 @@ impl PromptEditor<TerminalCodegen> {
                     fs,
                     model_selector_menu_handle.clone(),
                     prompt_editor.focus_handle(cx),
-                    ModelType::InlineAssistant,
+                    ModelUsageContext::InlineAssistant,
                     window,
                     cx,
                 )
@@ -1180,27 +1187,10 @@ impl PromptEditor<TerminalCodegen> {
     }
 }
 
-const DISMISSED_RATE_LIMIT_NOTICE_KEY: &str = "dismissed-rate-limit-notice";
-
-fn dismissed_rate_limit_notice() -> bool {
-    db::kvp::KEY_VALUE_STORE
-        .read_kvp(DISMISSED_RATE_LIMIT_NOTICE_KEY)
-        .log_err()
-        .map_or(false, |s| s.is_some())
-}
+struct RateLimitNotice;
 
-fn set_rate_limit_notice_dismissed(is_dismissed: bool, cx: &mut App) {
-    db::write_and_log(cx, move || async move {
-        if is_dismissed {
-            db::kvp::KEY_VALUE_STORE
-                .write_kvp(DISMISSED_RATE_LIMIT_NOTICE_KEY.into(), "1".into())
-                .await
-        } else {
-            db::kvp::KEY_VALUE_STORE
-                .delete_kvp(DISMISSED_RATE_LIMIT_NOTICE_KEY.into())
-                .await
-        }
-    })
+impl Dismissable for RateLimitNotice {
+    const KEY: &'static str = "dismissed-rate-limit-notice";
 }
 
 pub enum CodegenStatus {

crates/language_model_selector/src/language_model_selector.rs → crates/agent_ui/src/language_model_selector.rs 🔗

@@ -4,9 +4,7 @@ use collections::{HashSet, IndexMap};
 use feature_flags::ZedProFeatureFlag;
 use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
 use gpui::{
-    Action, AnyElement, AnyView, App, BackgroundExecutor, Corner, DismissEvent, Entity,
-    EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity,
-    action_with_deprecated_aliases,
+    Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task, actions,
 };
 use language_model::{
     AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId,
@@ -15,14 +13,13 @@ use language_model::{
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate};
 use proto::Plan;
-use ui::{ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, PopoverTrigger, prelude::*};
+use ui::{ListItem, ListItemSpacing, prelude::*};
 
-action_with_deprecated_aliases!(
+actions!(
     agent,
-    ToggleModelSelector,
     [
-        "assistant::ToggleModelSelector",
-        "assistant2::ToggleModelSelector"
+        #[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])]
+        ToggleModelSelector
     ]
 );
 
@@ -31,77 +28,128 @@ const TRY_ZED_PRO_URL: &str = "https://zed.dev/pro";
 type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
 type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
 
-pub struct LanguageModelSelector {
-    picker: Entity<Picker<LanguageModelPickerDelegate>>,
+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,
+    window: &mut Window,
+    cx: &mut Context<LanguageModelSelector>,
+) -> LanguageModelSelector {
+    let delegate = LanguageModelPickerDelegate::new(get_active_model, on_model_changed, window, cx);
+    Picker::list(delegate, window, cx)
+        .show_scrollbar(true)
+        .width(rems(20.))
+        .max_height(Some(rems(20.).into()))
+}
+
+fn all_models(cx: &App) -> GroupedModels {
+    let providers = LanguageModelRegistry::global(cx).read(cx).providers();
+
+    let recommended = providers
+        .iter()
+        .flat_map(|provider| {
+            provider
+                .recommended_models(cx)
+                .into_iter()
+                .map(|model| ModelInfo {
+                    model,
+                    icon: provider.icon(),
+                })
+        })
+        .collect();
+
+    let other = providers
+        .iter()
+        .flat_map(|provider| {
+            provider
+                .provided_models(cx)
+                .into_iter()
+                .map(|model| ModelInfo {
+                    model,
+                    icon: provider.icon(),
+                })
+        })
+        .collect();
+
+    GroupedModels::new(other, recommended)
+}
+
+#[derive(Clone)]
+struct ModelInfo {
+    model: Arc<dyn LanguageModel>,
+    icon: IconName,
+}
+
+pub struct LanguageModelPickerDelegate {
+    on_model_changed: OnModelChanged,
+    get_active_model: GetActiveModel,
+    all_models: Arc<GroupedModels>,
+    filtered_entries: Vec<LanguageModelPickerEntry>,
+    selected_index: usize,
     _authenticate_all_providers_task: Task<()>,
     _subscriptions: Vec<Subscription>,
 }
 
-impl LanguageModelSelector {
-    pub fn new(
+impl LanguageModelPickerDelegate {
+    fn new(
         get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
         on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
         window: &mut Window,
-        cx: &mut Context<Self>,
+        cx: &mut Context<Picker<Self>>,
     ) -> Self {
         let on_model_changed = Arc::new(on_model_changed);
+        let models = all_models(cx);
+        let entries = models.entries();
 
-        let all_models = Self::all_models(cx);
-        let entries = all_models.entries();
-
-        let delegate = LanguageModelPickerDelegate {
-            language_model_selector: cx.entity().downgrade(),
+        Self {
             on_model_changed: on_model_changed.clone(),
-            all_models: Arc::new(all_models),
+            all_models: Arc::new(models),
             selected_index: Self::get_active_model_index(&entries, get_active_model(cx)),
             filtered_entries: entries,
             get_active_model: Arc::new(get_active_model),
-        };
-
-        let picker = cx.new(|cx| {
-            Picker::list(delegate, window, cx)
-                .show_scrollbar(true)
-                .width(rems(20.))
-                .max_height(Some(rems(20.).into()))
-        });
-
-        let subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
-
-        LanguageModelSelector {
-            picker,
             _authenticate_all_providers_task: Self::authenticate_all_providers(cx),
-            _subscriptions: vec![
-                cx.subscribe_in(
-                    &LanguageModelRegistry::global(cx),
-                    window,
-                    Self::handle_language_model_registry_event,
-                ),
-                subscription,
-            ],
+            _subscriptions: vec![cx.subscribe_in(
+                &LanguageModelRegistry::global(cx),
+                window,
+                |picker, _, event, window, cx| {
+                    match event {
+                        language_model::Event::ProviderStateChanged
+                        | language_model::Event::AddedProvider(_)
+                        | language_model::Event::RemovedProvider(_) => {
+                            let query = picker.query(cx);
+                            picker.delegate.all_models = Arc::new(all_models(cx));
+                            // Update matches will automatically drop the previous task
+                            // if we get a provider event again
+                            picker.update_matches(query, window, cx)
+                        }
+                        _ => {}
+                    }
+                },
+            )],
         }
     }
 
-    fn handle_language_model_registry_event(
-        &mut self,
-        _registry: &Entity<LanguageModelRegistry>,
-        event: &language_model::Event,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        match event {
-            language_model::Event::ProviderStateChanged
-            | language_model::Event::AddedProvider(_)
-            | language_model::Event::RemovedProvider(_) => {
-                self.picker.update(cx, |this, cx| {
-                    let query = this.query(cx);
-                    this.delegate.all_models = Arc::new(Self::all_models(cx));
-                    // Update matches will automatically drop the previous task
-                    // if we get a provider event again
-                    this.update_matches(query, window, cx)
-                });
-            }
-            _ => {}
-        }
+    fn get_active_model_index(
+        entries: &[LanguageModelPickerEntry],
+        active_model: Option<ConfiguredModel>,
+    ) -> usize {
+        entries
+            .iter()
+            .position(|entry| {
+                if let LanguageModelPickerEntry::Model(model) = entry {
+                    active_model
+                        .as_ref()
+                        .map(|active_model| {
+                            active_model.model.id() == model.model.id()
+                                && active_model.provider.id() == model.model.provider_id()
+                        })
+                        .unwrap_or_default()
+                } else {
+                    false
+                }
+            })
+            .unwrap_or(0)
     }
 
     /// Authenticates all providers in the [`LanguageModelRegistry`].
@@ -154,169 +202,9 @@ impl LanguageModelSelector {
         })
     }
 
-    fn all_models(cx: &App) -> GroupedModels {
-        let mut recommended = Vec::new();
-        let mut recommended_set = HashSet::default();
-        for provider in LanguageModelRegistry::global(cx)
-            .read(cx)
-            .providers()
-            .iter()
-        {
-            let models = provider.recommended_models(cx);
-            recommended_set.extend(models.iter().map(|model| (model.provider_id(), model.id())));
-            recommended.extend(
-                provider
-                    .recommended_models(cx)
-                    .into_iter()
-                    .map(move |model| ModelInfo {
-                        model: model.clone(),
-                        icon: provider.icon(),
-                    }),
-            );
-        }
-
-        let other_models = LanguageModelRegistry::global(cx)
-            .read(cx)
-            .providers()
-            .iter()
-            .map(|provider| {
-                (
-                    provider.id(),
-                    provider
-                        .provided_models(cx)
-                        .into_iter()
-                        .filter_map(|model| {
-                            let not_included =
-                                !recommended_set.contains(&(model.provider_id(), model.id()));
-                            not_included.then(|| ModelInfo {
-                                model: model.clone(),
-                                icon: provider.icon(),
-                            })
-                        })
-                        .collect::<Vec<_>>(),
-                )
-            })
-            .collect::<IndexMap<_, _>>();
-
-        GroupedModels {
-            recommended,
-            other: other_models,
-        }
-    }
-
     pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
-        (self.picker.read(cx).delegate.get_active_model)(cx)
+        (self.get_active_model)(cx)
     }
-
-    fn get_active_model_index(
-        entries: &[LanguageModelPickerEntry],
-        active_model: Option<ConfiguredModel>,
-    ) -> usize {
-        entries
-            .iter()
-            .position(|entry| {
-                if let LanguageModelPickerEntry::Model(model) = entry {
-                    active_model
-                        .as_ref()
-                        .map(|active_model| {
-                            active_model.model.id() == model.model.id()
-                                && active_model.provider.id() == model.model.provider_id()
-                        })
-                        .unwrap_or_default()
-                } else {
-                    false
-                }
-            })
-            .unwrap_or(0)
-    }
-}
-
-impl EventEmitter<DismissEvent> for LanguageModelSelector {}
-
-impl Focusable for LanguageModelSelector {
-    fn focus_handle(&self, cx: &App) -> FocusHandle {
-        self.picker.focus_handle(cx)
-    }
-}
-
-impl Render for LanguageModelSelector {
-    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
-        self.picker.clone()
-    }
-}
-
-#[derive(IntoElement)]
-pub struct LanguageModelSelectorPopoverMenu<T, TT>
-where
-    T: PopoverTrigger + ButtonCommon,
-    TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
-{
-    language_model_selector: Entity<LanguageModelSelector>,
-    trigger: T,
-    tooltip: TT,
-    handle: Option<PopoverMenuHandle<LanguageModelSelector>>,
-    anchor: Corner,
-}
-
-impl<T, TT> LanguageModelSelectorPopoverMenu<T, TT>
-where
-    T: PopoverTrigger + ButtonCommon,
-    TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
-{
-    pub fn new(
-        language_model_selector: Entity<LanguageModelSelector>,
-        trigger: T,
-        tooltip: TT,
-        anchor: Corner,
-    ) -> Self {
-        Self {
-            language_model_selector,
-            trigger,
-            tooltip,
-            handle: None,
-            anchor,
-        }
-    }
-
-    pub fn with_handle(mut self, handle: PopoverMenuHandle<LanguageModelSelector>) -> Self {
-        self.handle = Some(handle);
-        self
-    }
-}
-
-impl<T, TT> RenderOnce for LanguageModelSelectorPopoverMenu<T, TT>
-where
-    T: PopoverTrigger + ButtonCommon,
-    TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
-{
-    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
-        let language_model_selector = self.language_model_selector.clone();
-
-        PopoverMenu::new("model-switcher")
-            .menu(move |_window, _cx| Some(language_model_selector.clone()))
-            .trigger_with_tooltip(self.trigger, self.tooltip)
-            .anchor(self.anchor)
-            .when_some(self.handle.clone(), |menu, handle| menu.with_handle(handle))
-            .offset(gpui::Point {
-                x: px(0.0),
-                y: px(-2.0),
-            })
-    }
-}
-
-#[derive(Clone)]
-struct ModelInfo {
-    model: Arc<dyn LanguageModel>,
-    icon: IconName,
-}
-
-pub struct LanguageModelPickerDelegate {
-    language_model_selector: WeakEntity<LanguageModelSelector>,
-    on_model_changed: OnModelChanged,
-    get_active_model: GetActiveModel,
-    all_models: Arc<GroupedModels>,
-    filtered_entries: Vec<LanguageModelPickerEntry>,
-    selected_index: usize,
 }
 
 struct GroupedModels {
@@ -326,8 +214,17 @@ struct GroupedModels {
 
 impl GroupedModels {
     pub fn new(other: Vec<ModelInfo>, recommended: Vec<ModelInfo>) -> Self {
+        let recommended_ids = recommended
+            .iter()
+            .map(|info| (info.model.provider_id(), info.model.id()))
+            .collect::<HashSet<_>>();
+
         let mut other_by_provider: IndexMap<_, Vec<ModelInfo>> = IndexMap::default();
         for model in other {
+            if recommended_ids.contains(&(model.model.provider_id(), model.model.id())) {
+                continue;
+            }
+
             let provider = model.model.provider_id();
             if let Some(models) = other_by_provider.get_mut(&provider) {
                 models.push(model);
@@ -411,6 +308,7 @@ impl ModelMatcher {
             &self.candidates,
             &query,
             false,
+            true,
             100,
             &Default::default(),
             self.bg_executor.clone(),
@@ -571,9 +469,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
     }
 
     fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
-        self.language_model_selector
-            .update(cx, |_this, cx| cx.emit(DismissEvent))
-            .ok();
+        cx.emit(DismissEvent);
     }
 
     fn render_match(
@@ -759,11 +655,15 @@ mod tests {
             false
         }
 
+        fn supports_images(&self) -> bool {
+            false
+        }
+
         fn telemetry_id(&self) -> String {
             format!("{}/{}", self.provider_id.0, self.name.0)
         }
 
-        fn max_token_count(&self) -> usize {
+        fn max_token_count(&self) -> u64 {
             1000
         }
 
@@ -771,7 +671,7 @@ mod tests {
             &self,
             _: LanguageModelRequest,
             _: &App,
-        ) -> BoxFuture<'static, http_client::Result<usize>> {
+        ) -> BoxFuture<'static, http_client::Result<u64>> {
             unimplemented!()
         }
 
@@ -781,11 +681,12 @@ mod tests {
             _: &AsyncApp,
         ) -> BoxFuture<
             'static,
-            http_client::Result<
+            Result<
                 BoxStream<
                     'static,
-                    http_client::Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
+                    Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
                 >,
+                LanguageModelCompletionError,
             >,
         > {
             unimplemented!()
@@ -885,4 +786,48 @@ mod tests {
         let results = matcher.fuzzy_search("z4n");
         assert_models_eq(results, vec!["zed/gpt-4.1-nano"]);
     }
+
+    #[gpui::test]
+    fn test_exclude_recommended_models(_cx: &mut TestAppContext) {
+        let recommended_models = create_models(vec![("zed", "claude")]);
+        let all_models = create_models(vec![
+            ("zed", "claude"), // Should be filtered out from "other"
+            ("zed", "gemini"),
+            ("copilot", "o3"),
+        ]);
+
+        let grouped_models = GroupedModels::new(all_models, recommended_models);
+
+        let actual_other_models = grouped_models
+            .other
+            .values()
+            .flatten()
+            .cloned()
+            .collect::<Vec<_>>();
+
+        // Recommended models should not appear in "other"
+        assert_models_eq(actual_other_models, vec!["zed/gemini", "copilot/o3"]);
+    }
+
+    #[gpui::test]
+    fn test_dont_exclude_models_from_other_providers(_cx: &mut TestAppContext) {
+        let recommended_models = create_models(vec![("zed", "claude")]);
+        let all_models = create_models(vec![
+            ("zed", "claude"), // Should be filtered out from "other"
+            ("zed", "gemini"),
+            ("copilot", "claude"), // Should not be filtered out from "other"
+        ]);
+
+        let grouped_models = GroupedModels::new(all_models, recommended_models);
+
+        let actual_other_models = grouped_models
+            .other
+            .values()
+            .flatten()
+            .cloned()
+            .collect::<Vec<_>>();
+
+        // Recommended models should not appear in "other"
+        assert_models_eq(actual_other_models, vec!["zed/gemini", "copilot/claude"]);
+    }
 }

crates/agent/src/message_editor.rs → crates/agent_ui/src/message_editor.rs 🔗

@@ -1,36 +1,40 @@
 use std::collections::BTreeMap;
+use std::rc::Rc;
 use std::sync::Arc;
 
-use crate::agent_model_selector::{AgentModelSelector, ModelType};
-use crate::context::{AgentContextKey, ContextCreasesAddon, ContextLoadResult, load_context};
+use crate::agent_model_selector::AgentModelSelector;
+use crate::language_model_selector::ToggleModelSelector;
 use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
 use crate::ui::{
-    AnimatedLabel, MaxModeTooltip,
+    MaxModeTooltip,
     preview::{AgentPreview, UsageCallout},
 };
-use assistant_settings::{AssistantSettings, CompletionMode};
+use agent::{
+    context::{AgentContextKey, ContextLoadResult, load_context},
+    context_store::ContextStoreEvent,
+};
+use agent_settings::{AgentSettings, CompletionMode};
 use buffer_diff::BufferDiff;
 use client::UserStore;
 use collections::{HashMap, HashSet};
 use editor::actions::{MoveUp, Paste};
+use editor::display_map::CreaseId;
 use editor::{
-    AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorEvent,
-    EditorMode, EditorStyle, MultiBuffer,
+    Addon, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
+    EditorEvent, EditorMode, EditorStyle, MultiBuffer,
 };
 use file_icons::FileIcons;
 use fs::Fs;
 use futures::future::Shared;
 use futures::{FutureExt as _, future};
 use gpui::{
-    Animation, AnimationExt, App, ClipboardEntry, Entity, EventEmitter, Focusable, Subscription,
-    Task, TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
+    Animation, AnimationExt, App, Entity, EventEmitter, Focusable, Subscription, Task, TextStyle,
+    WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
 };
-use language::{Buffer, Language};
+use language::{Buffer, Language, Point};
 use language_model::{
-    ConfiguredModel, LanguageModelRequestMessage, MessageContent, RequestUsage,
-    ZED_CLOUD_PROVIDER_ID,
+    ConfiguredModel, LanguageModelRequestMessage, MessageContent, ZED_CLOUD_PROVIDER_ID,
 };
-use language_model_selector::ToggleModelSelector;
 use multi_buffer;
 use project::Project;
 use prompt_store::PromptStore;
@@ -38,19 +42,25 @@ use proto::Plan;
 use settings::Settings;
 use std::time::Duration;
 use theme::ThemeSettings;
-use ui::{Disclosure, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
-use util::{ResultExt as _, maybe};
+use ui::{
+    Callout, Disclosure, Divider, DividerColor, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*,
+};
+use util::ResultExt as _;
 use workspace::{CollaboratorId, Workspace};
+use zed_llm_client::CompletionIntent;
 
 use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention};
-use crate::context_store::ContextStore;
 use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
 use crate::profile_selector::ProfileSelector;
-use crate::thread::{MessageCrease, Thread, TokenUsageRatio};
-use crate::thread_store::{TextThreadStore, ThreadStore};
 use crate::{
-    ActiveThread, AgentDiffPane, Chat, ExpandMessageEditor, Follow, NewThread, OpenAgentDiff,
-    RemoveAllContext, ToggleContextPicker, ToggleProfileSelector, register_agent_preview,
+    ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll,
+    ModelUsageContext, NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode,
+    ToggleContextPicker, ToggleProfileSelector, register_agent_preview,
+};
+use agent::{
+    MessageCrease, Thread, TokenUsageRatio,
+    context_store::ContextStore,
+    thread_store::{TextThreadStore, ThreadStore},
 };
 
 #[derive(RegisterComponent)]
@@ -71,11 +81,12 @@ pub struct MessageEditor {
     profile_selector: Entity<ProfileSelector>,
     edits_expanded: bool,
     editor_is_expanded: bool,
-    last_estimated_token_count: Option<usize>,
+    last_estimated_token_count: Option<u64>,
     update_token_count_task: Option<Task<()>>,
     _subscriptions: Vec<Subscription>,
 }
 
+const MIN_EDITOR_LINES: usize = 4;
 const MAX_EDITOR_LINES: usize = 8;
 
 pub(crate) fn create_editor(
@@ -83,6 +94,8 @@ pub(crate) fn create_editor(
     context_store: WeakEntity<ContextStore>,
     thread_store: WeakEntity<ThreadStore>,
     text_thread_store: WeakEntity<TextThreadStore>,
+    min_lines: usize,
+    max_lines: Option<usize>,
     window: &mut Window,
     cx: &mut App,
 ) -> Entity<Editor> {
@@ -99,7 +112,8 @@ pub(crate) fn create_editor(
         let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
         let mut editor = Editor::new(
             editor::EditorMode::AutoHeight {
-                max_lines: MAX_EDITOR_LINES,
+                min_lines,
+                max_lines: max_lines,
             },
             buffer,
             None,
@@ -109,6 +123,7 @@ pub(crate) fn create_editor(
         editor.set_placeholder_text("Message the agent – @ to include context", cx);
         editor.set_show_indent_guides(false, cx);
         editor.set_soft_wrap();
+        editor.set_use_modal_editing(true);
         editor.set_context_menu_options(ContextMenuOptions {
             min_entries_visible: 12,
             max_entries_visible: 12,
@@ -120,7 +135,7 @@ pub(crate) fn create_editor(
 
     let editor_entity = editor.downgrade();
     editor.update(cx, |editor, _| {
-        editor.set_completion_provider(Some(Box::new(ContextPickerCompletionProvider::new(
+        editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
             workspace,
             context_store,
             Some(thread_store),
@@ -153,6 +168,8 @@ impl MessageEditor {
             context_store.downgrade(),
             thread_store.clone(),
             text_thread_store.clone(),
+            MIN_EDITOR_LINES,
+            Some(MAX_EDITOR_LINES),
             window,
             cx,
         );
@@ -165,13 +182,13 @@ impl MessageEditor {
                 Some(text_thread_store.clone()),
                 context_picker_menu_handle.clone(),
                 SuggestContextKind::File,
+                ModelUsageContext::Thread(thread.clone()),
                 window,
                 cx,
             )
         });
 
-        let incompatible_tools =
-            cx.new(|cx| IncompatibleToolsState::new(thread.read(cx).tools().clone(), cx));
+        let incompatible_tools = cx.new(|cx| IncompatibleToolsState::new(thread.clone(), cx));
 
         let subscriptions = vec![
             cx.subscribe_in(&context_strip, window, Self::handle_context_strip_event),
@@ -193,21 +210,14 @@ impl MessageEditor {
                 fs.clone(),
                 model_selector_menu_handle,
                 editor.focus_handle(cx),
-                ModelType::Default(thread.clone()),
+                ModelUsageContext::Thread(thread.clone()),
                 window,
                 cx,
             )
         });
 
-        let profile_selector = cx.new(|cx| {
-            ProfileSelector::new(
-                fs,
-                thread.clone(),
-                thread_store,
-                editor.focus_handle(cx),
-                cx,
-            )
-        });
+        let profile_selector =
+            cx.new(|cx| ProfileSelector::new(fs, thread.clone(), editor.focus_handle(cx), cx));
 
         Self {
             editor: editor.clone(),
@@ -236,6 +246,21 @@ impl MessageEditor {
         &self.context_store
     }
 
+    pub fn get_text(&self, cx: &App) -> String {
+        self.editor.read(cx).text(cx)
+    }
+
+    pub fn set_text(
+        &mut self,
+        text: impl Into<Arc<str>>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.editor.update(cx, |editor, cx| {
+            editor.set_text(text, window, cx);
+        });
+    }
+
     pub fn expand_message_editor(
         &mut self,
         _: &ExpandMessageEditor,
@@ -256,7 +281,8 @@ impl MessageEditor {
                 })
             } else {
                 editor.set_mode(EditorMode::AutoHeight {
-                    max_lines: MAX_EDITOR_LINES,
+                    min_lines: MIN_EDITOR_LINES,
+                    max_lines: Some(MAX_EDITOR_LINES),
                 })
             }
         });
@@ -278,7 +304,7 @@ impl MessageEditor {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.context_store.update(cx, |store, _cx| store.clear());
+        self.context_store.update(cx, |store, cx| store.clear(cx));
         cx.notify();
     }
 
@@ -299,9 +325,25 @@ impl MessageEditor {
         self.set_editor_is_expanded(false, cx);
         self.send_to_model(window, cx);
 
+        cx.emit(MessageEditorEvent::ScrollThreadToBottom);
         cx.notify();
     }
 
+    fn chat_with_follow(
+        &mut self,
+        _: &ChatWithFollow,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.workspace
+            .update(cx, |this, cx| {
+                this.follow(CollaboratorId::Agent, window, cx)
+            })
+            .log_err();
+
+        self.chat(&Chat, window, cx);
+    }
+
     fn is_editor_empty(&self, cx: &App) -> bool {
         self.editor.read(cx).text(cx).trim().is_empty()
     }
@@ -358,7 +400,12 @@ impl MessageEditor {
             thread
                 .update(cx, |thread, cx| {
                     thread.advance_prompt_id();
-                    thread.send_to_model(model, Some(window_handle), cx);
+                    thread.send_to_model(
+                        model,
+                        CompletionIntent::UserPrompt,
+                        Some(window_handle),
+                        cx,
+                    );
                 })
                 .log_err();
         })
@@ -401,37 +448,13 @@ impl MessageEditor {
     fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
         if self.context_picker_menu_handle.is_deployed() {
             cx.propagate();
-        } else {
+        } else if self.context_strip.read(cx).has_context_items(cx) {
             self.context_strip.focus_handle(cx).focus(window);
         }
     }
 
     fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
-        let images = cx
-            .read_from_clipboard()
-            .map(|item| {
-                item.into_entries()
-                    .filter_map(|entry| {
-                        if let ClipboardEntry::Image(image) = entry {
-                            Some(image)
-                        } else {
-                            None
-                        }
-                    })
-                    .collect::<Vec<_>>()
-            })
-            .unwrap_or_default();
-
-        if images.is_empty() {
-            return;
-        }
-        cx.stop_propagation();
-
-        self.context_store.update(cx, |store, cx| {
-            for image in images {
-                store.add_image_instance(Arc::new(image), cx);
-            }
-        });
+        crate::active_thread::attach_pasted_images_as_context(&self.context_store, cx);
     }
 
     fn handle_review_click(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -440,6 +463,11 @@ impl MessageEditor {
         cx.notify();
     }
 
+    fn handle_edit_bar_expand(&mut self, cx: &mut Context<Self>) {
+        self.edits_expanded = !self.edits_expanded;
+        cx.notify();
+    }
+
     fn handle_file_click(
         &self,
         buffer: Entity<Buffer>,
@@ -454,35 +482,122 @@ impl MessageEditor {
         }
     }
 
-    fn render_max_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
+    pub fn toggle_burn_mode(
+        &mut self,
+        _: &ToggleBurnMode,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.thread.update(cx, |thread, _cx| {
+            let active_completion_mode = thread.completion_mode();
+
+            thread.set_completion_mode(match active_completion_mode {
+                CompletionMode::Burn => CompletionMode::Normal,
+                CompletionMode::Normal => CompletionMode::Burn,
+            });
+        });
+    }
+
+    fn handle_accept_all(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        if self.thread.read(cx).has_pending_edit_tool_uses() {
+            return;
+        }
+
+        self.thread.update(cx, |thread, cx| {
+            thread.keep_all_edits(cx);
+        });
+        cx.notify();
+    }
+
+    fn handle_reject_all(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        if self.thread.read(cx).has_pending_edit_tool_uses() {
+            return;
+        }
+
+        // Since there's no reject_all_edits method in the thread API,
+        // we need to iterate through all buffers and reject their edits
+        let action_log = self.thread.read(cx).action_log().clone();
+        let changed_buffers = action_log.read(cx).changed_buffers(cx);
+
+        for (buffer, _) in changed_buffers {
+            self.thread.update(cx, |thread, cx| {
+                let buffer_snapshot = buffer.read(cx);
+                let start = buffer_snapshot.anchor_before(Point::new(0, 0));
+                let end = buffer_snapshot.anchor_after(buffer_snapshot.max_point());
+                thread
+                    .reject_edits_in_ranges(buffer, vec![start..end], cx)
+                    .detach();
+            });
+        }
+        cx.notify();
+    }
+
+    fn handle_reject_file_changes(
+        &mut self,
+        buffer: Entity<Buffer>,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.thread.read(cx).has_pending_edit_tool_uses() {
+            return;
+        }
+
+        self.thread.update(cx, |thread, cx| {
+            let buffer_snapshot = buffer.read(cx);
+            let start = buffer_snapshot.anchor_before(Point::new(0, 0));
+            let end = buffer_snapshot.anchor_after(buffer_snapshot.max_point());
+            thread
+                .reject_edits_in_ranges(buffer, vec![start..end], cx)
+                .detach();
+        });
+        cx.notify();
+    }
+
+    fn handle_accept_file_changes(
+        &mut self,
+        buffer: Entity<Buffer>,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.thread.read(cx).has_pending_edit_tool_uses() {
+            return;
+        }
+
+        self.thread.update(cx, |thread, cx| {
+            let buffer_snapshot = buffer.read(cx);
+            let start = buffer_snapshot.anchor_before(Point::new(0, 0));
+            let end = buffer_snapshot.anchor_after(buffer_snapshot.max_point());
+            thread.keep_edits_in_range(buffer, start..end, cx);
+        });
+        cx.notify();
+    }
+
+    fn render_burn_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
         let thread = self.thread.read(cx);
         let model = thread.configured_model();
-        if !model?.model.supports_max_mode() {
+        if !model?.model.supports_burn_mode() {
             return None;
         }
 
         let active_completion_mode = thread.completion_mode();
-        let max_mode_enabled = active_completion_mode == CompletionMode::Max;
+        let burn_mode_enabled = active_completion_mode == CompletionMode::Burn;
+        let icon = if burn_mode_enabled {
+            IconName::ZedBurnModeOn
+        } else {
+            IconName::ZedBurnMode
+        };
 
         Some(
-            Button::new("max-mode", "Max Mode")
-                .label_size(LabelSize::Small)
-                .color(Color::Muted)
-                .icon(IconName::ZedMaxMode)
+            IconButton::new("burn-mode", icon)
                 .icon_size(IconSize::Small)
                 .icon_color(Color::Muted)
-                .icon_position(IconPosition::Start)
-                .toggle_state(max_mode_enabled)
-                .on_click(cx.listener(move |this, _event, _window, cx| {
-                    this.thread.update(cx, |thread, _cx| {
-                        thread.set_completion_mode(match active_completion_mode {
-                            CompletionMode::Max => CompletionMode::Normal,
-                            CompletionMode::Normal => CompletionMode::Max,
-                        });
-                    });
+                .toggle_state(burn_mode_enabled)
+                .selected_icon_color(Color::Error)
+                .on_click(cx.listener(|this, _event, window, cx| {
+                    this.toggle_burn_mode(&ToggleBurnMode, window, cx);
                 }))
                 .tooltip(move |_window, cx| {
-                    cx.new(|_| MaxModeTooltip::new().selected(max_mode_enabled))
+                    cx.new(|_| MaxModeTooltip::new().selected(burn_mode_enabled))
                         .into()
                 })
                 .into_any_element(),
@@ -562,6 +677,7 @@ impl MessageEditor {
         v_flex()
             .key_context("MessageEditor")
             .on_action(cx.listener(Self::chat))
+            .on_action(cx.listener(Self::chat_with_follow))
             .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
                 this.profile_selector
                     .read(cx)
@@ -576,6 +692,13 @@ impl MessageEditor {
             .on_action(cx.listener(Self::remove_all_context))
             .on_action(cx.listener(Self::move_up))
             .on_action(cx.listener(Self::expand_message_editor))
+            .on_action(cx.listener(Self::toggle_burn_mode))
+            .on_action(
+                cx.listener(|this, _: &KeepAll, window, cx| this.handle_accept_all(window, cx)),
+            )
+            .on_action(
+                cx.listener(|this, _: &RejectAll, window, cx| this.handle_reject_all(window, cx)),
+            )
             .capture_action(cx.listener(Self::paste))
             .gap_2()
             .p_2()
@@ -584,97 +707,87 @@ impl MessageEditor {
             .border_color(cx.theme().colors().border)
             .child(
                 h_flex()
-                    .items_start()
                     .justify_between()
                     .child(self.context_strip.clone())
-                    .child(
-                        h_flex()
-                            .gap_1()
-                            .when(focus_handle.is_focused(window), |this| {
-                                this.child(
-                                    IconButton::new("toggle-height", expand_icon)
-                                        .icon_size(IconSize::XSmall)
-                                        .icon_color(Color::Muted)
-                                        .tooltip({
-                                            let focus_handle = focus_handle.clone();
-                                            move |window, cx| {
-                                                let expand_label = if is_editor_expanded {
-                                                    "Minimize Message Editor".to_string()
-                                                } else {
-                                                    "Expand Message Editor".to_string()
-                                                };
-
-                                                Tooltip::for_action_in(
-                                                    expand_label,
-                                                    &ExpandMessageEditor,
-                                                    &focus_handle,
-                                                    window,
-                                                    cx,
-                                                )
-                                            }
-                                        })
-                                        .on_click(cx.listener(|_, _, window, cx| {
-                                            window
-                                                .dispatch_action(Box::new(ExpandMessageEditor), cx);
-                                        })),
-                                )
-                            }),
-                    ),
+                    .when(focus_handle.is_focused(window), |this| {
+                        this.child(
+                            IconButton::new("toggle-height", expand_icon)
+                                .icon_size(IconSize::XSmall)
+                                .icon_color(Color::Muted)
+                                .tooltip({
+                                    let focus_handle = focus_handle.clone();
+                                    move |window, cx| {
+                                        let expand_label = if is_editor_expanded {
+                                            "Minimize Message Editor".to_string()
+                                        } else {
+                                            "Expand Message Editor".to_string()
+                                        };
+
+                                        Tooltip::for_action_in(
+                                            expand_label,
+                                            &ExpandMessageEditor,
+                                            &focus_handle,
+                                            window,
+                                            cx,
+                                        )
+                                    }
+                                })
+                                .on_click(cx.listener(|_, _, window, cx| {
+                                    window.dispatch_action(Box::new(ExpandMessageEditor), cx);
+                                })),
+                        )
+                    }),
             )
             .child(
                 v_flex()
                     .size_full()
-                    .gap_4()
+                    .gap_1()
                     .when(is_editor_expanded, |this| {
                         this.h(vh(0.8, window)).justify_between()
                     })
-                    .child(
-                        v_flex()
-                            .min_h_16()
-                            .when(is_editor_expanded, |this| this.h_full())
-                            .child({
-                                let settings = ThemeSettings::get_global(cx);
-                                let font_size = TextSize::Small
-                                    .rems(cx)
-                                    .to_pixels(settings.agent_font_size(cx));
-                                let line_height = settings.buffer_line_height.value() * font_size;
-
-                                let text_style = TextStyle {
-                                    color: cx.theme().colors().text,
-                                    font_family: settings.buffer_font.family.clone(),
-                                    font_fallbacks: settings.buffer_font.fallbacks.clone(),
-                                    font_features: settings.buffer_font.features.clone(),
-                                    font_size: font_size.into(),
-                                    line_height: line_height.into(),
-                                    ..Default::default()
-                                };
-
-                                EditorElement::new(
-                                    &self.editor,
-                                    EditorStyle {
-                                        background: editor_bg_color,
-                                        local_player: cx.theme().players().local(),
-                                        text: text_style,
-                                        syntax: cx.theme().syntax().clone(),
-                                        ..Default::default()
-                                    },
-                                )
-                                .into_any()
-                            }),
-                    )
+                    .child({
+                        let settings = ThemeSettings::get_global(cx);
+                        let font_size = TextSize::Small
+                            .rems(cx)
+                            .to_pixels(settings.agent_font_size(cx));
+                        let line_height = settings.buffer_line_height.value() * font_size;
+
+                        let text_style = TextStyle {
+                            color: cx.theme().colors().text,
+                            font_family: settings.buffer_font.family.clone(),
+                            font_fallbacks: settings.buffer_font.fallbacks.clone(),
+                            font_features: settings.buffer_font.features.clone(),
+                            font_size: font_size.into(),
+                            line_height: line_height.into(),
+                            ..Default::default()
+                        };
+
+                        EditorElement::new(
+                            &self.editor,
+                            EditorStyle {
+                                background: editor_bg_color,
+                                local_player: cx.theme().players().local(),
+                                text: text_style,
+                                syntax: cx.theme().syntax().clone(),
+                                ..Default::default()
+                            },
+                        )
+                        .into_any()
+                    })
                     .child(
                         h_flex()
                             .flex_none()
+                            .flex_wrap()
                             .justify_between()
                             .child(
                                 h_flex()
-                                    .gap_1()
                                     .child(self.render_follow_toggle(cx))
-                                    .children(self.render_max_mode_toggle(cx)),
+                                    .children(self.render_burn_mode_toggle(cx)),
                             )
                             .child(
                                 h_flex()
                                     .gap_1()
+                                    .flex_wrap()
                                     .when(!incompatible_tools.is_empty(), |this| {
                                         this.child(
                                             IconButton::new(
@@ -818,7 +931,7 @@ impl MessageEditor {
             )
     }
 
-    fn render_changed_buffers(
+    fn render_edits_bar(
         &self,
         changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
         window: &mut Window,
@@ -832,7 +945,10 @@ impl MessageEditor {
         let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
 
         let is_edit_changes_expanded = self.edits_expanded;
-        let is_generating = self.thread.read(cx).is_generating();
+        let thread = self.thread.read(cx);
+        let pending_edits = thread.has_pending_edit_tool_uses();
+
+        const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
 
         v_flex()
             .mt_1()
@@ -842,7 +958,7 @@ impl MessageEditor {
             .border_b_0()
             .border_color(border_color)
             .rounded_t_md()
-            .shadow(smallvec::smallvec![gpui::BoxShadow {
+            .shadow(vec![gpui::BoxShadow {
                 color: gpui::black().opacity(0.15),
                 offset: point(px(1.), px(-1.)),
                 blur_radius: px(3.),
@@ -850,31 +966,28 @@ impl MessageEditor {
             }])
             .child(
                 h_flex()
-                    .id("edits-container")
-                    .cursor_pointer()
-                    .p_1p5()
+                    .p_1()
                     .justify_between()
                     .when(is_edit_changes_expanded, |this| {
                         this.border_b_1().border_color(border_color)
                     })
-                    .on_click(
-                        cx.listener(|this, _, window, cx| this.handle_review_click(window, cx)),
-                    )
                     .child(
                         h_flex()
+                            .id("edits-container")
+                            .cursor_pointer()
+                            .w_full()
                             .gap_1()
                             .child(
                                 Disclosure::new("edits-disclosure", is_edit_changes_expanded)
-                                    .on_click(cx.listener(|this, _ev, _window, cx| {
-                                        this.edits_expanded = !this.edits_expanded;
-                                        cx.notify();
+                                    .on_click(cx.listener(|this, _, _, cx| {
+                                        this.handle_edit_bar_expand(cx)
                                     })),
                             )
                             .map(|this| {
-                                if is_generating {
+                                if pending_edits {
                                     this.child(
-                                        AnimatedLabel::new(format!(
-                                            "Editing {} {}",
+                                        Label::new(format!(
+                                            "Editing {} {}…",
                                             changed_buffers.len(),
                                             if changed_buffers.len() == 1 {
                                                 "file"
@@ -882,7 +995,15 @@ impl MessageEditor {
                                                 "files"
                                             }
                                         ))
-                                        .size(LabelSize::Small),
+                                        .color(Color::Muted)
+                                        .size(LabelSize::Small)
+                                        .with_animation(
+                                            "edit-label",
+                                            Animation::new(Duration::from_secs(2))
+                                                .repeat()
+                                                .with_easing(pulsating_between(0.3, 0.7)),
+                                            |label, delta| label.alpha(delta),
+                                        ),
                                     )
                                 } else {
                                     this.child(
@@ -907,23 +1028,74 @@ impl MessageEditor {
                                         .color(Color::Muted),
                                     )
                                 }
-                            }),
+                            })
+                            .on_click(
+                                cx.listener(|this, _, _, cx| this.handle_edit_bar_expand(cx)),
+                            ),
                     )
                     .child(
-                        Button::new("review", "Review Changes")
-                            .label_size(LabelSize::Small)
-                            .key_binding(
-                                KeyBinding::for_action_in(
-                                    &OpenAgentDiff,
-                                    &focus_handle,
-                                    window,
-                                    cx,
-                                )
-                                .map(|kb| kb.size(rems_from_px(12.))),
+                        h_flex()
+                            .gap_1()
+                            .child(
+                                IconButton::new("review-changes", IconName::ListTodo)
+                                    .icon_size(IconSize::Small)
+                                    .tooltip({
+                                        let focus_handle = focus_handle.clone();
+                                        move |window, cx| {
+                                            Tooltip::for_action_in(
+                                                "Review Changes",
+                                                &OpenAgentDiff,
+                                                &focus_handle,
+                                                window,
+                                                cx,
+                                            )
+                                        }
+                                    })
+                                    .on_click(cx.listener(|this, _, window, cx| {
+                                        this.handle_review_click(window, cx)
+                                    })),
+                            )
+                            .child(Divider::vertical().color(DividerColor::Border))
+                            .child(
+                                Button::new("reject-all-changes", "Reject All")
+                                    .label_size(LabelSize::Small)
+                                    .disabled(pending_edits)
+                                    .when(pending_edits, |this| {
+                                        this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
+                                    })
+                                    .key_binding(
+                                        KeyBinding::for_action_in(
+                                            &RejectAll,
+                                            &focus_handle.clone(),
+                                            window,
+                                            cx,
+                                        )
+                                        .map(|kb| kb.size(rems_from_px(10.))),
+                                    )
+                                    .on_click(cx.listener(|this, _, window, cx| {
+                                        this.handle_reject_all(window, cx)
+                                    })),
                             )
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.handle_review_click(window, cx)
-                            })),
+                            .child(
+                                Button::new("accept-all-changes", "Accept All")
+                                    .label_size(LabelSize::Small)
+                                    .disabled(pending_edits)
+                                    .when(pending_edits, |this| {
+                                        this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
+                                    })
+                                    .key_binding(
+                                        KeyBinding::for_action_in(
+                                            &KeepAll,
+                                            &focus_handle,
+                                            window,
+                                            cx,
+                                        )
+                                        .map(|kb| kb.size(rems_from_px(10.))),
+                                    )
+                                    .on_click(cx.listener(|this, _, window, cx| {
+                                        this.handle_accept_all(window, cx)
+                                    })),
+                            ),
                     ),
             )
             .when(is_edit_changes_expanded, |parent| {
@@ -933,7 +1105,7 @@ impl MessageEditor {
                             let file = buffer.read(cx).file()?;
                             let path = file.path();
 
-                            let parent_label = path.parent().and_then(|parent| {
+                            let file_path = path.parent().and_then(|parent| {
                                 let parent_str = parent.to_string_lossy();
 
                                 if parent_str.is_empty() {
@@ -952,7 +1124,7 @@ impl MessageEditor {
                                 }
                             });
 
-                            let name_label = path.file_name().map(|name| {
+                            let file_name = path.file_name().map(|name| {
                                 Label::new(name.to_string_lossy().to_string())
                                     .size(LabelSize::XSmall)
                                     .buffer_font(cx)
@@ -967,36 +1139,22 @@ impl MessageEditor {
                                         .size(IconSize::Small)
                                 });
 
-                            let hover_color = cx
-                                .theme()
-                                .colors()
-                                .element_background
-                                .blend(cx.theme().colors().editor_foreground.opacity(0.025));
-
                             let overlay_gradient = linear_gradient(
                                 90.,
                                 linear_color_stop(editor_bg_color, 1.),
                                 linear_color_stop(editor_bg_color.opacity(0.2), 0.),
                             );
 
-                            let overlay_gradient_hover = linear_gradient(
-                                90.,
-                                linear_color_stop(hover_color, 1.),
-                                linear_color_stop(hover_color.opacity(0.2), 0.),
-                            );
-
                             let element = h_flex()
                                 .group("edited-code")
                                 .id(("file-container", index))
-                                .cursor_pointer()
                                 .relative()
                                 .py_1()
                                 .pl_2()
                                 .pr_1()
                                 .gap_2()
                                 .justify_between()
-                                .bg(cx.theme().colors().editor_background)
-                                .hover(|style| style.bg(hover_color))
+                                .bg(editor_bg_color)
                                 .when(index < changed_buffers.len() - 1, |parent| {
                                     parent.border_color(border_color).border_b_1()
                                 })
@@ -1011,47 +1169,75 @@ impl MessageEditor {
                                         .child(
                                             h_flex()
                                                 .gap_0p5()
-                                                .children(name_label)
-                                                .children(parent_label),
+                                                .children(file_name)
+                                                .children(file_path),
                                         ), // TODO: Implement line diff
                                            // .child(Label::new("+").color(Color::Created))
                                            // .child(Label::new("-").color(Color::Deleted)),
                                 )
                                 .child(
-                                    div().visible_on_hover("edited-code").child(
-                                        Button::new("review", "Review")
-                                            .label_size(LabelSize::Small)
-                                            .on_click({
-                                                let buffer = buffer.clone();
-                                                cx.listener(move |this, _, window, cx| {
-                                                    this.handle_file_click(
-                                                        buffer.clone(),
-                                                        window,
-                                                        cx,
-                                                    );
-                                                })
-                                            }),
-                                    ),
+                                    h_flex()
+                                        .gap_1()
+                                        .visible_on_hover("edited-code")
+                                        .child(
+                                            Button::new("review", "Review")
+                                                .label_size(LabelSize::Small)
+                                                .on_click({
+                                                    let buffer = buffer.clone();
+                                                    cx.listener(move |this, _, window, cx| {
+                                                        this.handle_file_click(
+                                                            buffer.clone(),
+                                                            window,
+                                                            cx,
+                                                        );
+                                                    })
+                                                }),
+                                        )
+                                        .child(
+                                            Divider::vertical().color(DividerColor::BorderVariant),
+                                        )
+                                        .child(
+                                            Button::new("reject-file", "Reject")
+                                                .label_size(LabelSize::Small)
+                                                .disabled(pending_edits)
+                                                .on_click({
+                                                    let buffer = buffer.clone();
+                                                    cx.listener(move |this, _, window, cx| {
+                                                        this.handle_reject_file_changes(
+                                                            buffer.clone(),
+                                                            window,
+                                                            cx,
+                                                        );
+                                                    })
+                                                }),
+                                        )
+                                        .child(
+                                            Button::new("accept-file", "Accept")
+                                                .label_size(LabelSize::Small)
+                                                .disabled(pending_edits)
+                                                .on_click({
+                                                    let buffer = buffer.clone();
+                                                    cx.listener(move |this, _, window, cx| {
+                                                        this.handle_accept_file_changes(
+                                                            buffer.clone(),
+                                                            window,
+                                                            cx,
+                                                        );
+                                                    })
+                                                }),
+                                        ),
                                 )
                                 .child(
                                     div()
                                         .id("gradient-overlay")
                                         .absolute()
-                                        .h_5_6()
+                                        .h_full()
                                         .w_12()
+                                        .top_0()
                                         .bottom_0()
-                                        .right(px(52.))
-                                        .bg(overlay_gradient)
-                                        .group_hover("edited-code", |style| {
-                                            style.bg(overlay_gradient_hover)
-                                        }),
-                                )
-                                .on_click({
-                                    let buffer = buffer.clone();
-                                    cx.listener(move |this, _, window, cx| {
-                                        this.handle_file_click(buffer.clone(), window, cx);
-                                    })
-                                });
+                                        .right(px(152.))
+                                        .bg(overlay_gradient),
+                                );
 
                             Some(element)
                         },
@@ -1060,15 +1246,17 @@ impl MessageEditor {
             })
     }
 
-    fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context<Self>) -> Option<Div> {
-        let is_using_zed_provider = self
-            .thread
+    fn is_using_zed_provider(&self, cx: &App) -> bool {
+        self.thread
             .read(cx)
             .configured_model()
             .map_or(false, |model| {
                 model.provider.id().0 == ZED_CLOUD_PROVIDER_ID
-            });
-        if !is_using_zed_provider {
+            })
+    }
+
+    fn render_usage_callout(&self, line_height: Pixels, cx: &mut Context<Self>) -> Option<Div> {
+        if !self.is_using_zed_provider(cx) {
             return None;
         }
 

crates/agent/src/profile_selector.rs → crates/agent_ui/src/profile_selector.rs 🔗

@@ -1,26 +1,23 @@
-use std::sync::Arc;
-
-use assistant_settings::{
-    AgentProfile, AgentProfileId, AssistantDockPosition, AssistantSettings, GroupedAgentProfiles,
-    builtin_profiles,
+use crate::{ManageProfiles, ToggleProfileSelector};
+use agent::{
+    Thread,
+    agent_profile::{AgentProfile, AvailableProfiles},
 };
+use agent_settings::{AgentDockPosition, AgentProfileId, AgentSettings, builtin_profiles};
 use fs::Fs;
-use gpui::{Action, Entity, FocusHandle, Subscription, WeakEntity, prelude::*};
+use gpui::{Action, Empty, Entity, FocusHandle, Subscription, prelude::*};
 use language_model::LanguageModelRegistry;
 use settings::{Settings as _, SettingsStore, update_settings_file};
+use std::sync::Arc;
 use ui::{
     ContextMenu, ContextMenuEntry, DocumentationSide, PopoverMenu, PopoverMenuHandle, Tooltip,
     prelude::*,
 };
-use util::ResultExt as _;
-
-use crate::{ManageProfiles, Thread, ThreadStore, ToggleProfileSelector};
 
 pub struct ProfileSelector {
-    profiles: GroupedAgentProfiles,
+    profiles: AvailableProfiles,
     fs: Arc<dyn Fs>,
     thread: Entity<Thread>,
-    thread_store: WeakEntity<ThreadStore>,
     menu_handle: PopoverMenuHandle<ContextMenu>,
     focus_handle: FocusHandle,
     _subscriptions: Vec<Subscription>,
@@ -30,7 +27,6 @@ impl ProfileSelector {
     pub fn new(
         fs: Arc<dyn Fs>,
         thread: Entity<Thread>,
-        thread_store: WeakEntity<ThreadStore>,
         focus_handle: FocusHandle,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -39,10 +35,9 @@ impl ProfileSelector {
         });
 
         Self {
-            profiles: GroupedAgentProfiles::from_settings(AssistantSettings::get_global(cx)),
+            profiles: AgentProfile::available_profiles(cx),
             fs,
             thread,
-            thread_store,
             menu_handle: PopoverMenuHandle::default(),
             focus_handle,
             _subscriptions: vec![settings_subscription],
@@ -54,7 +49,7 @@ impl ProfileSelector {
     }
 
     fn refresh_profiles(&mut self, cx: &mut Context<Self>) {
-        self.profiles = GroupedAgentProfiles::from_settings(AssistantSettings::get_global(cx));
+        self.profiles = AgentProfile::available_profiles(cx);
     }
 
     fn build_context_menu(
@@ -63,22 +58,31 @@ impl ProfileSelector {
         cx: &mut Context<Self>,
     ) -> Entity<ContextMenu> {
         ContextMenu::build(window, cx, |mut menu, _window, cx| {
-            let settings = AssistantSettings::get_global(cx);
-            for (profile_id, profile) in self.profiles.builtin.iter() {
+            let settings = AgentSettings::get_global(cx);
+
+            let mut found_non_builtin = false;
+            for (profile_id, profile_name) in self.profiles.iter() {
+                if !builtin_profiles::is_builtin(profile_id) {
+                    found_non_builtin = true;
+                    continue;
+                }
                 menu = menu.item(self.menu_entry_for_profile(
                     profile_id.clone(),
-                    profile,
+                    profile_name,
                     settings,
                     cx,
                 ));
             }
 
-            if !self.profiles.custom.is_empty() {
+            if found_non_builtin {
                 menu = menu.separator().header("Custom Profiles");
-                for (profile_id, profile) in self.profiles.custom.iter() {
+                for (profile_id, profile_name) in self.profiles.iter() {
+                    if builtin_profiles::is_builtin(profile_id) {
+                        continue;
+                    }
                     menu = menu.item(self.menu_entry_for_profile(
                         profile_id.clone(),
-                        profile,
+                        profile_name,
                         settings,
                         cx,
                     ));
@@ -99,19 +103,20 @@ impl ProfileSelector {
     fn menu_entry_for_profile(
         &self,
         profile_id: AgentProfileId,
-        profile: &AgentProfile,
-        settings: &AssistantSettings,
-        _cx: &App,
+        profile_name: &SharedString,
+        settings: &AgentSettings,
+        cx: &App,
     ) -> ContextMenuEntry {
-        let documentation = match profile.name.to_lowercase().as_str() {
+        let documentation = match profile_name.to_lowercase().as_str() {
             builtin_profiles::WRITE => Some("Get help to write anything."),
             builtin_profiles::ASK => Some("Chat about your codebase."),
             builtin_profiles::MINIMAL => Some("Chat about anything with no tools."),
             _ => None,
         };
+        let thread_profile_id = self.thread.read(cx).profile().id();
 
-        let entry = ContextMenuEntry::new(profile.name.clone())
-            .toggleable(IconPosition::End, profile_id == settings.default_profile);
+        let entry = ContextMenuEntry::new(profile_name.clone())
+            .toggleable(IconPosition::End, &profile_id == thread_profile_id);
 
         let entry = if let Some(doc_text) = documentation {
             entry.documentation_aside(documentation_side(settings.dock), move |_| {
@@ -123,21 +128,19 @@ impl ProfileSelector {
 
         entry.handler({
             let fs = self.fs.clone();
-            let thread_store = self.thread_store.clone();
+            let thread = self.thread.clone();
             let profile_id = profile_id.clone();
             move |_window, cx| {
-                update_settings_file::<AssistantSettings>(fs.clone(), cx, {
+                update_settings_file::<AgentSettings>(fs.clone(), cx, {
                     let profile_id = profile_id.clone();
                     move |settings, _cx| {
                         settings.set_profile(profile_id.clone());
                     }
                 });
 
-                thread_store
-                    .update(cx, |this, cx| {
-                        this.load_profile_by_id(profile_id.clone(), cx);
-                    })
-                    .log_err();
+                thread.update(cx, |this, cx| {
+                    this.set_profile(profile_id.clone(), cx);
+                });
             }
         })
     }
@@ -145,25 +148,23 @@ impl ProfileSelector {
 
 impl Render for ProfileSelector {
     fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let settings = AssistantSettings::get_global(cx);
-        let profile_id = &settings.default_profile;
+        let settings = AgentSettings::get_global(cx);
+        let profile_id = self.thread.read(cx).profile().id();
         let profile = settings.profiles.get(profile_id);
 
         let selected_profile = profile
             .map(|profile| profile.name.clone())
             .unwrap_or_else(|| "Unknown".into());
 
-        let configured_model = self
-            .thread
-            .read_with(cx, |thread, _cx| thread.configured_model())
-            .or_else(|| {
-                let model_registry = LanguageModelRegistry::read_global(cx);
-                model_registry.default_model()
-            });
-        let supports_tools =
-            configured_model.map_or(false, |default| default.model.supports_tools());
-
-        if supports_tools {
+        let configured_model = self.thread.read(cx).configured_model().or_else(|| {
+            let model_registry = LanguageModelRegistry::read_global(cx);
+            model_registry.default_model()
+        });
+        let Some(configured_model) = configured_model else {
+            return Empty.into_any_element();
+        };
+
+        if configured_model.model.supports_tools() {
             let this = cx.entity().clone();
             let focus_handle = self.focus_handle.clone();
             let trigger_button = Button::new("profile-selector-model", selected_profile)
@@ -210,10 +211,10 @@ impl Render for ProfileSelector {
     }
 }
 
-fn documentation_side(position: AssistantDockPosition) -> DocumentationSide {
+fn documentation_side(position: AgentDockPosition) -> DocumentationSide {
     match position {
-        AssistantDockPosition::Left => DocumentationSide::Right,
-        AssistantDockPosition::Bottom => DocumentationSide::Left,
-        AssistantDockPosition::Right => DocumentationSide::Left,
+        AgentDockPosition::Left => DocumentationSide::Right,
+        AgentDockPosition::Bottom => DocumentationSide::Left,
+        AgentDockPosition::Right => DocumentationSide::Left,
     }
 }

crates/assistant_context_editor/src/slash_command.rs → crates/agent_ui/src/slash_command.rs 🔗

@@ -1,4 +1,4 @@
-use crate::context_editor::ContextEditor;
+use crate::text_thread_editor::TextThreadEditor;
 use anyhow::Result;
 pub use assistant_slash_command::SlashCommand;
 use assistant_slash_command::{AfterCompletion, SlashCommandLine, SlashCommandWorkingSet};
@@ -10,9 +10,7 @@ use parking_lot::Mutex;
 use project::{CompletionIntent, CompletionSource, lsp_store::CompletionDocumentation};
 use rope::Point;
 use std::{
-    cell::RefCell,
     ops::Range,
-    rc::Rc,
     sync::{
         Arc,
         atomic::{AtomicBool, Ordering::SeqCst},
@@ -23,14 +21,14 @@ use workspace::Workspace;
 pub struct SlashCommandCompletionProvider {
     cancel_flag: Mutex<Arc<AtomicBool>>,
     slash_commands: Arc<SlashCommandWorkingSet>,
-    editor: Option<WeakEntity<ContextEditor>>,
+    editor: Option<WeakEntity<TextThreadEditor>>,
     workspace: Option<WeakEntity<Workspace>>,
 }
 
 impl SlashCommandCompletionProvider {
     pub fn new(
         slash_commands: Arc<SlashCommandWorkingSet>,
-        editor: Option<WeakEntity<ContextEditor>>,
+        editor: Option<WeakEntity<TextThreadEditor>>,
         workspace: Option<WeakEntity<Workspace>>,
     ) -> Self {
         Self {
@@ -48,7 +46,7 @@ impl SlashCommandCompletionProvider {
         name_range: Range<Anchor>,
         window: &mut Window,
         cx: &mut App,
-    ) -> Task<Result<Option<Vec<project::Completion>>>> {
+    ) -> Task<Result<Vec<project::CompletionResponse>>> {
         let slash_commands = self.slash_commands.clone();
         let candidates = slash_commands
             .command_names(cx)
@@ -64,6 +62,7 @@ impl SlashCommandCompletionProvider {
                 &candidates,
                 &command_name,
                 true,
+                true,
                 usize::MAX,
                 &Default::default(),
                 cx.background_executor().clone(),
@@ -71,28 +70,27 @@ impl SlashCommandCompletionProvider {
             .await;
 
             cx.update(|_, cx| {
-                Some(
-                    matches
-                        .into_iter()
-                        .filter_map(|mat| {
-                            let command = slash_commands.command(&mat.string, cx)?;
-                            let mut new_text = mat.string.clone();
-                            let requires_argument = command.requires_argument();
-                            let accepts_arguments = command.accepts_arguments();
-                            if requires_argument || accepts_arguments {
-                                new_text.push(' ');
-                            }
+                let completions = matches
+                    .into_iter()
+                    .filter_map(|mat| {
+                        let command = slash_commands.command(&mat.string, cx)?;
+                        let mut new_text = mat.string.clone();
+                        let requires_argument = command.requires_argument();
+                        let accepts_arguments = command.accepts_arguments();
+                        if requires_argument || accepts_arguments {
+                            new_text.push(' ');
+                        }
 
-                            let confirm =
-                                editor
-                                    .clone()
-                                    .zip(workspace.clone())
-                                    .map(|(editor, workspace)| {
-                                        let command_name = mat.string.clone();
-                                        let command_range = command_range.clone();
-                                        let editor = editor.clone();
-                                        let workspace = workspace.clone();
-                                        Arc::new(
+                        let confirm =
+                            editor
+                                .clone()
+                                .zip(workspace.clone())
+                                .map(|(editor, workspace)| {
+                                    let command_name = mat.string.clone();
+                                    let command_range = command_range.clone();
+                                    let editor = editor.clone();
+                                    let workspace = workspace.clone();
+                                    Arc::new(
                                             move |intent: CompletionIntent,
                                             window: &mut Window,
                                             cx: &mut App| {
@@ -118,22 +116,27 @@ impl SlashCommandCompletionProvider {
                                                 }
                                             },
                                         ) as Arc<_>
-                                    });
-                            Some(project::Completion {
-                                replace_range: name_range.clone(),
-                                documentation: Some(CompletionDocumentation::SingleLine(
-                                    command.description().into(),
-                                )),
-                                new_text,
-                                label: command.label(cx),
-                                icon_path: None,
-                                insert_text_mode: None,
-                                confirm,
-                                source: CompletionSource::Custom,
-                            })
+                                });
+
+                        Some(project::Completion {
+                            replace_range: name_range.clone(),
+                            documentation: Some(CompletionDocumentation::SingleLine(
+                                command.description().into(),
+                            )),
+                            new_text,
+                            label: command.label(cx),
+                            icon_path: None,
+                            insert_text_mode: None,
+                            confirm,
+                            source: CompletionSource::Custom,
                         })
-                        .collect(),
-                )
+                    })
+                    .collect();
+
+                vec![project::CompletionResponse {
+                    completions,
+                    is_incomplete: false,
+                }]
             })
         })
     }
@@ -147,7 +150,7 @@ impl SlashCommandCompletionProvider {
         last_argument_range: Range<Anchor>,
         window: &mut Window,
         cx: &mut App,
-    ) -> Task<Result<Option<Vec<project::Completion>>>> {
+    ) -> Task<Result<Vec<project::CompletionResponse>>> {
         let new_cancel_flag = Arc::new(AtomicBool::new(false));
         let mut flag = self.cancel_flag.lock();
         flag.store(true, SeqCst);
@@ -165,28 +168,27 @@ impl SlashCommandCompletionProvider {
             let workspace = self.workspace.clone();
             let arguments = arguments.to_vec();
             cx.background_spawn(async move {
-                Ok(Some(
-                    completions
-                        .await?
-                        .into_iter()
-                        .map(|new_argument| {
-                            let confirm =
-                                editor
-                                    .clone()
-                                    .zip(workspace.clone())
-                                    .map(|(editor, workspace)| {
-                                        Arc::new({
-                                            let mut completed_arguments = arguments.clone();
-                                            if new_argument.replace_previous_arguments {
-                                                completed_arguments.clear();
-                                            } else {
-                                                completed_arguments.pop();
-                                            }
-                                            completed_arguments.push(new_argument.new_text.clone());
+                let completions = completions
+                    .await?
+                    .into_iter()
+                    .map(|new_argument| {
+                        let confirm =
+                            editor
+                                .clone()
+                                .zip(workspace.clone())
+                                .map(|(editor, workspace)| {
+                                    Arc::new({
+                                        let mut completed_arguments = arguments.clone();
+                                        if new_argument.replace_previous_arguments {
+                                            completed_arguments.clear();
+                                        } else {
+                                            completed_arguments.pop();
+                                        }
+                                        completed_arguments.push(new_argument.new_text.clone());
 
-                                            let command_range = command_range.clone();
-                                            let command_name = command_name.clone();
-                                            move |intent: CompletionIntent,
+                                        let command_range = command_range.clone();
+                                        let command_name = command_name.clone();
+                                        move |intent: CompletionIntent,
                                               window: &mut Window,
                                               cx: &mut App| {
                                             if new_argument.after_completion.run()
@@ -210,34 +212,42 @@ impl SlashCommandCompletionProvider {
                                                 !new_argument.after_completion.run()
                                             }
                                         }
-                                        }) as Arc<_>
-                                    });
+                                    }) as Arc<_>
+                                });
 
-                            let mut new_text = new_argument.new_text.clone();
-                            if new_argument.after_completion == AfterCompletion::Continue {
-                                new_text.push(' ');
-                            }
+                        let mut new_text = new_argument.new_text.clone();
+                        if new_argument.after_completion == AfterCompletion::Continue {
+                            new_text.push(' ');
+                        }
 
-                            project::Completion {
-                                replace_range: if new_argument.replace_previous_arguments {
-                                    argument_range.clone()
-                                } else {
-                                    last_argument_range.clone()
-                                },
-                                label: new_argument.label,
-                                icon_path: None,
-                                new_text,
-                                documentation: None,
-                                confirm,
-                                insert_text_mode: None,
-                                source: CompletionSource::Custom,
-                            }
-                        })
-                        .collect(),
-                ))
+                        project::Completion {
+                            replace_range: if new_argument.replace_previous_arguments {
+                                argument_range.clone()
+                            } else {
+                                last_argument_range.clone()
+                            },
+                            label: new_argument.label,
+                            icon_path: None,
+                            new_text,
+                            documentation: None,
+                            confirm,
+                            insert_text_mode: None,
+                            source: CompletionSource::Custom,
+                        }
+                    })
+                    .collect();
+
+                Ok(vec![project::CompletionResponse {
+                    completions,
+                    // TODO: Could have slash commands indicate whether their completions are incomplete.
+                    is_incomplete: true,
+                }])
             })
         } else {
-            Task::ready(Ok(Some(Vec::new())))
+            Task::ready(Ok(vec![project::CompletionResponse {
+                completions: Vec::new(),
+                is_incomplete: true,
+            }]))
         }
     }
 }
@@ -251,7 +261,7 @@ impl CompletionProvider for SlashCommandCompletionProvider {
         _: editor::CompletionContext,
         window: &mut Window,
         cx: &mut Context<Editor>,
-    ) -> Task<Result<Option<Vec<project::Completion>>>> {
+    ) -> Task<Result<Vec<project::CompletionResponse>>> {
         let Some((name, arguments, command_range, last_argument_range)) =
             buffer.update(cx, |buffer, _cx| {
                 let position = buffer_position.to_point(buffer);
@@ -265,21 +275,21 @@ impl CompletionProvider for SlashCommandCompletionProvider {
                     position.row,
                     call.arguments.last().map_or(call.name.end, |arg| arg.end) as u32,
                 );
-                let command_range = buffer.anchor_after(command_range_start)
+                let command_range = buffer.anchor_before(command_range_start)
                     ..buffer.anchor_after(command_range_end);
 
                 let name = line[call.name.clone()].to_string();
                 let (arguments, last_argument_range) = if let Some(argument) = call.arguments.last()
                 {
                     let last_arg_start =
-                        buffer.anchor_after(Point::new(position.row, argument.start as u32));
+                        buffer.anchor_before(Point::new(position.row, argument.start as u32));
                     let first_arg_start = call.arguments.first().expect("we have the last element");
-                    let first_arg_start =
-                        buffer.anchor_after(Point::new(position.row, first_arg_start.start as u32));
+                    let first_arg_start = buffer
+                        .anchor_before(Point::new(position.row, first_arg_start.start as u32));
                     let arguments = call
                         .arguments
-                        .iter()
-                        .filter_map(|argument| Some(line.get(argument.clone())?.to_string()))
+                        .into_iter()
+                        .filter_map(|argument| Some(line.get(argument)?.to_string()))
                         .collect::<Vec<_>>();
                     let argument_range = first_arg_start..buffer_position;
                     (
@@ -288,14 +298,17 @@ impl CompletionProvider for SlashCommandCompletionProvider {
                     )
                 } else {
                     let start =
-                        buffer.anchor_after(Point::new(position.row, call.name.start as u32));
+                        buffer.anchor_before(Point::new(position.row, call.name.start as u32));
                     (None, start..buffer_position)
                 };
 
                 Some((name, arguments, command_range, last_argument_range))
             })
         else {
-            return Task::ready(Ok(Some(Vec::new())));
+            return Task::ready(Ok(vec![project::CompletionResponse {
+                completions: Vec::new(),
+                is_incomplete: false,
+            }]));
         };
 
         if let Some((arguments, argument_range)) = arguments {
@@ -313,22 +326,13 @@ impl CompletionProvider for SlashCommandCompletionProvider {
         }
     }
 
-    fn resolve_completions(
-        &self,
-        _: Entity<Buffer>,
-        _: Vec<usize>,
-        _: Rc<RefCell<Box<[project::Completion]>>>,
-        _: &mut Context<Editor>,
-    ) -> Task<Result<bool>> {
-        Task::ready(Ok(true))
-    }
-
     fn is_completion_trigger(
         &self,
         buffer: &Entity<Buffer>,
         position: language::Anchor,
         _text: &str,
         _trigger_in_words: bool,
+        _menu_is_open: bool,
         cx: &mut Context<Editor>,
     ) -> bool {
         let buffer = buffer.read(cx);

crates/assistant_context_editor/src/slash_command_picker.rs → crates/agent_ui/src/slash_command_picker.rs 🔗

@@ -1,12 +1,10 @@
-use std::sync::Arc;
-
+use crate::text_thread_editor::TextThreadEditor;
 use assistant_slash_command::SlashCommandWorkingSet;
 use gpui::{AnyElement, AnyView, DismissEvent, SharedString, Task, WeakEntity};
 use picker::{Picker, PickerDelegate, PickerEditorPosition};
+use std::sync::Arc;
 use ui::{ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger, Tooltip, prelude::*};
 
-use crate::context_editor::ContextEditor;
-
 #[derive(IntoElement)]
 pub(super) struct SlashCommandSelector<T, TT>
 where
@@ -14,7 +12,7 @@ where
     TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
 {
     working_set: Arc<SlashCommandWorkingSet>,
-    active_context_editor: WeakEntity<ContextEditor>,
+    active_context_editor: WeakEntity<TextThreadEditor>,
     trigger: T,
     tooltip: TT,
 }
@@ -49,7 +47,7 @@ impl AsRef<str> for SlashCommandEntry {
 pub(crate) struct SlashCommandDelegate {
     all_commands: Vec<SlashCommandEntry>,
     filtered_commands: Vec<SlashCommandEntry>,
-    active_context_editor: WeakEntity<ContextEditor>,
+    active_context_editor: WeakEntity<TextThreadEditor>,
     selected_index: usize,
 }
 
@@ -60,7 +58,7 @@ where
 {
     pub(crate) fn new(
         working_set: Arc<SlashCommandWorkingSet>,
-        active_context_editor: WeakEntity<ContextEditor>,
+        active_context_editor: WeakEntity<TextThreadEditor>,
         trigger: T,
         tooltip: TT,
     ) -> Self {
@@ -338,7 +336,7 @@ where
 
         let handle = self
             .active_context_editor
-            .update(cx, |this, _| this.slash_menu_handle.clone())
+            .read_with(cx, |this, _| this.slash_menu_handle.clone())
             .ok();
         PopoverMenu::new("model-switcher")
             .menu(move |_window, _cx| Some(picker_view.clone()))

crates/agent/src/terminal_codegen.rs → crates/agent_ui/src/terminal_codegen.rs 🔗

@@ -179,21 +179,21 @@ impl TerminalTransaction {
         // Ensure that the assistant cannot accidentally execute commands that are streamed into the terminal
         let input = Self::sanitize_input(hunk);
         self.terminal
-            .update(cx, |terminal, _| terminal.input(input));
+            .update(cx, |terminal, _| terminal.input(input.into_bytes()));
     }
 
     pub fn undo(&self, cx: &mut App) {
         self.terminal
-            .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.to_string()));
+            .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.as_bytes()));
     }
 
     pub fn complete(&self, cx: &mut App) {
-        self.terminal.update(cx, |terminal, _| {
-            terminal.input(CARRIAGE_RETURN.to_string())
-        });
+        self.terminal
+            .update(cx, |terminal, _| terminal.input(CARRIAGE_RETURN.as_bytes()));
     }
 
-    fn sanitize_input(input: String) -> String {
-        input.replace(['\r', '\n'], "")
+    fn sanitize_input(mut input: String) -> String {
+        input.retain(|c| c != '\r' && c != '\n');
+        input
     }
 }

crates/agent/src/terminal_inline_assistant.rs → crates/agent_ui/src/terminal_inline_assistant.rs 🔗

@@ -1,12 +1,14 @@
-use crate::context::load_context;
-use crate::context_store::ContextStore;
 use crate::inline_prompt_editor::{
     CodegenStatus, PromptEditor, PromptEditorEvent, TerminalInlineAssistId,
 };
 use crate::terminal_codegen::{CLEAR_INPUT, CodegenEvent, TerminalCodegen};
-use crate::thread_store::{TextThreadStore, ThreadStore};
+use agent::{
+    context::load_context,
+    context_store::ContextStore,
+    thread_store::{TextThreadStore, ThreadStore},
+};
+use agent_settings::AgentSettings;
 use anyhow::{Context as _, Result};
-use assistant_settings::AssistantSettings;
 use client::telemetry::Telemetry;
 use collections::{HashMap, VecDeque};
 use editor::{MultiBuffer, actions::SelectAll};
@@ -25,6 +27,7 @@ use terminal_view::TerminalView;
 use ui::prelude::*;
 use util::ResultExt;
 use workspace::{Toast, Workspace, notifications::NotificationId};
+use zed_llm_client::CompletionIntent;
 
 pub fn init(
     fs: Arc<dyn Fs>,
@@ -105,7 +108,7 @@ impl TerminalInlineAssistant {
         });
         let prompt_editor_render = prompt_editor.clone();
         let block = terminal_view::BlockProperties {
-            height: 2,
+            height: 4,
             render: Box::new(move |_| prompt_editor_render.clone().into_any_element()),
         };
         terminal_view.update(cx, |terminal_view, cx| {
@@ -166,9 +169,6 @@ impl TerminalInlineAssistant {
             PromptEditorEvent::CancelRequested => {
                 self.finish_assist(assist_id, true, false, window, cx);
             }
-            PromptEditorEvent::DismissRequested => {
-                self.dismiss_assist(assist_id, window, cx);
-            }
             PromptEditorEvent::Resized { height_in_lines } => {
                 self.insert_prompt_editor_into_terminal(assist_id, *height_in_lines, window, cx);
             }
@@ -191,7 +191,7 @@ impl TerminalInlineAssistant {
         };
 
         self.prompt_history.retain(|prompt| *prompt != user_prompt);
-        self.prompt_history.push_back(user_prompt.clone());
+        self.prompt_history.push_back(user_prompt);
         if self.prompt_history.len() > PROMPT_HISTORY_MAX_LEN {
             self.prompt_history.pop_front();
         }
@@ -201,7 +201,7 @@ impl TerminalInlineAssistant {
             .update(cx, |terminal, cx| {
                 terminal
                     .terminal()
-                    .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.to_string()));
+                    .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.as_bytes()));
             })
             .log_err();
 
@@ -271,7 +271,7 @@ impl TerminalInlineAssistant {
             .inline_assistant_model()
             .context("No inline assistant model")?;
 
-        let temperature = AssistantSettings::temperature_for_model(&model, cx);
+        let temperature = AgentSettings::temperature_for_model(&model, cx);
 
         Ok(cx.background_spawn(async move {
             let mut request_message = LanguageModelRequestMessage {
@@ -291,6 +291,7 @@ impl TerminalInlineAssistant {
                 thread_id: None,
                 prompt_id: None,
                 mode: None,
+                intent: Some(CompletionIntent::TerminalInlineAssist),
                 messages: vec![request_message],
                 tools: Vec::new(),
                 tool_choice: None,

crates/assistant_context_editor/src/context_editor.rs → crates/agent_ui/src/text_thread_editor.rs 🔗

@@ -1,5 +1,11 @@
+use crate::{
+    burn_mode_tooltip::BurnModeTooltip,
+    language_model_selector::{
+        LanguageModelSelector, ToggleModelSelector, language_model_selector,
+    },
+};
+use agent_settings::{AgentSettings, CompletionMode};
 use anyhow::Result;
-use assistant_settings::AssistantSettings;
 use assistant_slash_command::{SlashCommand, SlashCommandOutputSection, SlashCommandWorkingSet};
 use assistant_slash_commands::{
     DefaultSlashCommand, DocsSlashCommand, DocsSlashCommandArgs, FileSlashCommand,
@@ -15,17 +21,16 @@ use editor::{
         BlockPlacement, BlockProperties, BlockStyle, Crease, CreaseMetadata, CustomBlockId, FoldId,
         RenderBlock, ToDisplayPoint,
     },
-    scroll::Autoscroll,
 };
 use editor::{FoldPlaceholder, display_map::CreaseId};
 use fs::Fs;
 use futures::FutureExt;
 use gpui::{
-    Animation, AnimationExt, AnyElement, AnyView, App, ClipboardEntry, ClipboardItem, Empty,
-    Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, InteractiveElement,
+    Action, Animation, AnimationExt, AnyElement, AnyView, App, ClipboardEntry, ClipboardItem,
+    Empty, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, InteractiveElement,
     IntoElement, ParentElement, Pixels, Render, RenderImage, SharedString, Size,
     StatefulInteractiveElement, Styled, Subscription, Task, Transformation, WeakEntity, actions,
-    div, img, impl_internal_actions, percentage, point, prelude::*, pulsating_between, size,
+    div, img, percentage, point, prelude::*, pulsating_between, size,
 };
 use indexed_docs::IndexedDocsStore;
 use language::{
@@ -33,14 +38,11 @@ use language::{
     language_settings::{SoftWrap, all_language_settings},
 };
 use language_model::{
-    LanguageModelImage, LanguageModelProvider, LanguageModelProviderTosView, LanguageModelRegistry,
+    ConfigurationError, LanguageModelImage, LanguageModelProviderTosView, LanguageModelRegistry,
     Role,
 };
-use language_model_selector::{
-    LanguageModelSelector, LanguageModelSelectorPopoverMenu, ToggleModelSelector,
-};
 use multi_buffer::MultiBufferRow;
-use picker::Picker;
+use picker::{Picker, popover_menu::PickerPopoverMenu};
 use project::{Project, Worktree};
 use project::{ProjectPath, lsp_store::LocalLspAdapterDelegate};
 use rope::Point;
@@ -51,6 +53,7 @@ use std::{
     cmp,
     ops::Range,
     path::{Path, PathBuf},
+    rc::Rc,
     sync::Arc,
     time::Duration,
 };
@@ -65,21 +68,18 @@ use workspace::{
     searchable::{Direction, SearchableItemHandle},
 };
 use workspace::{
-    Save, Toast, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
+    Save, Toast, Workspace,
     item::{self, FollowableItem, Item, ItemHandle},
     notifications::NotificationId,
     pane,
     searchable::{SearchEvent, SearchableItem},
 };
 
-use crate::{
+use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker};
+use assistant_context::{
     AssistantContext, CacheStatus, Content, ContextEvent, ContextId, InvokedSlashCommandId,
     InvokedSlashCommandStatus, Message, MessageId, MessageMetadata, MessageStatus,
-    ParsedSlashCommand, PendingSlashCommandStatus,
-};
-use crate::{
-    ThoughtProcessOutputSection, slash_command::SlashCommandCompletionProvider,
-    slash_command_picker,
+    ParsedSlashCommand, PendingSlashCommandStatus, ThoughtProcessOutputSection,
 };
 
 actions!(
@@ -95,14 +95,13 @@ actions!(
     ]
 );
 
-#[derive(PartialEq, Clone)]
+#[derive(PartialEq, Clone, Action)]
+#[action(namespace = assistant, no_json, no_register)]
 pub enum InsertDraggedFiles {
     ProjectPaths(Vec<ProjectPath>),
     ExternalFiles(Vec<PathBuf>),
 }
 
-impl_internal_actions!(assistant, [InsertDraggedFiles]);
-
 #[derive(Copy, Clone, Debug, PartialEq)]
 struct ScrollPosition {
     offset_before_cursor: gpui::Point<f32>,
@@ -114,7 +113,6 @@ type MessageHeader = MessageMetadata;
 #[derive(Clone)]
 enum AssistError {
     PaymentRequired,
-    MaxMonthlySpendReached,
     Message(SharedString),
 }
 
@@ -129,7 +127,7 @@ pub trait AgentPanelDelegate {
         workspace: &mut Workspace,
         window: &mut Window,
         cx: &mut Context<Workspace>,
-    ) -> Option<Entity<ContextEditor>>;
+    ) -> Option<Entity<TextThreadEditor>>;
 
     fn open_saved_context(
         &self,
@@ -145,7 +143,7 @@ pub trait AgentPanelDelegate {
         context_id: ContextId,
         window: &mut Window,
         cx: &mut Context<Workspace>,
-    ) -> Task<Result<Entity<ContextEditor>>>;
+    ) -> Task<Result<Entity<TextThreadEditor>>>;
 
     fn quote_selection(
         &self,
@@ -174,7 +172,7 @@ struct GlobalAssistantPanelDelegate(Arc<dyn AgentPanelDelegate>);
 
 impl Global for GlobalAssistantPanelDelegate {}
 
-pub struct ContextEditor {
+pub struct TextThreadEditor {
     context: Entity<AssistantContext>,
     fs: Arc<dyn Fs>,
     slash_commands: Arc<SlashCommandWorkingSet>,
@@ -204,10 +202,24 @@ pub struct ContextEditor {
     language_model_selector_menu_handle: PopoverMenuHandle<LanguageModelSelector>,
 }
 
-pub const DEFAULT_TAB_TITLE: &str = "New Chat";
 const MAX_TAB_TITLE_LEN: usize = 16;
 
-impl ContextEditor {
+impl TextThreadEditor {
+    pub fn init(cx: &mut App) {
+        workspace::FollowableViewRegistry::register::<TextThreadEditor>(cx);
+
+        cx.observe_new(
+            |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
+                workspace
+                    .register_action(TextThreadEditor::quote_selection)
+                    .register_action(TextThreadEditor::insert_selection)
+                    .register_action(TextThreadEditor::copy_code)
+                    .register_action(TextThreadEditor::handle_insert_dragged_files);
+            },
+        )
+        .detach();
+    }
+
     pub fn for_context(
         context: Entity<AssistantContext>,
         fs: Arc<dyn Fs>,
@@ -235,7 +247,7 @@ impl ContextEditor {
             editor.set_show_breakpoints(false, cx);
             editor.set_show_wrap_guides(false, cx);
             editor.set_show_indent_guides(false, cx);
-            editor.set_completion_provider(Some(Box::new(completion_provider)));
+            editor.set_completion_provider(Some(Rc::new(completion_provider)));
             editor.set_menu_inline_completions_policy(MenuInlineCompletionsPolicy::Never);
             editor.set_collaboration_hub(Box::new(project.clone()));
 
@@ -280,10 +292,10 @@ impl ContextEditor {
             slash_menu_handle: Default::default(),
             dragged_file_worktrees: Vec::new(),
             language_model_selector: cx.new(|cx| {
-                LanguageModelSelector::new(
+                language_model_selector(
                     |cx| LanguageModelRegistry::read_global(cx).default_model(),
                     move |model, cx| {
-                        update_settings_file::<AssistantSettings>(
+                        update_settings_file::<AgentSettings>(
                             fs.clone(),
                             cx,
                             move |settings, _| settings.set_model(model.clone()),
@@ -376,7 +388,7 @@ impl ContextEditor {
                 cursor..cursor
             };
             self.editor.update(cx, |editor, cx| {
-                editor.change_selections(Some(Autoscroll::fit()), window, cx, |selections| {
+                editor.change_selections(Default::default(), window, cx, |selections| {
                     selections.select_ranges([new_selection])
                 });
             });
@@ -436,8 +448,7 @@ impl ContextEditor {
         if let Some(command) = self.slash_commands.command(name, cx) {
             self.editor.update(cx, |editor, cx| {
                 editor.transact(window, cx, |editor, window, cx| {
-                    editor
-                        .change_selections(Some(Autoscroll::fit()), window, cx, |s| s.try_cancel());
+                    editor.change_selections(Default::default(), window, cx, |s| s.try_cancel());
                     let snapshot = editor.buffer().read(cx).snapshot(cx);
                     let newest_cursor = editor.selections.newest::<Point>(cx).head();
                     if newest_cursor.column > 0
@@ -577,6 +588,7 @@ impl ContextEditor {
                 });
             }
             ContextEvent::SummaryGenerated => {}
+            ContextEvent::PathChanged { .. } => {}
             ContextEvent::StartedThoughtProcess(range) => {
                 let creases = self.insert_thought_process_output_sections(
                     [(
@@ -732,9 +744,6 @@ impl ContextEditor {
             ContextEvent::ShowPaymentRequiredError => {
                 self.last_error = Some(AssistError::PaymentRequired);
             }
-            ContextEvent::ShowMaxMonthlySpendReachedError => {
-                self.last_error = Some(AssistError::MaxMonthlySpendReached);
-            }
         }
     }
 
@@ -1279,7 +1288,7 @@ impl ContextEditor {
     /// Returns either the selected text, or the content of the Markdown code
     /// block surrounding the cursor.
     fn get_selection_or_code_block(
-        context_editor_view: &Entity<ContextEditor>,
+        context_editor_view: &Entity<TextThreadEditor>,
         cx: &mut Context<Workspace>,
     ) -> Option<(String, bool)> {
         const CODE_FENCE_DELIMITER: &'static str = "```";
@@ -1572,7 +1581,7 @@ impl ContextEditor {
 
             self.editor.update(cx, |editor, cx| {
                 editor.transact(window, cx, |this, window, cx| {
-                    this.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+                    this.change_selections(Default::default(), window, cx, |s| {
                         s.select(selections);
                     });
                     this.insert("", window, cx);
@@ -1594,7 +1603,7 @@ impl ContextEditor {
         &mut self,
         cx: &mut Context<Self>,
     ) -> (String, CopyMetadata, Vec<text::Selection<usize>>) {
-        let (selection, creases) = self.editor.update(cx, |editor, cx| {
+        let (mut selection, creases) = self.editor.update(cx, |editor, cx| {
             let mut selection = editor.selections.newest_adjusted(cx);
             let snapshot = editor.buffer().read(cx).snapshot(cx);
 
@@ -1646,23 +1655,35 @@ impl ContextEditor {
         let context = self.context.read(cx);
 
         let mut text = String::new();
-        for message in context.messages(cx) {
-            if message.offset_range.start >= selection.range().end {
-                break;
-            } else if message.offset_range.end >= selection.range().start {
-                let range = cmp::max(message.offset_range.start, selection.range().start)
-                    ..cmp::min(message.offset_range.end, selection.range().end);
-                if !range.is_empty() {
-                    for chunk in context.buffer().read(cx).text_for_range(range) {
-                        text.push_str(chunk);
-                    }
-                    if message.offset_range.end < selection.range().end {
-                        text.push('\n');
+
+        // If selection is empty, we want to copy the entire line
+        if selection.range().is_empty() {
+            let snapshot = context.buffer().read(cx).snapshot();
+            let point = snapshot.offset_to_point(selection.range().start);
+            selection.start = snapshot.point_to_offset(Point::new(point.row, 0));
+            selection.end = snapshot
+                .point_to_offset(cmp::min(Point::new(point.row + 1, 0), snapshot.max_point()));
+            for chunk in context.buffer().read(cx).text_for_range(selection.range()) {
+                text.push_str(chunk);
+            }
+        } else {
+            for message in context.messages(cx) {
+                if message.offset_range.start >= selection.range().end {
+                    break;
+                } else if message.offset_range.end >= selection.range().start {
+                    let range = cmp::max(message.offset_range.start, selection.range().start)
+                        ..cmp::min(message.offset_range.end, selection.range().end);
+                    if !range.is_empty() {
+                        for chunk in context.buffer().read(cx).text_for_range(range) {
+                            text.push_str(chunk);
+                        }
+                        if message.offset_range.end < selection.range().end {
+                            text.push('\n');
+                        }
                     }
                 }
             }
         }
-
         (text, CopyMetadata { creases }, vec![selection])
     }
 
@@ -1874,6 +1895,8 @@ impl ContextEditor {
         // value to not show the nudge.
         let nudge = Some(false);
 
+        let model_registry = LanguageModelRegistry::read_global(cx);
+
         if nudge.map_or(false, |value| value) {
             Some(
                 h_flex()
@@ -1895,7 +1918,7 @@ impl ContextEditor {
                             .on_click(cx.listener(|this, _event, _window, cx| {
                                 let client = this
                                     .workspace
-                                    .update(cx, |workspace, _| workspace.client().clone())
+                                    .read_with(cx, |workspace, _| workspace.client().clone())
                                     .log_err();
 
                                 if let Some(client) = client {
@@ -1922,14 +1945,9 @@ impl ContextEditor {
                     )
                     .into_any_element(),
             )
-        } else if let Some(configuration_error) = configuration_error(cx) {
-            let label = match configuration_error {
-                ConfigurationError::NoProvider => "No LLM provider selected.",
-                ConfigurationError::ProviderNotAuthenticated => "LLM provider is not configured.",
-                ConfigurationError::ProviderPendingTermsAcceptance(_) => {
-                    "LLM provider requires accepting the Terms of Service."
-                }
-            };
+        } else if let Some(configuration_error) =
+            model_registry.configuration_error(model_registry.default_model(), cx)
+        {
             Some(
                 h_flex()
                     .px_3()
@@ -1946,7 +1964,7 @@ impl ContextEditor {
                                     .size(IconSize::Small)
                                     .color(Color::Warning),
                             )
-                            .child(Label::new(label)),
+                            .child(Label::new(configuration_error.to_string())),
                     )
                     .child(
                         Button::new("open-configuration", "Configure Providers")
@@ -2000,17 +2018,17 @@ impl ContextEditor {
             None => (ButtonStyle::Filled, None),
         };
 
-        ButtonLike::new("send_button")
+        Button::new("send_button", "Send")
+            .label_size(LabelSize::Small)
             .disabled(self.sending_disabled(cx))
             .style(style)
             .when_some(tooltip, |button, tooltip| {
                 button.tooltip(move |_, _| tooltip.clone())
             })
             .layer(ElevationIndex::ModalSurface)
-            .child(Label::new("Send"))
-            .children(
+            .key_binding(
                 KeyBinding::for_action_in(&Assist, &focus_handle, window, cx)
-                    .map(|binding| binding.into_any_element()),
+                    .map(|kb| kb.size(rems_from_px(12.))),
             )
             .on_click(move |_event, window, cx| {
                 focus_handle.dispatch_action(&Assist, window, cx);
@@ -2020,15 +2038,20 @@ impl ContextEditor {
     /// Whether or not we should allow messages to be sent.
     /// Will return false if the selected provided has a configuration error or
     /// if the user has not accepted the terms of service for this provider.
-    fn sending_disabled(&self, cx: &mut Context<'_, ContextEditor>) -> bool {
-        let model = LanguageModelRegistry::read_global(cx).default_model();
+    fn sending_disabled(&self, cx: &mut Context<'_, TextThreadEditor>) -> bool {
+        let model_registry = LanguageModelRegistry::read_global(cx);
+        let Some(configuration_error) =
+            model_registry.configuration_error(model_registry.default_model(), cx)
+        else {
+            return false;
+        };
 
-        let has_configuration_error = configuration_error(cx).is_some();
-        let needs_to_accept_terms = self.show_accept_terms
-            && model
-                .as_ref()
-                .map_or(false, |model| model.provider.must_accept_terms(cx));
-        has_configuration_error || needs_to_accept_terms
+        match configuration_error {
+            ConfigurationError::NoProvider
+            | ConfigurationError::ModelNotFound
+            | ConfigurationError::ProviderNotAuthenticated(_) => true,
+            ConfigurationError::ProviderPendingTermsAcceptance(_) => self.show_accept_terms,
+        }
     }
 
     fn render_inject_context_menu(&self, cx: &mut Context<Self>) -> impl IntoElement {
@@ -2050,27 +2073,85 @@ impl ContextEditor {
         )
     }
 
-    fn render_language_model_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render_burn_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
+        let context = self.context().read(cx);
+        let active_model = LanguageModelRegistry::read_global(cx)
+            .default_model()
+            .map(|default| default.model)?;
+        if !active_model.supports_burn_mode() {
+            return None;
+        }
+
+        let active_completion_mode = context.completion_mode();
+        let burn_mode_enabled = active_completion_mode == CompletionMode::Burn;
+        let icon = if burn_mode_enabled {
+            IconName::ZedBurnModeOn
+        } else {
+            IconName::ZedBurnMode
+        };
+
+        Some(
+            IconButton::new("burn-mode", icon)
+                .icon_size(IconSize::Small)
+                .icon_color(Color::Muted)
+                .toggle_state(burn_mode_enabled)
+                .selected_icon_color(Color::Error)
+                .on_click(cx.listener(move |this, _event, _window, cx| {
+                    this.context().update(cx, |context, _cx| {
+                        context.set_completion_mode(match active_completion_mode {
+                            CompletionMode::Burn => CompletionMode::Normal,
+                            CompletionMode::Normal => CompletionMode::Burn,
+                        });
+                    });
+                }))
+                .tooltip(move |_window, cx| {
+                    cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled))
+                        .into()
+                })
+                .into_any_element(),
+        )
+    }
+
+    fn render_language_model_selector(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
         let active_model = LanguageModelRegistry::read_global(cx)
             .default_model()
             .map(|default| default.model);
-        let focus_handle = self.editor().focus_handle(cx).clone();
         let model_name = match active_model {
             Some(model) => model.name().0,
             None => SharedString::from("No model selected"),
         };
 
-        LanguageModelSelectorPopoverMenu::new(
+        let active_provider = LanguageModelRegistry::read_global(cx)
+            .default_model()
+            .map(|default| default.provider);
+        let provider_icon = match active_provider {
+            Some(provider) => provider.icon(),
+            None => IconName::Ai,
+        };
+
+        let focus_handle = self.editor().focus_handle(cx).clone();
+
+        PickerPopoverMenu::new(
             self.language_model_selector.clone(),
             ButtonLike::new("active-model")
                 .style(ButtonStyle::Subtle)
                 .child(
                     h_flex()
                         .gap_0p5()
+                        .child(
+                            Icon::new(provider_icon)
+                                .color(Color::Muted)
+                                .size(IconSize::XSmall),
+                        )
                         .child(
                             Label::new(model_name)
+                                .color(Color::Muted)
                                 .size(LabelSize::Small)
-                                .color(Color::Muted),
+                                .ml_0p5(),
                         )
                         .child(
                             Icon::new(IconName::ChevronDown)
@@ -2088,8 +2169,10 @@ impl ContextEditor {
                 )
             },
             gpui::Corner::BottomLeft,
+            cx,
         )
         .with_handle(self.language_model_selector_menu_handle.clone())
+        .render(window, cx)
     }
 
     fn render_last_error(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
@@ -2107,9 +2190,6 @@ impl ContextEditor {
                 .occlude()
                 .child(match last_error {
                     AssistError::PaymentRequired => self.render_payment_required_error(cx),
-                    AssistError::MaxMonthlySpendReached => {
-                        self.render_max_monthly_spend_reached_error(cx)
-                    }
                     AssistError::Message(error_message) => {
                         self.render_assist_error(error_message, cx)
                     }
@@ -2158,48 +2238,6 @@ impl ContextEditor {
             .into_any()
     }
 
-    fn render_max_monthly_spend_reached_error(&self, cx: &mut Context<Self>) -> AnyElement {
-        const ERROR_MESSAGE: &str = "You have reached your maximum monthly spend. Increase your spend limit to continue using Zed LLMs.";
-
-        v_flex()
-            .gap_0p5()
-            .child(
-                h_flex()
-                    .gap_1p5()
-                    .items_center()
-                    .child(Icon::new(IconName::XCircle).color(Color::Error))
-                    .child(Label::new("Max Monthly Spend Reached").weight(FontWeight::MEDIUM)),
-            )
-            .child(
-                div()
-                    .id("error-message")
-                    .max_h_24()
-                    .overflow_y_scroll()
-                    .child(Label::new(ERROR_MESSAGE)),
-            )
-            .child(
-                h_flex()
-                    .justify_end()
-                    .mt_1()
-                    .child(
-                        Button::new("subscribe", "Update Monthly Spend Limit").on_click(
-                            cx.listener(|this, _, _window, cx| {
-                                this.last_error = None;
-                                cx.open_url(&zed_urls::account_url(cx));
-                                cx.notify();
-                            }),
-                        ),
-                    )
-                    .child(Button::new("dismiss", "Dismiss").on_click(cx.listener(
-                        |this, _, _window, cx| {
-                            this.last_error = None;
-                            cx.notify();
-                        },
-                    ))),
-            )
-            .into_any()
-    }
-
     fn render_assist_error(
         &self,
         error_message: &SharedString,
@@ -2532,14 +2570,15 @@ struct SelectedCreaseMetadata {
     crease: CreaseMetadata,
 }
 
-impl EventEmitter<EditorEvent> for ContextEditor {}
-impl EventEmitter<SearchEvent> for ContextEditor {}
+impl EventEmitter<EditorEvent> for TextThreadEditor {}
+impl EventEmitter<SearchEvent> for TextThreadEditor {}
 
-impl Render for ContextEditor {
+impl Render for TextThreadEditor {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let provider = LanguageModelRegistry::read_global(cx)
             .default_model()
             .map(|default| default.provider);
+
         let accept_terms = if self.show_accept_terms {
             provider.as_ref().and_then(|provider| {
                 provider.render_accept_terms(LanguageModelProviderTosView::PromptEditorPopup, cx)
@@ -2549,17 +2588,19 @@ impl Render for ContextEditor {
         };
 
         let language_model_selector = self.language_model_selector_menu_handle.clone();
+        let burn_mode_toggle = self.render_burn_mode_toggle(cx);
+
         v_flex()
             .key_context("ContextEditor")
-            .capture_action(cx.listener(ContextEditor::cancel))
-            .capture_action(cx.listener(ContextEditor::save))
-            .capture_action(cx.listener(ContextEditor::copy))
-            .capture_action(cx.listener(ContextEditor::cut))
-            .capture_action(cx.listener(ContextEditor::paste))
-            .capture_action(cx.listener(ContextEditor::cycle_message_role))
-            .capture_action(cx.listener(ContextEditor::confirm_command))
-            .on_action(cx.listener(ContextEditor::assist))
-            .on_action(cx.listener(ContextEditor::split))
+            .capture_action(cx.listener(TextThreadEditor::cancel))
+            .capture_action(cx.listener(TextThreadEditor::save))
+            .capture_action(cx.listener(TextThreadEditor::copy))
+            .capture_action(cx.listener(TextThreadEditor::cut))
+            .capture_action(cx.listener(TextThreadEditor::paste))
+            .capture_action(cx.listener(TextThreadEditor::cycle_message_role))
+            .capture_action(cx.listener(TextThreadEditor::confirm_command))
+            .on_action(cx.listener(TextThreadEditor::assist))
+            .on_action(cx.listener(TextThreadEditor::split))
             .on_action(move |_: &ToggleModelSelector, window, cx| {
                 language_model_selector.toggle(window, cx);
             })
@@ -2588,42 +2629,39 @@ impl Render for ContextEditor {
             })
             .children(self.render_last_error(cx))
             .child(
-                h_flex().w_full().relative().child(
-                    h_flex()
-                        .p_2()
-                        .w_full()
-                        .border_t_1()
-                        .border_color(cx.theme().colors().border_variant)
-                        .bg(cx.theme().colors().editor_background)
-                        .child(
-                            h_flex()
-                                .gap_1()
-                                .child(self.render_inject_context_menu(cx))
-                                .child(ui::Divider::vertical())
-                                .child(
-                                    div()
-                                        .pl_0p5()
-                                        .child(self.render_language_model_selector(cx)),
-                                ),
-                        )
-                        .child(
-                            h_flex()
-                                .w_full()
-                                .justify_end()
-                                .child(self.render_send_button(window, cx)),
-                        ),
-                ),
+                h_flex()
+                    .relative()
+                    .py_2()
+                    .pl_1p5()
+                    .pr_2()
+                    .w_full()
+                    .justify_between()
+                    .border_t_1()
+                    .border_color(cx.theme().colors().border_variant)
+                    .bg(cx.theme().colors().editor_background)
+                    .child(
+                        h_flex()
+                            .gap_0p5()
+                            .child(self.render_inject_context_menu(cx))
+                            .when_some(burn_mode_toggle, |this, element| this.child(element)),
+                    )
+                    .child(
+                        h_flex()
+                            .gap_1()
+                            .child(self.render_language_model_selector(window, cx))
+                            .child(self.render_send_button(window, cx)),
+                    ),
             )
     }
 }
 
-impl Focusable for ContextEditor {
+impl Focusable for TextThreadEditor {
     fn focus_handle(&self, cx: &App) -> FocusHandle {
         self.editor.focus_handle(cx)
     }
 }
 
-impl Item for ContextEditor {
+impl Item for TextThreadEditor {
     type Event = editor::EditorEvent;
 
     fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
@@ -2696,7 +2734,7 @@ impl Item for ContextEditor {
     }
 }
 
-impl SearchableItem for ContextEditor {
+impl SearchableItem for TextThreadEditor {
     type Match = <Editor as SearchableItem>::Match;
 
     fn clear_matches(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -2777,7 +2815,7 @@ impl SearchableItem for ContextEditor {
     }
 }
 
-impl FollowableItem for ContextEditor {
+impl FollowableItem for TextThreadEditor {
     fn remote_id(&self) -> Option<workspace::ViewId> {
         self.remote_id
     }
@@ -2899,22 +2937,8 @@ impl FollowableItem for ContextEditor {
     }
 }
 
-pub struct ContextEditorToolbarItem {
-    active_context_editor: Option<WeakEntity<ContextEditor>>,
-    model_summary_editor: Entity<Editor>,
-}
-
-impl ContextEditorToolbarItem {
-    pub fn new(model_summary_editor: Entity<Editor>) -> Self {
-        Self {
-            active_context_editor: None,
-            model_summary_editor,
-        }
-    }
-}
-
 pub fn render_remaining_tokens(
-    context_editor: &Entity<ContextEditor>,
+    context_editor: &Entity<TextThreadEditor>,
     cx: &App,
 ) -> Option<impl IntoElement + use<>> {
     let context = &context_editor.read(cx).context;
@@ -2965,98 +2989,6 @@ pub fn render_remaining_tokens(
     )
 }
 
-impl Render for ContextEditorToolbarItem {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let left_side = h_flex()
-            .group("chat-title-group")
-            .gap_1()
-            .items_center()
-            .flex_grow()
-            .child(
-                div()
-                    .w_full()
-                    .when(self.active_context_editor.is_some(), |left_side| {
-                        left_side.child(self.model_summary_editor.clone())
-                    }),
-            )
-            .child(
-                div().visible_on_hover("chat-title-group").child(
-                    IconButton::new("regenerate-context", IconName::RefreshTitle)
-                        .shape(ui::IconButtonShape::Square)
-                        .tooltip(Tooltip::text("Regenerate Title"))
-                        .on_click(cx.listener(move |_, _, _window, cx| {
-                            cx.emit(ContextEditorToolbarItemEvent::RegenerateSummary)
-                        })),
-                ),
-            );
-
-        let right_side = h_flex()
-            .gap_2()
-            // TODO display this in a nicer way, once we have a design for it.
-            // .children({
-            //     let project = self
-            //         .workspace
-            //         .upgrade()
-            //         .map(|workspace| workspace.read(cx).project().downgrade());
-            //
-            //     let scan_items_remaining = cx.update_global(|db: &mut SemanticDb, cx| {
-            //         project.and_then(|project| db.remaining_summaries(&project, cx))
-            //     });
-            //     scan_items_remaining
-            //         .map(|remaining_items| format!("Files to scan: {}", remaining_items))
-            // })
-            .children(
-                self.active_context_editor
-                    .as_ref()
-                    .and_then(|editor| editor.upgrade())
-                    .and_then(|editor| render_remaining_tokens(&editor, cx)),
-            );
-
-        h_flex()
-            .px_0p5()
-            .size_full()
-            .gap_2()
-            .justify_between()
-            .child(left_side)
-            .child(right_side)
-    }
-}
-
-impl ToolbarItemView for ContextEditorToolbarItem {
-    fn set_active_pane_item(
-        &mut self,
-        active_pane_item: Option<&dyn ItemHandle>,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> ToolbarItemLocation {
-        self.active_context_editor = active_pane_item
-            .and_then(|item| item.act_as::<ContextEditor>(cx))
-            .map(|editor| editor.downgrade());
-        cx.notify();
-        if self.active_context_editor.is_none() {
-            ToolbarItemLocation::Hidden
-        } else {
-            ToolbarItemLocation::PrimaryRight
-        }
-    }
-
-    fn pane_focus_update(
-        &mut self,
-        _pane_focused: bool,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        cx.notify();
-    }
-}
-
-impl EventEmitter<ToolbarItemEvent> for ContextEditorToolbarItem {}
-
-pub enum ContextEditorToolbarItemEvent {
-    RegenerateSummary,
-}
-impl EventEmitter<ContextEditorToolbarItemEvent> for ContextEditorToolbarItem {}
-
 enum PendingSlashCommand {}
 
 fn invoked_slash_command_fold_placeholder(
@@ -3082,7 +3014,7 @@ fn invoked_slash_command_fold_placeholder(
                 .gap_2()
                 .bg(cx.theme().colors().surface_background)
                 .rounded_sm()
-                .child(Label::new(format!("/{}", command.name.clone())))
+                .child(Label::new(format!("/{}", command.name)))
                 .map(|parent| match &command.status {
                     InvokedSlashCommandStatus::Running(_) => {
                         parent.child(Icon::new(IconName::ArrowCircle).with_animation(
@@ -3106,12 +3038,12 @@ fn invoked_slash_command_fold_placeholder(
 
 enum TokenState {
     NoTokensLeft {
-        max_token_count: usize,
-        token_count: usize,
+        max_token_count: u64,
+        token_count: u64,
     },
     HasMoreTokens {
-        max_token_count: usize,
-        token_count: usize,
+        max_token_count: u64,
+        token_count: u64,
         over_warn_threshold: bool,
     },
 }
@@ -3124,9 +3056,7 @@ fn token_state(context: &Entity<AssistantContext>, cx: &App) -> Option<TokenStat
         .model;
     let token_count = context.read(cx).token_count()?;
     let max_token_count = model.max_token_count();
-
-    let remaining_tokens = max_token_count as isize - token_count as isize;
-    let token_state = if remaining_tokens <= 0 {
+    let token_state = if max_token_count.saturating_sub(token_count) == 0 {
         TokenState::NoTokensLeft {
             max_token_count,
             token_count,
@@ -3167,34 +3097,7 @@ fn size_for_image(data: &RenderImage, max_size: Size<Pixels>) -> Size<Pixels> {
     }
 }
 
-pub enum ConfigurationError {
-    NoProvider,
-    ProviderNotAuthenticated,
-    ProviderPendingTermsAcceptance(Arc<dyn LanguageModelProvider>),
-}
-
-fn configuration_error(cx: &App) -> Option<ConfigurationError> {
-    let model = LanguageModelRegistry::read_global(cx).default_model();
-    let is_authenticated = model
-        .as_ref()
-        .map_or(false, |model| model.provider.is_authenticated(cx));
-
-    if model.is_some() && is_authenticated {
-        return None;
-    }
-
-    if model.is_none() {
-        return Some(ConfigurationError::NoProvider);
-    }
-
-    if !is_authenticated {
-        return Some(ConfigurationError::ProviderNotAuthenticated);
-    }
-
-    None
-}
-
-pub fn humanize_token_count(count: usize) -> String {
+pub fn humanize_token_count(count: u64) -> String {
     match count {
         0..=999 => count.to_string(),
         1000..=9999 => {
@@ -3251,9 +3154,96 @@ pub fn make_lsp_adapter_delegate(
 #[cfg(test)]
 mod tests {
     use super::*;
-    use gpui::App;
-    use language::Buffer;
+    use editor::SelectionEffects;
+    use fs::FakeFs;
+    use gpui::{App, TestAppContext, VisualTestContext};
+    use indoc::indoc;
+    use language::{Buffer, LanguageRegistry};
+    use pretty_assertions::assert_eq;
+    use prompt_store::PromptBuilder;
+    use text::OffsetRangeExt;
     use unindent::Unindent;
+    use util::path;
+
+    #[gpui::test]
+    async fn test_copy_paste_whole_message(cx: &mut TestAppContext) {
+        let (context, context_editor, mut cx) = setup_context_editor_text(vec![
+            (Role::User, "What is the Zed editor?"),
+            (
+                Role::Assistant,
+                "Zed is a modern, high-performance code editor designed from the ground up for speed and collaboration.",
+            ),
+            (Role::User, ""),
+        ],cx).await;
+
+        // Select & Copy whole user message
+        assert_copy_paste_context_editor(
+            &context_editor,
+            message_range(&context, 0, &mut cx),
+            indoc! {"
+                What is the Zed editor?
+                Zed is a modern, high-performance code editor designed from the ground up for speed and collaboration.
+                What is the Zed editor?
+            "},
+            &mut cx,
+        );
+
+        // Select & Copy whole assistant message
+        assert_copy_paste_context_editor(
+            &context_editor,
+            message_range(&context, 1, &mut cx),
+            indoc! {"
+                What is the Zed editor?
+                Zed is a modern, high-performance code editor designed from the ground up for speed and collaboration.
+                What is the Zed editor?
+                Zed is a modern, high-performance code editor designed from the ground up for speed and collaboration.
+            "},
+            &mut cx,
+        );
+    }
+
+    #[gpui::test]
+    async fn test_copy_paste_no_selection(cx: &mut TestAppContext) {
+        let (context, context_editor, mut cx) = setup_context_editor_text(
+            vec![
+                (Role::User, "user1"),
+                (Role::Assistant, "assistant1"),
+                (Role::Assistant, "assistant2"),
+                (Role::User, ""),
+            ],
+            cx,
+        )
+        .await;
+
+        // Copy and paste first assistant message
+        let message_2_range = message_range(&context, 1, &mut cx);
+        assert_copy_paste_context_editor(
+            &context_editor,
+            message_2_range.start..message_2_range.start,
+            indoc! {"
+                user1
+                assistant1
+                assistant2
+                assistant1
+            "},
+            &mut cx,
+        );
+
+        // Copy and cut second assistant message
+        let message_3_range = message_range(&context, 2, &mut cx);
+        assert_copy_paste_context_editor(
+            &context_editor,
+            message_3_range.start..message_3_range.start,
+            indoc! {"
+                user1
+                assistant1
+                assistant2
+                assistant1
+                assistant2
+            "},
+            &mut cx,
+        );
+    }
 
     #[gpui::test]
     fn test_find_code_blocks(cx: &mut App) {
@@ -3328,4 +3318,142 @@ mod tests {
             assert_eq!(range, expected, "unexpected result on row {:?}", row);
         }
     }
+
+    async fn setup_context_editor_text(
+        messages: Vec<(Role, &str)>,
+        cx: &mut TestAppContext,
+    ) -> (
+        Entity<AssistantContext>,
+        Entity<TextThreadEditor>,
+        VisualTestContext,
+    ) {
+        cx.update(init_test);
+
+        let fs = FakeFs::new(cx.executor());
+        let context = create_context_with_messages(messages, cx);
+
+        let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
+        let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let workspace = window.root(cx).unwrap();
+        let mut cx = VisualTestContext::from_window(*window, cx);
+
+        let context_editor = window
+            .update(&mut cx, |_, window, cx| {
+                cx.new(|cx| {
+                    let editor = TextThreadEditor::for_context(
+                        context.clone(),
+                        fs,
+                        workspace.downgrade(),
+                        project,
+                        None,
+                        window,
+                        cx,
+                    );
+                    editor
+                })
+            })
+            .unwrap();
+
+        (context, context_editor, cx)
+    }
+
+    fn message_range(
+        context: &Entity<AssistantContext>,
+        message_ix: usize,
+        cx: &mut TestAppContext,
+    ) -> Range<usize> {
+        context.update(cx, |context, cx| {
+            context
+                .messages(cx)
+                .nth(message_ix)
+                .unwrap()
+                .anchor_range
+                .to_offset(&context.buffer().read(cx).snapshot())
+        })
+    }
+
+    fn assert_copy_paste_context_editor<T: editor::ToOffset>(
+        context_editor: &Entity<TextThreadEditor>,
+        range: Range<T>,
+        expected_text: &str,
+        cx: &mut VisualTestContext,
+    ) {
+        context_editor.update_in(cx, |context_editor, window, cx| {
+            context_editor.editor.update(cx, |editor, cx| {
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                    s.select_ranges([range])
+                });
+            });
+
+            context_editor.copy(&Default::default(), window, cx);
+
+            context_editor.editor.update(cx, |editor, cx| {
+                editor.move_to_end(&Default::default(), window, cx);
+            });
+
+            context_editor.paste(&Default::default(), window, cx);
+
+            context_editor.editor.update(cx, |editor, cx| {
+                assert_eq!(editor.text(cx), expected_text);
+            });
+        });
+    }
+
+    fn create_context_with_messages(
+        mut messages: Vec<(Role, &str)>,
+        cx: &mut TestAppContext,
+    ) -> Entity<AssistantContext> {
+        let registry = Arc::new(LanguageRegistry::test(cx.executor()));
+        let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap());
+        cx.new(|cx| {
+            let mut context = AssistantContext::local(
+                registry,
+                None,
+                None,
+                prompt_builder.clone(),
+                Arc::new(SlashCommandWorkingSet::default()),
+                cx,
+            );
+            let mut message_1 = context.messages(cx).next().unwrap();
+            let (role, text) = messages.remove(0);
+
+            loop {
+                if role == message_1.role {
+                    context.buffer().update(cx, |buffer, cx| {
+                        buffer.edit([(message_1.offset_range, text)], None, cx);
+                    });
+                    break;
+                }
+                let mut ids = HashSet::default();
+                ids.insert(message_1.id);
+                context.cycle_message_roles(ids, cx);
+                message_1 = context.messages(cx).next().unwrap();
+            }
+
+            let mut last_message_id = message_1.id;
+            for (role, text) in messages {
+                context.insert_message_after(last_message_id, role, MessageStatus::Done, cx);
+                let message = context.messages(cx).last().unwrap();
+                last_message_id = message.id;
+                context.buffer().update(cx, |buffer, cx| {
+                    buffer.edit([(message.offset_range, text)], None, cx);
+                })
+            }
+
+            context
+        })
+    }
+
+    fn init_test(cx: &mut App) {
+        let settings_store = SettingsStore::test(cx);
+        prompt_store::init(cx);
+        LanguageModelRegistry::test(cx);
+        cx.set_global(settings_store);
+        language::init(cx);
+        agent_settings::init(cx);
+        Project::init_settings(cx);
+        theme::init(theme::LoadThemes::JustBase, cx);
+        workspace::init_settings(cx);
+        editor::init_settings(cx);
+    }
 }

crates/agent/src/thread_history.rs → crates/agent_ui/src/thread_history.rs 🔗

@@ -1,7 +1,5 @@
-use std::fmt::Display;
-use std::ops::Range;
-use std::sync::Arc;
-
+use crate::{AgentPanel, RemoveSelectedThread};
+use agent::history_store::{HistoryEntry, HistoryStore};
 use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
 use editor::{Editor, EditorEvent};
 use fuzzy::{StringMatch, StringMatchCandidate};
@@ -9,6 +7,7 @@ use gpui::{
     App, ClickEvent, Empty, Entity, FocusHandle, Focusable, ScrollStrategy, Stateful, Task,
     UniformListScrollHandle, WeakEntity, Window, uniform_list,
 };
+use std::{fmt::Display, ops::Range, sync::Arc};
 use time::{OffsetDateTime, UtcOffset};
 use ui::{
     HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState,
@@ -16,9 +15,6 @@ use ui::{
 };
 use util::ResultExt;
 
-use crate::history_store::{HistoryEntry, HistoryStore};
-use crate::{AgentPanel, RemoveSelectedThread};
-
 pub struct ThreadHistory {
     agent_panel: WeakEntity<AgentPanel>,
     history_store: Entity<HistoryStore>,
@@ -224,6 +220,7 @@ impl ThreadHistory {
                     &candidates,
                     &query,
                     false,
+                    true,
                     MAX_MATCHES,
                     &Default::default(),
                     executor,
@@ -260,10 +257,7 @@ impl ThreadHistory {
             }
         });
 
-        self.search_state = SearchState::Searching {
-            query: query.clone(),
-            _task: task,
-        };
+        self.search_state = SearchState::Searching { query, _task: task };
         cx.notify();
     }
 
@@ -597,10 +591,11 @@ impl Render for ThreadHistory {
                     view.pr_5()
                         .child(
                             uniform_list(
-                                cx.entity().clone(),
                                 "thread-history",
                                 self.list_item_count(),
-                                Self::list_items,
+                                cx.processor(|this, range: Range<usize>, window, cx| {
+                                    this.list_items(range, window, cx)
+                                }),
                             )
                             .p_1()
                             .track_scroll(self.scroll_handle.clone())
@@ -674,7 +669,7 @@ impl RenderOnce for HistoryEntryElement {
             ),
             HistoryEntry::Context(context) => (
                 context.path.to_string_lossy().to_string(),
-                context.title.clone().into(),
+                context.title.clone(),
                 context.mtime.timestamp(),
             ),
         };

crates/agent/src/tool_compatibility.rs → crates/agent_ui/src/tool_compatibility.rs 🔗

@@ -1,30 +1,31 @@
-use std::sync::Arc;
-
-use assistant_tool::{Tool, ToolSource, ToolWorkingSet, ToolWorkingSetEvent};
+use agent::{Thread, ThreadEvent};
+use assistant_tool::{Tool, ToolSource};
 use collections::HashMap;
 use gpui::{App, Context, Entity, IntoElement, Render, Subscription, Window};
 use language_model::{LanguageModel, LanguageModelToolSchemaFormat};
+use std::sync::Arc;
 use ui::prelude::*;
 
 pub struct IncompatibleToolsState {
     cache: HashMap<LanguageModelToolSchemaFormat, Vec<Arc<dyn Tool>>>,
-    tool_working_set: Entity<ToolWorkingSet>,
-    _tool_working_set_subscription: Subscription,
+    thread: Entity<Thread>,
+    _thread_subscription: Subscription,
 }
 
 impl IncompatibleToolsState {
-    pub fn new(tool_working_set: Entity<ToolWorkingSet>, cx: &mut Context<Self>) -> Self {
+    pub fn new(thread: Entity<Thread>, cx: &mut Context<Self>) -> Self {
         let _tool_working_set_subscription =
-            cx.subscribe(&tool_working_set, |this, _, event, _| match event {
-                ToolWorkingSetEvent::EnabledToolsChanged => {
+            cx.subscribe(&thread, |this, _, event, _| match event {
+                ThreadEvent::ProfileChanged => {
                     this.cache.clear();
                 }
+                _ => {}
             });
 
         Self {
             cache: HashMap::default(),
-            tool_working_set,
-            _tool_working_set_subscription,
+            thread,
+            _thread_subscription: _tool_working_set_subscription,
         }
     }
 
@@ -36,8 +37,9 @@ impl IncompatibleToolsState {
         self.cache
             .entry(model.tool_input_format())
             .or_insert_with(|| {
-                self.tool_working_set
+                self.thread
                     .read(cx)
+                    .profile()
                     .enabled_tools(cx)
                     .iter()
                     .filter(|tool| tool.input_schema(model.tool_input_format()).is_err())

crates/agent/src/ui.rs → crates/agent_ui/src/ui.rs 🔗

@@ -1,13 +1,13 @@
 mod agent_notification;
 mod animated_label;
+mod burn_mode_tooltip;
 mod context_pill;
-mod max_mode_tooltip;
 mod onboarding_modal;
 pub mod preview;
 mod upsell;
 
 pub use agent_notification::*;
 pub use animated_label::*;
+pub use burn_mode_tooltip::*;
 pub use context_pill::*;
-pub use max_mode_tooltip::*;
 pub use onboarding_modal::*;

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

@@ -0,0 +1,70 @@
+use crate::ToggleBurnMode;
+use gpui::{Context, FontWeight, IntoElement, Render, Window};
+use ui::{KeyBinding, prelude::*, tooltip_container};
+
+pub struct MaxModeTooltip {
+    selected: bool,
+}
+
+impl MaxModeTooltip {
+    pub fn new() -> Self {
+        Self { selected: false }
+    }
+
+    pub fn selected(mut self, selected: bool) -> Self {
+        self.selected = selected;
+        self
+    }
+}
+
+impl Render for MaxModeTooltip {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let (icon, color) = if self.selected {
+            (IconName::ZedBurnModeOn, Color::Error)
+        } else {
+            (IconName::ZedBurnMode, Color::Default)
+        };
+
+        let turned_on = h_flex()
+            .h_4()
+            .px_1()
+            .border_1()
+            .border_color(cx.theme().colors().border)
+            .bg(cx.theme().colors().text_accent.opacity(0.1))
+            .rounded_sm()
+            .child(
+                Label::new("ON")
+                    .size(LabelSize::XSmall)
+                    .weight(FontWeight::SEMIBOLD)
+                    .color(Color::Accent),
+            );
+
+        let title = h_flex()
+            .gap_1p5()
+            .child(Icon::new(icon).size(IconSize::Small).color(color))
+            .child(Label::new("Burn Mode"))
+            .when(self.selected, |title| title.child(turned_on));
+
+        let keybinding = KeyBinding::for_action(&ToggleBurnMode, window, cx)
+            .map(|kb| kb.size(rems_from_px(12.)));
+
+        tooltip_container(window, cx, |this, _, _| {
+            this
+                .child(
+                    h_flex()
+                        .justify_between()
+                        .child(title)
+                        .children(keybinding)
+                )
+                .child(
+                    div()
+                        .max_w_64()
+                        .child(
+                            Label::new("Enables models to use large context windows, unlimited tool calls, and other capabilities for expanded reasoning.")
+                                .size(LabelSize::Small)
+                                .color(Color::Muted)
+                        )
+                )
+        })
+    }
+}

crates/agent/src/ui/context_pill.rs → crates/agent_ui/src/ui/context_pill.rs 🔗

@@ -12,7 +12,7 @@ use prompt_store::PromptStore;
 use rope::Point;
 use ui::{IconButtonShape, Tooltip, prelude::*, tooltip_container};
 
-use crate::context::{
+use agent::context::{
     AgentContext, AgentContextHandle, ContextId, ContextKind, DirectoryContext,
     DirectoryContextHandle, FetchedUrlContext, FileContext, FileContextHandle, ImageContext,
     ImageStatus, RulesContext, RulesContextHandle, SelectionContext, SelectionContextHandle,
@@ -93,20 +93,9 @@ impl ContextPill {
             Self::Suggested {
                 icon_path: Some(icon_path),
                 ..
-            }
-            | Self::Added {
-                context:
-                    AddedContext {
-                        icon_path: Some(icon_path),
-                        ..
-                    },
-                ..
             } => Icon::from_path(icon_path),
-            Self::Suggested { kind, .. }
-            | Self::Added {
-                context: AddedContext { kind, .. },
-                ..
-            } => Icon::new(kind.icon()),
+            Self::Suggested { kind, .. } => Icon::new(kind.icon()),
+            Self::Added { context, .. } => context.icon(),
         }
     }
 }
@@ -133,6 +122,7 @@ impl RenderOnce for ContextPill {
                 on_click,
             } => {
                 let status_is_error = matches!(context.status, ContextStatus::Error { .. });
+                let status_is_warning = matches!(context.status, ContextStatus::Warning { .. });
 
                 base_pill
                     .pr(if on_remove.is_some() { px(2.) } else { px(4.) })
@@ -140,6 +130,9 @@ impl RenderOnce for ContextPill {
                         if status_is_error {
                             pill.bg(cx.theme().status().error_background)
                                 .border_color(cx.theme().status().error_border)
+                        } else if status_is_warning {
+                            pill.bg(cx.theme().status().warning_background)
+                                .border_color(cx.theme().status().warning_border)
                         } else if *focused {
                             pill.bg(color.element_background)
                                 .border_color(color.border_focused)
@@ -195,7 +188,8 @@ impl RenderOnce for ContextPill {
                                         |label, delta| label.opacity(delta),
                                     )
                                     .into_any_element(),
-                                ContextStatus::Error { message } => element
+                                ContextStatus::Warning { message }
+                                | ContextStatus::Error { message } => element
                                     .tooltip(ui::Tooltip::text(message.clone()))
                                     .into_any_element(),
                             }),
@@ -270,6 +264,7 @@ pub enum ContextStatus {
     Ready,
     Loading { message: SharedString },
     Error { message: SharedString },
+    Warning { message: SharedString },
 }
 
 #[derive(RegisterComponent)]
@@ -285,6 +280,19 @@ pub struct AddedContext {
 }
 
 impl AddedContext {
+    pub fn icon(&self) -> Icon {
+        match &self.status {
+            ContextStatus::Warning { .. } => Icon::new(IconName::Warning).color(Color::Warning),
+            ContextStatus::Error { .. } => Icon::new(IconName::XCircle).color(Color::Error),
+            _ => {
+                if let Some(icon_path) = &self.icon_path {
+                    Icon::from_path(icon_path)
+                } else {
+                    Icon::new(self.kind.icon())
+                }
+            }
+        }
+    }
     /// Creates an `AddedContext` by retrieving relevant details of `AgentContext`. This returns a
     /// `None` if `DirectoryContext` or `RulesContext` no longer exist.
     ///
@@ -293,6 +301,7 @@ impl AddedContext {
         handle: AgentContextHandle,
         prompt_store: Option<&Entity<PromptStore>>,
         project: &Project,
+        model: Option<&Arc<dyn language_model::LanguageModel>>,
         cx: &App,
     ) -> Option<AddedContext> {
         match handle {
@@ -304,11 +313,15 @@ impl AddedContext {
             AgentContextHandle::Thread(handle) => Some(Self::pending_thread(handle, cx)),
             AgentContextHandle::TextThread(handle) => Some(Self::pending_text_thread(handle, cx)),
             AgentContextHandle::Rules(handle) => Self::pending_rules(handle, prompt_store, cx),
-            AgentContextHandle::Image(handle) => Some(Self::image(handle)),
+            AgentContextHandle::Image(handle) => Some(Self::image(handle, model, cx)),
         }
     }
 
-    pub fn new_attached(context: &AgentContext, cx: &App) -> AddedContext {
+    pub fn new_attached(
+        context: &AgentContext,
+        model: Option<&Arc<dyn language_model::LanguageModel>>,
+        cx: &App,
+    ) -> AddedContext {
         match context {
             AgentContext::File(context) => Self::attached_file(context, cx),
             AgentContext::Directory(context) => Self::attached_directory(context),
@@ -318,7 +331,7 @@ impl AddedContext {
             AgentContext::Thread(context) => Self::attached_thread(context),
             AgentContext::TextThread(context) => Self::attached_text_thread(context),
             AgentContext::Rules(context) => Self::attached_rules(context),
-            AgentContext::Image(context) => Self::image(context.clone()),
+            AgentContext::Image(context) => Self::image(context.clone(), model, cx),
         }
     }
 
@@ -333,14 +346,8 @@ impl AddedContext {
 
     fn file(handle: FileContextHandle, full_path: &Path, cx: &App) -> AddedContext {
         let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
-        let name = full_path
-            .file_name()
-            .map(|n| n.to_string_lossy().into_owned().into())
-            .unwrap_or_else(|| full_path_string.clone());
-        let parent = full_path
-            .parent()
-            .and_then(|p| p.file_name())
-            .map(|n| n.to_string_lossy().into_owned().into());
+        let (name, parent) =
+            extract_file_name_and_directory_from_full_path(full_path, &full_path_string);
         AddedContext {
             kind: ContextKind::File,
             name,
@@ -370,14 +377,8 @@ impl AddedContext {
 
     fn directory(handle: DirectoryContextHandle, full_path: &Path) -> AddedContext {
         let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
-        let name = full_path
-            .file_name()
-            .map(|n| n.to_string_lossy().into_owned().into())
-            .unwrap_or_else(|| full_path_string.clone());
-        let parent = full_path
-            .parent()
-            .and_then(|p| p.file_name())
-            .map(|n| n.to_string_lossy().into_owned().into());
+        let (name, parent) =
+            extract_file_name_and_directory_from_full_path(full_path, &full_path_string);
         AddedContext {
             kind: ContextKind::Directory,
             name,
@@ -605,22 +606,45 @@ impl AddedContext {
         }
     }
 
-    fn image(context: ImageContext) -> AddedContext {
+    fn image(
+        context: ImageContext,
+        model: Option<&Arc<dyn language_model::LanguageModel>>,
+        cx: &App,
+    ) -> AddedContext {
+        let (name, parent, icon_path) = if let Some(full_path) = context.full_path.as_ref() {
+            let full_path_string: SharedString = full_path.to_string_lossy().into_owned().into();
+            let (name, parent) =
+                extract_file_name_and_directory_from_full_path(full_path, &full_path_string);
+            let icon_path = FileIcons::get_icon(&full_path, cx);
+            (name, parent, icon_path)
+        } else {
+            ("Image".into(), None, None)
+        };
+
+        let status = match context.status(model) {
+            ImageStatus::Loading => ContextStatus::Loading {
+                message: "Loading…".into(),
+            },
+            ImageStatus::Error => ContextStatus::Error {
+                message: "Failed to load Image".into(),
+            },
+            ImageStatus::Warning => ContextStatus::Warning {
+                message: format!(
+                    "{} doesn't support attaching Images as Context",
+                    model.map(|m| m.name().0).unwrap_or_else(|| "Model".into())
+                )
+                .into(),
+            },
+            ImageStatus::Ready => ContextStatus::Ready,
+        };
+
         AddedContext {
             kind: ContextKind::Image,
-            name: "Image".into(),
-            parent: None,
+            name,
+            parent,
             tooltip: None,
-            icon_path: None,
-            status: match context.status() {
-                ImageStatus::Loading => ContextStatus::Loading {
-                    message: "Loading…".into(),
-                },
-                ImageStatus::Error => ContextStatus::Error {
-                    message: "Failed to load image".into(),
-                },
-                ImageStatus::Ready => ContextStatus::Ready,
-            },
+            icon_path,
+            status,
             render_hover: Some(Rc::new({
                 let image = context.original_image.clone();
                 move |_, cx| {
@@ -639,6 +663,22 @@ impl AddedContext {
     }
 }
 
+fn extract_file_name_and_directory_from_full_path(
+    path: &Path,
+    name_fallback: &SharedString,
+) -> (SharedString, Option<SharedString>) {
+    let name = path
+        .file_name()
+        .map(|n| n.to_string_lossy().into_owned().into())
+        .unwrap_or_else(|| name_fallback.clone());
+    let parent = path
+        .parent()
+        .and_then(|p| p.file_name())
+        .map(|n| n.to_string_lossy().into_owned().into());
+
+    (name, parent)
+}
+
 #[derive(Debug, Clone)]
 struct ContextFileExcerpt {
     pub file_name_and_range: SharedString,
@@ -765,37 +805,52 @@ impl Component for AddedContext {
         let mut next_context_id = ContextId::zero();
         let image_ready = (
             "Ready",
-            AddedContext::image(ImageContext {
-                context_id: next_context_id.post_inc(),
-                project_path: None,
-                original_image: Arc::new(Image::empty()),
-                image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
-            }),
+            AddedContext::image(
+                ImageContext {
+                    context_id: next_context_id.post_inc(),
+                    project_path: None,
+                    full_path: None,
+                    original_image: Arc::new(Image::empty()),
+                    image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
+                },
+                None,
+                cx,
+            ),
         );
 
         let image_loading = (
             "Loading",
-            AddedContext::image(ImageContext {
-                context_id: next_context_id.post_inc(),
-                project_path: None,
-                original_image: Arc::new(Image::empty()),
-                image_task: cx
-                    .background_spawn(async move {
-                        smol::Timer::after(Duration::from_secs(60 * 5)).await;
-                        Some(LanguageModelImage::empty())
-                    })
-                    .shared(),
-            }),
+            AddedContext::image(
+                ImageContext {
+                    context_id: next_context_id.post_inc(),
+                    project_path: None,
+                    full_path: None,
+                    original_image: Arc::new(Image::empty()),
+                    image_task: cx
+                        .background_spawn(async move {
+                            smol::Timer::after(Duration::from_secs(60 * 5)).await;
+                            Some(LanguageModelImage::empty())
+                        })
+                        .shared(),
+                },
+                None,
+                cx,
+            ),
         );
 
         let image_error = (
             "Error",
-            AddedContext::image(ImageContext {
-                context_id: next_context_id.post_inc(),
-                project_path: None,
-                original_image: Arc::new(Image::empty()),
-                image_task: Task::ready(None).shared(),
-            }),
+            AddedContext::image(
+                ImageContext {
+                    context_id: next_context_id.post_inc(),
+                    project_path: None,
+                    full_path: None,
+                    original_image: Arc::new(Image::empty()),
+                    image_task: Task::ready(None).shared(),
+                },
+                None,
+                cx,
+            ),
         );
 
         Some(
@@ -815,3 +870,60 @@ impl Component for AddedContext {
         )
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::App;
+    use language_model::{LanguageModel, fake_provider::FakeLanguageModel};
+    use std::sync::Arc;
+
+    #[gpui::test]
+    fn test_image_context_warning_for_unsupported_model(cx: &mut App) {
+        let model: Arc<dyn LanguageModel> = Arc::new(FakeLanguageModel::default());
+        assert!(!model.supports_images());
+
+        let image_context = ImageContext {
+            context_id: ContextId::zero(),
+            project_path: None,
+            original_image: Arc::new(Image::empty()),
+            image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
+            full_path: None,
+        };
+
+        let added_context = AddedContext::image(image_context, Some(&model), cx);
+
+        assert!(matches!(
+            added_context.status,
+            ContextStatus::Warning { .. }
+        ));
+
+        assert!(matches!(added_context.kind, ContextKind::Image));
+        assert_eq!(added_context.name.as_ref(), "Image");
+        assert!(added_context.parent.is_none());
+        assert!(added_context.icon_path.is_none());
+    }
+
+    #[gpui::test]
+    fn test_image_context_ready_for_no_model(cx: &mut App) {
+        let image_context = ImageContext {
+            context_id: ContextId::zero(),
+            project_path: None,
+            original_image: Arc::new(Image::empty()),
+            image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
+            full_path: None,
+        };
+
+        let added_context = AddedContext::image(image_context, None, cx);
+
+        assert!(
+            matches!(added_context.status, ContextStatus::Ready),
+            "Expected ready status when no model provided"
+        );
+
+        assert!(matches!(added_context.kind, ContextKind::Image));
+        assert_eq!(added_context.name.as_ref(), "Image");
+        assert!(added_context.parent.is_none());
+        assert!(added_context.icon_path.is_none());
+    }
+}

crates/agent/src/ui/preview/agent_preview.rs → crates/agent_ui/src/ui/preview/agent_preview.rs 🔗

@@ -1,8 +1,8 @@
+use std::sync::OnceLock;
+
 use collections::HashMap;
 use component::ComponentId;
 use gpui::{App, Entity, WeakEntity};
-use linkme::distributed_slice;
-use std::sync::OnceLock;
 use ui::{AnyElement, Component, ComponentScope, Window};
 use workspace::Workspace;
 
@@ -12,9 +12,15 @@ use crate::ActiveThread;
 pub type PreviewFn =
     fn(WeakEntity<Workspace>, Entity<ActiveThread>, &mut Window, &mut App) -> Option<AnyElement>;
 
-/// Distributed slice for preview registration functions
-#[distributed_slice]
-pub static __ALL_AGENT_PREVIEWS: [fn() -> (ComponentId, PreviewFn)] = [..];
+pub struct AgentPreviewFn(fn() -> (ComponentId, PreviewFn));
+
+impl AgentPreviewFn {
+    pub const fn new(f: fn() -> (ComponentId, PreviewFn)) -> Self {
+        Self(f)
+    }
+}
+
+inventory::collect!(AgentPreviewFn);
 
 /// Trait that must be implemented by components that provide agent previews.
 pub trait AgentPreview: Component + Sized {
@@ -36,16 +42,14 @@ pub trait AgentPreview: Component + Sized {
 #[macro_export]
 macro_rules! register_agent_preview {
     ($type:ty) => {
-        #[linkme::distributed_slice($crate::ui::preview::__ALL_AGENT_PREVIEWS)]
-        static __REGISTER_AGENT_PREVIEW: fn() -> (
-            component::ComponentId,
-            $crate::ui::preview::PreviewFn,
-        ) = || {
-            (
-                <$type as component::Component>::id(),
-                <$type as $crate::ui::preview::AgentPreview>::agent_preview,
-            )
-        };
+        inventory::submit! {
+            $crate::ui::preview::AgentPreviewFn::new(|| {
+                (
+                    <$type as component::Component>::id(),
+                    <$type as $crate::ui::preview::AgentPreview>::agent_preview,
+                )
+            })
+        }
     };
 }
 
@@ -56,8 +60,8 @@ static AGENT_PREVIEW_REGISTRY: OnceLock<HashMap<ComponentId, PreviewFn>> = OnceL
 fn get_or_init_registry() -> &'static HashMap<ComponentId, PreviewFn> {
     AGENT_PREVIEW_REGISTRY.get_or_init(|| {
         let mut map = HashMap::default();
-        for register_fn in __ALL_AGENT_PREVIEWS.iter() {
-            let (id, preview_fn) = register_fn();
+        for register_fn in inventory::iter::<AgentPreviewFn>() {
+            let (id, preview_fn) = (register_fn.0)();
             map.insert(id, preview_fn);
         }
         map

crates/agent/src/ui/preview/usage_callouts.rs → crates/agent_ui/src/ui/preview/usage_callouts.rs 🔗

@@ -1,18 +1,17 @@
-use client::zed_urls;
+use client::{ModelRequestUsage, RequestUsage, zed_urls};
 use component::{empty_example, example_group_with_title, single_example};
 use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
-use language_model::RequestUsage;
-use ui::{Callout, Color, Icon, IconName, IconSize, prelude::*};
+use ui::{Callout, prelude::*};
 use zed_llm_client::{Plan, UsageLimit};
 
 #[derive(IntoElement, RegisterComponent)]
 pub struct UsageCallout {
     plan: Plan,
-    usage: RequestUsage,
+    usage: ModelRequestUsage,
 }
 
 impl UsageCallout {
-    pub fn new(plan: Plan, usage: RequestUsage) -> Self {
+    pub fn new(plan: Plan, usage: ModelRequestUsage) -> Self {
         Self { plan, usage }
     }
 }
@@ -91,16 +90,23 @@ impl RenderOnce for UsageCallout {
                 .size(IconSize::XSmall)
         };
 
-        Callout::multi_line(
-            title,
-            message,
-            icon,
-            button_text,
-            Box::new(move |_, _, cx| {
-                cx.open_url(&url);
-            }),
-        )
-        .into_any_element()
+        div()
+            .border_t_1()
+            .border_color(cx.theme().colors().border)
+            .child(
+                Callout::new()
+                    .icon(icon)
+                    .title(title)
+                    .description(message)
+                    .primary_action(
+                        Button::new("upgrade", button_text)
+                            .label_size(LabelSize::Small)
+                            .on_click(move |_, _, cx| {
+                                cx.open_url(&url);
+                            }),
+                    ),
+            )
+            .into_any_element()
     }
 }
 
@@ -121,10 +127,10 @@ impl Component for UsageCallout {
                     "Approaching limit (90%)",
                     UsageCallout::new(
                         Plan::ZedFree,
-                        RequestUsage {
+                        ModelRequestUsage(RequestUsage {
                             limit: UsageLimit::Limited(50),
                             amount: 45, // 90% of limit
-                        },
+                        }),
                     )
                     .into_any_element(),
                 ),
@@ -132,10 +138,10 @@ impl Component for UsageCallout {
                     "Limit reached (100%)",
                     UsageCallout::new(
                         Plan::ZedFree,
-                        RequestUsage {
+                        ModelRequestUsage(RequestUsage {
                             limit: UsageLimit::Limited(50),
                             amount: 50, // 100% of limit
-                        },
+                        }),
                     )
                     .into_any_element(),
                 ),
@@ -149,10 +155,10 @@ impl Component for UsageCallout {
                     "Approaching limit (90%)",
                     UsageCallout::new(
                         Plan::ZedProTrial,
-                        RequestUsage {
+                        ModelRequestUsage(RequestUsage {
                             limit: UsageLimit::Limited(150),
                             amount: 135, // 90% of limit
-                        },
+                        }),
                     )
                     .into_any_element(),
                 ),
@@ -160,10 +166,10 @@ impl Component for UsageCallout {
                     "Limit reached (100%)",
                     UsageCallout::new(
                         Plan::ZedProTrial,
-                        RequestUsage {
+                        ModelRequestUsage(RequestUsage {
                             limit: UsageLimit::Limited(150),
                             amount: 150, // 100% of limit
-                        },
+                        }),
                     )
                     .into_any_element(),
                 ),
@@ -177,10 +183,10 @@ impl Component for UsageCallout {
                     "Limit reached (100%)",
                     UsageCallout::new(
                         Plan::ZedPro,
-                        RequestUsage {
+                        ModelRequestUsage(RequestUsage {
                             limit: UsageLimit::Limited(500),
                             amount: 500, // 100% of limit
-                        },
+                        }),
                     )
                     .into_any_element(),
                 ),
@@ -189,10 +195,8 @@ impl Component for UsageCallout {
         );
 
         Some(
-            div()
+            v_flex()
                 .p_4()
-                .flex()
-                .flex_col()
                 .gap_4()
                 .child(free_examples)
                 .child(trial_examples)

crates/anthropic/src/anthropic.rs 🔗

@@ -1,9 +1,11 @@
+use std::io;
 use std::str::FromStr;
+use std::time::Duration;
 
 use anyhow::{Context as _, Result, anyhow};
 use chrono::{DateTime, Utc};
 use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
-use http_client::http::{HeaderMap, HeaderValue};
+use http_client::http::{self, HeaderMap, HeaderValue};
 use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
 use serde::{Deserialize, Serialize};
 use strum::{EnumIter, EnumString};
@@ -14,7 +16,7 @@ pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com";
 #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
 #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
 pub struct AnthropicModelCacheConfiguration {
-    pub min_total_token: usize,
+    pub min_total_token: u64,
     pub should_speculate: bool,
     pub max_cache_anchors: usize,
 }
@@ -32,9 +34,21 @@ pub enum AnthropicModelMode {
 #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
 #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)]
 pub enum Model {
-    #[serde(rename = "claude-3-5-sonnet", alias = "claude-3-5-sonnet-latest")]
-    Claude3_5Sonnet,
+    #[serde(rename = "claude-opus-4", alias = "claude-opus-4-latest")]
+    ClaudeOpus4,
+    #[serde(
+        rename = "claude-opus-4-thinking",
+        alias = "claude-opus-4-thinking-latest"
+    )]
+    ClaudeOpus4Thinking,
     #[default]
+    #[serde(rename = "claude-sonnet-4", alias = "claude-sonnet-4-latest")]
+    ClaudeSonnet4,
+    #[serde(
+        rename = "claude-sonnet-4-thinking",
+        alias = "claude-sonnet-4-thinking-latest"
+    )]
+    ClaudeSonnet4Thinking,
     #[serde(rename = "claude-3-7-sonnet", alias = "claude-3-7-sonnet-latest")]
     Claude3_7Sonnet,
     #[serde(
@@ -42,6 +56,8 @@ pub enum Model {
         alias = "claude-3-7-sonnet-thinking-latest"
     )]
     Claude3_7SonnetThinking,
+    #[serde(rename = "claude-3-5-sonnet", alias = "claude-3-5-sonnet-latest")]
+    Claude3_5Sonnet,
     #[serde(rename = "claude-3-5-haiku", alias = "claude-3-5-haiku-latest")]
     Claude3_5Haiku,
     #[serde(rename = "claude-3-opus", alias = "claude-3-opus-latest")]
@@ -53,14 +69,14 @@ pub enum Model {
     #[serde(rename = "custom")]
     Custom {
         name: String,
-        max_tokens: usize,
+        max_tokens: u64,
         /// The name displayed in the UI, such as in the assistant panel model dropdown menu.
         display_name: Option<String>,
         /// Override this model with a different Anthropic model for tool calls.
         tool_override: Option<String>,
         /// Indicates whether this custom model supports caching.
         cache_configuration: Option<AnthropicModelCacheConfiguration>,
-        max_output_tokens: Option<u32>,
+        max_output_tokens: Option<u64>,
         default_temperature: Option<f32>,
         #[serde(default)]
         extra_beta_headers: Vec<String>,
@@ -75,34 +91,66 @@ impl Model {
     }
 
     pub fn from_id(id: &str) -> Result<Self> {
+        if id.starts_with("claude-opus-4-thinking") {
+            return Ok(Self::ClaudeOpus4Thinking);
+        }
+
+        if id.starts_with("claude-opus-4") {
+            return Ok(Self::ClaudeOpus4);
+        }
+
+        if id.starts_with("claude-sonnet-4-thinking") {
+            return Ok(Self::ClaudeSonnet4Thinking);
+        }
+
+        if id.starts_with("claude-sonnet-4") {
+            return Ok(Self::ClaudeSonnet4);
+        }
+
+        if id.starts_with("claude-3-7-sonnet-thinking") {
+            return Ok(Self::Claude3_7SonnetThinking);
+        }
+
+        if id.starts_with("claude-3-7-sonnet") {
+            return Ok(Self::Claude3_7Sonnet);
+        }
+
         if id.starts_with("claude-3-5-sonnet") {
-            Ok(Self::Claude3_5Sonnet)
-        } else if id.starts_with("claude-3-7-sonnet-thinking") {
-            Ok(Self::Claude3_7SonnetThinking)
-        } else if id.starts_with("claude-3-7-sonnet") {
-            Ok(Self::Claude3_7Sonnet)
-        } else if id.starts_with("claude-3-5-haiku") {
-            Ok(Self::Claude3_5Haiku)
-        } else if id.starts_with("claude-3-opus") {
-            Ok(Self::Claude3Opus)
-        } else if id.starts_with("claude-3-sonnet") {
-            Ok(Self::Claude3Sonnet)
-        } else if id.starts_with("claude-3-haiku") {
-            Ok(Self::Claude3Haiku)
-        } else {
-            Err(anyhow!("invalid model id"))
+            return Ok(Self::Claude3_5Sonnet);
+        }
+
+        if id.starts_with("claude-3-5-haiku") {
+            return Ok(Self::Claude3_5Haiku);
+        }
+
+        if id.starts_with("claude-3-opus") {
+            return Ok(Self::Claude3Opus);
+        }
+
+        if id.starts_with("claude-3-sonnet") {
+            return Ok(Self::Claude3Sonnet);
+        }
+
+        if id.starts_with("claude-3-haiku") {
+            return Ok(Self::Claude3Haiku);
         }
+
+        Err(anyhow!("invalid model ID: {id}"))
     }
 
     pub fn id(&self) -> &str {
         match self {
-            Model::Claude3_5Sonnet => "claude-3-5-sonnet-latest",
-            Model::Claude3_7Sonnet => "claude-3-7-sonnet-latest",
-            Model::Claude3_7SonnetThinking => "claude-3-7-sonnet-thinking-latest",
-            Model::Claude3_5Haiku => "claude-3-5-haiku-latest",
-            Model::Claude3Opus => "claude-3-opus-latest",
-            Model::Claude3Sonnet => "claude-3-sonnet-20240229",
-            Model::Claude3Haiku => "claude-3-haiku-20240307",
+            Self::ClaudeOpus4 => "claude-opus-4-latest",
+            Self::ClaudeOpus4Thinking => "claude-opus-4-thinking-latest",
+            Self::ClaudeSonnet4 => "claude-sonnet-4-latest",
+            Self::ClaudeSonnet4Thinking => "claude-sonnet-4-thinking-latest",
+            Self::Claude3_5Sonnet => "claude-3-5-sonnet-latest",
+            Self::Claude3_7Sonnet => "claude-3-7-sonnet-latest",
+            Self::Claude3_7SonnetThinking => "claude-3-7-sonnet-thinking-latest",
+            Self::Claude3_5Haiku => "claude-3-5-haiku-latest",
+            Self::Claude3Opus => "claude-3-opus-latest",
+            Self::Claude3Sonnet => "claude-3-sonnet-20240229",
+            Self::Claude3Haiku => "claude-3-haiku-20240307",
             Self::Custom { name, .. } => name,
         }
     }
@@ -110,18 +158,24 @@ impl Model {
     /// The id of the model that should be used for making API requests
     pub fn request_id(&self) -> &str {
         match self {
-            Model::Claude3_5Sonnet => "claude-3-5-sonnet-latest",
-            Model::Claude3_7Sonnet | Model::Claude3_7SonnetThinking => "claude-3-7-sonnet-latest",
-            Model::Claude3_5Haiku => "claude-3-5-haiku-latest",
-            Model::Claude3Opus => "claude-3-opus-latest",
-            Model::Claude3Sonnet => "claude-3-sonnet-20240229",
-            Model::Claude3Haiku => "claude-3-haiku-20240307",
+            Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking => "claude-opus-4-20250514",
+            Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking => "claude-sonnet-4-20250514",
+            Self::Claude3_5Sonnet => "claude-3-5-sonnet-latest",
+            Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => "claude-3-7-sonnet-latest",
+            Self::Claude3_5Haiku => "claude-3-5-haiku-latest",
+            Self::Claude3Opus => "claude-3-opus-latest",
+            Self::Claude3Sonnet => "claude-3-sonnet-20240229",
+            Self::Claude3Haiku => "claude-3-haiku-20240307",
             Self::Custom { name, .. } => name,
         }
     }
 
     pub fn display_name(&self) -> &str {
         match self {
+            Self::ClaudeOpus4 => "Claude Opus 4",
+            Self::ClaudeOpus4Thinking => "Claude Opus 4 Thinking",
+            Self::ClaudeSonnet4 => "Claude Sonnet 4",
+            Self::ClaudeSonnet4Thinking => "Claude Sonnet 4 Thinking",
             Self::Claude3_7Sonnet => "Claude 3.7 Sonnet",
             Self::Claude3_5Sonnet => "Claude 3.5 Sonnet",
             Self::Claude3_7SonnetThinking => "Claude 3.7 Sonnet Thinking",
@@ -137,7 +191,11 @@ impl Model {
 
     pub fn cache_configuration(&self) -> Option<AnthropicModelCacheConfiguration> {
         match self {
-            Self::Claude3_5Sonnet
+            Self::ClaudeOpus4
+            | Self::ClaudeOpus4Thinking
+            | Self::ClaudeSonnet4
+            | Self::ClaudeSonnet4Thinking
+            | Self::Claude3_5Sonnet
             | Self::Claude3_5Haiku
             | Self::Claude3_7Sonnet
             | Self::Claude3_7SonnetThinking
@@ -154,9 +212,13 @@ impl Model {
         }
     }
 
-    pub fn max_token_count(&self) -> usize {
+    pub fn max_token_count(&self) -> u64 {
         match self {
-            Self::Claude3_5Sonnet
+            Self::ClaudeOpus4
+            | Self::ClaudeOpus4Thinking
+            | Self::ClaudeSonnet4
+            | Self::ClaudeSonnet4Thinking
+            | Self::Claude3_5Sonnet
             | Self::Claude3_5Haiku
             | Self::Claude3_7Sonnet
             | Self::Claude3_7SonnetThinking
@@ -167,13 +229,17 @@ impl Model {
         }
     }
 
-    pub fn max_output_tokens(&self) -> u32 {
+    pub fn max_output_tokens(&self) -> u64 {
         match self {
-            Self::Claude3Opus | Self::Claude3Sonnet | Self::Claude3Haiku => 4_096,
-            Self::Claude3_5Sonnet
+            Self::ClaudeOpus4
+            | Self::ClaudeOpus4Thinking
+            | Self::ClaudeSonnet4
+            | Self::ClaudeSonnet4Thinking
+            | Self::Claude3_5Sonnet
             | Self::Claude3_7Sonnet
             | Self::Claude3_7SonnetThinking
             | Self::Claude3_5Haiku => 8_192,
+            Self::Claude3Opus | Self::Claude3Sonnet | Self::Claude3Haiku => 4_096,
             Self::Custom {
                 max_output_tokens, ..
             } => max_output_tokens.unwrap_or(4_096),
@@ -182,7 +248,11 @@ impl Model {
 
     pub fn default_temperature(&self) -> f32 {
         match self {
-            Self::Claude3_5Sonnet
+            Self::ClaudeOpus4
+            | Self::ClaudeOpus4Thinking
+            | Self::ClaudeSonnet4
+            | Self::ClaudeSonnet4Thinking
+            | Self::Claude3_5Sonnet
             | Self::Claude3_7Sonnet
             | Self::Claude3_7SonnetThinking
             | Self::Claude3_5Haiku
@@ -198,13 +268,17 @@ impl Model {
 
     pub fn mode(&self) -> AnthropicModelMode {
         match self {
-            Self::Claude3_5Sonnet
+            Self::ClaudeOpus4
+            | Self::ClaudeSonnet4
+            | Self::Claude3_5Sonnet
             | Self::Claude3_7Sonnet
             | Self::Claude3_5Haiku
             | Self::Claude3Opus
             | Self::Claude3Sonnet
             | Self::Claude3Haiku => AnthropicModelMode::Default,
-            Self::Claude3_7SonnetThinking => AnthropicModelMode::Thinking {
+            Self::ClaudeOpus4Thinking
+            | Self::ClaudeSonnet4Thinking
+            | Self::Claude3_7SonnetThinking => AnthropicModelMode::Thinking {
                 budget_tokens: Some(4_096),
             },
             Self::Custom { mode, .. } => mode.clone(),
@@ -215,7 +289,7 @@ impl Model {
 
     pub fn beta_headers(&self) -> String {
         let mut headers = Self::DEFAULT_BETA_HEADERS
-            .into_iter()
+            .iter()
             .map(|header| header.to_string())
             .collect::<Vec<_>>();
 
@@ -263,7 +337,7 @@ pub async fn complete(
     let uri = format!("{api_url}/v1/messages");
     let beta_headers = Model::from_id(&request.model)
         .map(|model| model.beta_headers())
-        .unwrap_or_else(|_err| Model::DEFAULT_BETA_HEADERS.join(","));
+        .unwrap_or_else(|_| Model::DEFAULT_BETA_HEADERS.join(","));
     let request_builder = HttpRequest::builder()
         .method(Method::POST)
         .uri(uri)
@@ -273,39 +347,30 @@ pub async fn complete(
         .header("Content-Type", "application/json");
 
     let serialized_request =
-        serde_json::to_string(&request).context("failed to serialize request")?;
+        serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?;
     let request = request_builder
         .body(AsyncBody::from(serialized_request))
-        .context("failed to construct request body")?;
+        .map_err(AnthropicError::BuildRequestBody)?;
 
     let mut response = client
         .send(request)
         .await
-        .context("failed to send request to Anthropic")?;
-    if response.status().is_success() {
-        let mut body = Vec::new();
-        response
-            .body_mut()
-            .read_to_end(&mut body)
-            .await
-            .context("failed to read response body")?;
-        let response_message: Response =
-            serde_json::from_slice(&body).context("failed to deserialize response body")?;
-        Ok(response_message)
+        .map_err(AnthropicError::HttpSend)?;
+    let status = response.status();
+    let mut body = String::new();
+    response
+        .body_mut()
+        .read_to_string(&mut body)
+        .await
+        .map_err(AnthropicError::ReadResponse)?;
+
+    if status.is_success() {
+        Ok(serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse)?)
     } else {
-        let mut body = Vec::new();
-        response
-            .body_mut()
-            .read_to_end(&mut body)
-            .await
-            .context("failed to read response body")?;
-        let body_str =
-            std::str::from_utf8(&body).context("failed to parse response body as UTF-8")?;
-        Err(AnthropicError::Other(anyhow!(
-            "Failed to connect to API: {} {}",
-            response.status(),
-            body_str
-        )))
+        Err(AnthropicError::HttpResponseError {
+            status: status.as_u16(),
+            body,
+        })
     }
 }
 
@@ -354,6 +419,7 @@ impl RateLimit {
 /// <https://docs.anthropic.com/en/api/rate-limits#response-headers>
 #[derive(Debug)]
 pub struct RateLimitInfo {
+    pub retry_after: Option<Duration>,
     pub requests: Option<RateLimit>,
     pub tokens: Option<RateLimit>,
     pub input_tokens: Option<RateLimit>,
@@ -365,10 +431,11 @@ impl RateLimitInfo {
         // Check if any rate limit headers exist
         let has_rate_limit_headers = headers
             .keys()
-            .any(|k| k.as_str().starts_with("anthropic-ratelimit-"));
+            .any(|k| k == "retry-after" || k.as_str().starts_with("anthropic-ratelimit-"));
 
         if !has_rate_limit_headers {
             return Self {
+                retry_after: None,
                 requests: None,
                 tokens: None,
                 input_tokens: None,
@@ -377,6 +444,11 @@ impl RateLimitInfo {
         }
 
         Self {
+            retry_after: headers
+                .get("retry-after")
+                .and_then(|v| v.to_str().ok())
+                .and_then(|v| v.parse::<u64>().ok())
+                .map(Duration::from_secs),
             requests: RateLimit::from_headers("requests", headers).ok(),
             tokens: RateLimit::from_headers("tokens", headers).ok(),
             input_tokens: RateLimit::from_headers("input-tokens", headers).ok(),
@@ -385,10 +457,10 @@ impl RateLimitInfo {
     }
 }
 
-fn get_header<'a>(key: &str, headers: &'a HeaderMap) -> Result<&'a str, anyhow::Error> {
+fn get_header<'a>(key: &str, headers: &'a HeaderMap) -> anyhow::Result<&'a str> {
     Ok(headers
         .get(key)
-        .ok_or_else(|| anyhow!("missing header `{key}`"))?
+        .with_context(|| format!("missing header `{key}`"))?
         .to_str()?)
 }
 
@@ -411,7 +483,7 @@ pub async fn stream_completion_with_rate_limit_info(
     let uri = format!("{api_url}/v1/messages");
     let beta_headers = Model::from_id(&request.base.model)
         .map(|model| model.beta_headers())
-        .unwrap_or_else(|_err| Model::DEFAULT_BETA_HEADERS.join(","));
+        .unwrap_or_else(|_| Model::DEFAULT_BETA_HEADERS.join(","));
     let request_builder = HttpRequest::builder()
         .method(Method::POST)
         .uri(uri)
@@ -420,17 +492,17 @@ pub async fn stream_completion_with_rate_limit_info(
         .header("X-Api-Key", api_key)
         .header("Content-Type", "application/json");
     let serialized_request =
-        serde_json::to_string(&request).context("failed to serialize request")?;
+        serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?;
     let request = request_builder
         .body(AsyncBody::from(serialized_request))
-        .context("failed to construct request body")?;
+        .map_err(AnthropicError::BuildRequestBody)?;
 
     let mut response = client
         .send(request)
         .await
-        .context("failed to send request to Anthropic")?;
+        .map_err(AnthropicError::HttpSend)?;
+    let rate_limits = RateLimitInfo::from_headers(response.headers());
     if response.status().is_success() {
-        let rate_limits = RateLimitInfo::from_headers(response.headers());
         let reader = BufReader::new(response.into_body());
         let stream = reader
             .lines()
@@ -440,35 +512,31 @@ pub async fn stream_completion_with_rate_limit_info(
                         let line = line.strip_prefix("data: ")?;
                         match serde_json::from_str(line) {
                             Ok(response) => Some(Ok(response)),
-                            Err(error) => Some(Err(AnthropicError::Other(anyhow!(error)))),
+                            Err(error) => Some(Err(AnthropicError::DeserializeResponse(error))),
                         }
                     }
-                    Err(error) => Some(Err(AnthropicError::Other(anyhow!(error)))),
+                    Err(error) => Some(Err(AnthropicError::ReadResponse(error))),
                 }
             })
             .boxed();
         Ok((stream, Some(rate_limits)))
+    } else if let Some(retry_after) = rate_limits.retry_after {
+        Err(AnthropicError::RateLimit { retry_after })
     } else {
-        let mut body = Vec::new();
+        let mut body = String::new();
         response
             .body_mut()
-            .read_to_end(&mut body)
+            .read_to_string(&mut body)
             .await
-            .context("failed to read response body")?;
+            .map_err(AnthropicError::ReadResponse)?;
 
-        let body_str =
-            std::str::from_utf8(&body).context("failed to parse response body as UTF-8")?;
-
-        match serde_json::from_str::<Event>(body_str) {
+        match serde_json::from_str::<Event>(&body) {
             Ok(Event::Error { error }) => Err(AnthropicError::ApiError(error)),
-            Ok(_) => Err(AnthropicError::Other(anyhow!(
-                "Unexpected success response while expecting an error: '{body_str}'",
-            ))),
-            Err(_) => Err(AnthropicError::Other(anyhow!(
-                "Failed to connect to API: {} {}",
-                response.status(),
-                body_str,
-            ))),
+            Ok(_) => Err(AnthropicError::UnexpectedResponseFormat(body)),
+            Err(_) => Err(AnthropicError::HttpResponseError {
+                status: response.status().as_u16(),
+                body: body,
+            }),
         }
     }
 }
@@ -534,12 +602,26 @@ pub enum RequestContent {
     ToolResult {
         tool_use_id: String,
         is_error: bool,
-        content: String,
+        content: ToolResultContent,
         #[serde(skip_serializing_if = "Option::is_none")]
         cache_control: Option<CacheControl>,
     },
 }
 
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum ToolResultContent {
+    Plain(String),
+    Multipart(Vec<ToolResultPart>),
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "lowercase")]
+pub enum ToolResultPart {
+    Text { text: String },
+    Image { source: ImageSource },
+}
+
 #[derive(Debug, Serialize, Deserialize)]
 #[serde(tag = "type")]
 pub enum ResponseContent {
@@ -597,7 +679,7 @@ pub enum StringOrContents {
 #[derive(Debug, Serialize, Deserialize)]
 pub struct Request {
     pub model: String,
-    pub max_tokens: u32,
+    pub max_tokens: u64,
     pub messages: Vec<Message>,
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     pub tools: Vec<Tool>,
@@ -634,13 +716,13 @@ pub struct Metadata {
 #[derive(Debug, Serialize, Deserialize, Default)]
 pub struct Usage {
     #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub input_tokens: Option<u32>,
+    pub input_tokens: Option<u64>,
     #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub output_tokens: Option<u32>,
+    pub output_tokens: Option<u64>,
     #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub cache_creation_input_tokens: Option<u32>,
+    pub cache_creation_input_tokens: Option<u64>,
     #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub cache_read_input_tokens: Option<u32>,
+    pub cache_read_input_tokens: Option<u64>,
 }
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -701,15 +783,38 @@ pub struct MessageDelta {
     pub stop_sequence: Option<String>,
 }
 
-#[derive(Error, Debug)]
+#[derive(Debug)]
 pub enum AnthropicError {
-    #[error("an error occurred while interacting with the Anthropic API: {error_type}: {message}", error_type = .0.error_type, message = .0.message)]
+    /// Failed to serialize the HTTP request body to JSON
+    SerializeRequest(serde_json::Error),
+
+    /// Failed to construct the HTTP request body
+    BuildRequestBody(http::Error),
+
+    /// Failed to send the HTTP request
+    HttpSend(anyhow::Error),
+
+    /// Failed to deserialize the response from JSON
+    DeserializeResponse(serde_json::Error),
+
+    /// Failed to read from response stream
+    ReadResponse(io::Error),
+
+    /// HTTP error response from the API
+    HttpResponseError { status: u16, body: String },
+
+    /// Rate limit exceeded
+    RateLimit { retry_after: Duration },
+
+    /// API returned an error response
     ApiError(ApiError),
-    #[error("{0}")]
-    Other(#[from] anyhow::Error),
+
+    /// Unexpected response format
+    UnexpectedResponseFormat(String),
 }
 
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, Error)]
+#[error("Anthropic API Error: {error_type}: {message}")]
 pub struct ApiError {
     #[serde(rename = "type")]
     pub error_type: String,
@@ -748,7 +853,7 @@ impl ApiError {
         matches!(self.error_type.as_str(), "rate_limit_error")
     }
 
-    pub fn match_window_exceeded(&self) -> Option<usize> {
+    pub fn match_window_exceeded(&self) -> Option<u64> {
         let Some(ApiErrorCode::InvalidRequestError) = self.code() else {
             return None;
         };
@@ -757,12 +862,12 @@ impl ApiError {
     }
 }
 
-pub fn parse_prompt_too_long(message: &str) -> Option<usize> {
+pub fn parse_prompt_too_long(message: &str) -> Option<u64> {
     message
         .strip_prefix("prompt is too long: ")?
         .split_once(" tokens")?
         .0
-        .parse::<usize>()
+        .parse()
         .ok()
 }
 

crates/askpass/Cargo.toml 🔗

@@ -15,7 +15,6 @@ path = "src/askpass.rs"
 anyhow.workspace = true
 futures.workspace = true
 gpui.workspace = true
-shlex.workspace = true
 smol.workspace = true
 tempfile.workspace = true
 util.workspace = true

crates/askpass/src/askpass.rs 🔗

@@ -13,9 +13,9 @@ use gpui::{AsyncApp, BackgroundExecutor, Task};
 #[cfg(unix)]
 use smol::fs;
 #[cfg(unix)]
-use smol::{fs::unix::PermissionsExt as _, net::unix::UnixListener};
+use smol::net::unix::UnixListener;
 #[cfg(unix)]
-use util::ResultExt as _;
+use util::{ResultExt as _, fs::make_file_executable, get_shell_safe_zed_path};
 
 #[derive(PartialEq, Eq)]
 pub enum AskPassResult {
@@ -120,7 +120,7 @@ impl AskPassSession {
             shebang = "#!/bin/sh",
         );
         fs::write(&askpass_script_path, askpass_script).await?;
-        fs::set_permissions(&askpass_script_path, std::fs::Permissions::from_mode(0o755)).await?;
+        make_file_executable(&askpass_script_path).await?;
 
         Ok(Self {
             script_path: askpass_script_path,
@@ -160,36 +160,6 @@ impl AskPassSession {
     }
 }
 
-#[cfg(unix)]
-fn get_shell_safe_zed_path() -> anyhow::Result<String> {
-    let zed_path = std::env::current_exe()
-        .context("Failed to figure out current executable path for use in askpass")?
-        .to_string_lossy()
-        .to_string();
-
-    // NOTE: this was previously enabled, however, it caused errors when it shouldn't have
-    //       (see https://github.com/zed-industries/zed/issues/29819)
-    //       The zed path failing to execute within the askpass script results in very vague ssh
-    //       authentication failed errors, so this was done to try and surface a better error
-    //
-    // use std::os::unix::fs::MetadataExt;
-    // let metadata = std::fs::metadata(&zed_path)
-    //     .context("Failed to check metadata of Zed executable path for use in askpass")?;
-    // let is_executable = metadata.is_file() && metadata.mode() & 0o111 != 0;
-    // anyhow::ensure!(
-    //     is_executable,
-    //     "Failed to verify Zed executable path for use in askpass"
-    // );
-
-    // As of writing, this can only be fail if the path contains a null byte, which shouldn't be possible
-    // but shlex has annotated the error as #[non_exhaustive] so we can't make it a compile error if other
-    // errors are introduced in the future :(
-    let zed_path_escaped = shlex::try_quote(&zed_path)
-        .context("Failed to shell-escape Zed executable path for use in askpass")?;
-
-    return Ok(zed_path_escaped.to_string());
-}
-
 /// The main function for when Zed is running in netcat mode for use in askpass.
 /// Called from both the remote server binary and the zed binary in their respective main functions.
 #[cfg(unix)]

crates/assets/src/assets.rs 🔗

@@ -1,6 +1,6 @@
 // This crate was essentially pulled out verbatim from main `zed` crate to avoid having to run RustEmbed macro whenever zed has to be rebuilt. It saves a second or two on an incremental build.
-use anyhow::anyhow;
 
+use anyhow::Context as _;
 use gpui::{App, AssetSource, Result, SharedString};
 use rust_embed::RustEmbed;
 
@@ -21,7 +21,7 @@ impl AssetSource for Assets {
     fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
         Self::get(path)
             .map(|f| Some(f.data))
-            .ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path))
+            .with_context(|| format!("loading asset at path {path:?}"))
     }
 
     fn list(&self, path: &str) -> Result<Vec<SharedString>> {
@@ -39,7 +39,7 @@ impl AssetSource for Assets {
 
 impl Assets {
     /// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory.
-    pub fn load_fonts(&self, cx: &App) -> gpui::Result<()> {
+    pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
         let font_paths = self.list("fonts")?;
         let mut embedded_fonts = Vec::new();
         for font_path in font_paths {

crates/assistant_context_editor/Cargo.toml → crates/assistant_context/Cargo.toml 🔗

@@ -1,5 +1,5 @@
 [package]
-name = "assistant_context_editor"
+name = "assistant_context"
 version = "0.1.0"
 edition.workspace = true
 publish.workspace = true
@@ -9,11 +9,11 @@ license = "GPL-3.0-or-later"
 workspace = true
 
 [lib]
-path = "src/assistant_context_editor.rs"
+path = "src/assistant_context.rs"
 
 [dependencies]
+agent_settings.workspace = true
 anyhow.workspace = true
-assistant_settings.workspace = true
 assistant_slash_command.workspace = true
 assistant_slash_commands.workspace = true
 chrono.workspace = true
@@ -21,25 +21,20 @@ client.workspace = true
 clock.workspace = true
 collections.workspace = true
 context_server.workspace = true
-editor.workspace = true
 fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
-indexed_docs.workspace = true
 language.workspace = true
 language_model.workspace = true
-language_model_selector.workspace = true
 log.workspace = true
-multi_buffer.workspace = true
 open_ai.workspace = true
 parking_lot.workspace = true
 paths.workspace = true
-picker.workspace = true
 project.workspace = true
 prompt_store.workspace = true
+proto.workspace = true
 regex.workspace = true
-rope.workspace = true
 rpc.workspace = true
 serde.workspace = true
 serde_json.workspace = true
@@ -48,19 +43,17 @@ smallvec.workspace = true
 smol.workspace = true
 telemetry_events.workspace = true
 text.workspace = true
-theme.workspace = true
 ui.workspace = true
 util.workspace = true
 uuid.workspace = true
 workspace-hack.workspace = true
 workspace.workspace = true
-zed_actions.workspace = true
+zed_llm_client.workspace = true
 
 [dev-dependencies]
+indoc.workspace = true
 language_model = { workspace = true, features = ["test-support"] }
-languages = { workspace = true, features = ["test-support"] }
 pretty_assertions.workspace = true
 rand.workspace = true
-tree-sitter-md.workspace = true
 unindent.workspace = true
 workspace = { workspace = true, features = ["test-support"] }

crates/assistant_context_editor/src/context.rs → crates/assistant_context/src/assistant_context.rs 🔗

@@ -1,17 +1,18 @@
 #[cfg(test)]
-mod context_tests;
+mod assistant_context_tests;
+mod context_store;
 
-use anyhow::{Context as _, Result, anyhow, bail};
-use assistant_settings::AssistantSettings;
+use agent_settings::AgentSettings;
+use anyhow::{Context as _, Result, bail};
 use assistant_slash_command::{
     SlashCommandContent, SlashCommandEvent, SlashCommandLine, SlashCommandOutputSection,
     SlashCommandResult, SlashCommandWorkingSet,
 };
 use assistant_slash_commands::FileCommandMetadata;
-use client::{self, proto, telemetry::Telemetry};
+use client::{self, Client, proto, telemetry::Telemetry};
 use clock::ReplicaId;
 use collections::{HashMap, HashSet};
-use fs::{Fs, RemoveOptions};
+use fs::{Fs, RenameOptions};
 use futures::{FutureExt, StreamExt, future::Shared};
 use gpui::{
     App, AppContext as _, Context, Entity, EventEmitter, RenderImage, SharedString, Subscription,
@@ -21,14 +22,15 @@ use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, P
 use language_model::{
     LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionEvent,
     LanguageModelImage, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
-    LanguageModelToolUseId, MaxMonthlySpendReachedError, MessageContent, PaymentRequiredError,
-    Role, StopReason, report_assistant_event,
+    LanguageModelToolUseId, MessageContent, PaymentRequiredError, Role, StopReason,
+    report_assistant_event,
 };
 use open_ai::Model as OpenAiModel;
 use paths::contexts_dir;
 use project::Project;
 use prompt_store::PromptBuilder;
 use serde::{Deserialize, Serialize};
+use settings::Settings;
 use smallvec::SmallVec;
 use std::{
     cmp::{Ordering, max},
@@ -44,6 +46,13 @@ use text::{BufferSnapshot, ToPoint};
 use ui::IconName;
 use util::{ResultExt, TryFutureExt, post_inc};
 use uuid::Uuid;
+use zed_llm_client::CompletionIntent;
+
+pub use crate::context_store::*;
+
+pub fn init(client: Arc<Client>, _: &mut App) {
+    context_store::init(&client.into());
+}
 
 #[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct ContextId(String);
@@ -447,10 +456,13 @@ impl ContextOperation {
 pub enum ContextEvent {
     ShowAssistError(SharedString),
     ShowPaymentRequiredError,
-    ShowMaxMonthlySpendReachedError,
     MessagesEdited,
     SummaryChanged,
     SummaryGenerated,
+    PathChanged {
+        old_path: Option<Arc<Path>>,
+        new_path: Arc<Path>,
+    },
     StreamedCompletion,
     StartedThoughtProcess(Range<language::Anchor>),
     EndedThoughtProcess(language::Anchor),
@@ -673,7 +685,7 @@ pub struct AssistantContext {
     summary_task: Task<Option<()>>,
     completion_count: usize,
     pending_completions: Vec<PendingCompletion>,
-    token_count: Option<usize>,
+    token_count: Option<u64>,
     pending_token_count: Task<Option<()>>,
     pending_save: Task<Result<()>>,
     pending_cache_warming_task: Task<Option<()>>,
@@ -683,6 +695,7 @@ pub struct AssistantContext {
     language_registry: Arc<LanguageRegistry>,
     project: Option<Entity<Project>>,
     prompt_builder: Arc<PromptBuilder>,
+    completion_mode: agent_settings::CompletionMode,
 }
 
 trait ContextAnnotation {
@@ -719,6 +732,14 @@ impl AssistantContext {
         )
     }
 
+    pub fn completion_mode(&self) -> agent_settings::CompletionMode {
+        self.completion_mode
+    }
+
+    pub fn set_completion_mode(&mut self, completion_mode: agent_settings::CompletionMode) {
+        self.completion_mode = completion_mode;
+    }
+
     pub fn new(
         id: ContextId,
         replica_id: ReplicaId,
@@ -765,6 +786,7 @@ impl AssistantContext {
             pending_cache_warming_task: Task::ready(None),
             _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
             pending_save: Task::ready(Ok(())),
+            completion_mode: AgentSettings::get_global(cx).preferred_completion_mode,
             path: None,
             buffer,
             telemetry,
@@ -1235,7 +1257,7 @@ impl AssistantContext {
         }
     }
 
-    pub fn token_count(&self) -> Option<usize> {
+    pub fn token_count(&self) -> Option<u64> {
         self.token_count
     }
 
@@ -1731,9 +1753,8 @@ impl AssistantContext {
                                 merge_same_roles,
                             } => {
                                 if !merge_same_roles && Some(role) != last_role {
-                                    let offset = this.buffer.read_with(cx, |buffer, _cx| {
-                                        insert_position.to_offset(buffer)
-                                    });
+                                    let buffer = this.buffer.read(cx);
+                                    let offset = insert_position.to_offset(buffer);
                                     this.insert_message_at_offset(
                                         offset,
                                         role,
@@ -2096,6 +2117,7 @@ impl AssistantContext {
                                             );
                                         }
                                     }
+                                    LanguageModelCompletionEvent::RedactedThinking { .. } => {},
                                     LanguageModelCompletionEvent::Text(mut chunk) => {
                                         if let Some(start) = thought_process_stack.pop() {
                                             let end = buffer.anchor_before(message_old_end_offset);
@@ -2155,12 +2177,6 @@ impl AssistantContext {
                                 metadata.status = MessageStatus::Canceled;
                             });
                             Some(error.to_string())
-                        } else if error.is::<MaxMonthlySpendReachedError>() {
-                            cx.emit(ContextEvent::ShowMaxMonthlySpendReachedError);
-                            this.update_metadata(assistant_message_id, cx, |metadata| {
-                                metadata.status = MessageStatus::Canceled;
-                            });
-                            Some(error.to_string())
                         } else {
                             let error_message = error
                                 .chain()
@@ -2211,6 +2227,7 @@ impl AssistantContext {
                             StopReason::ToolUse => {}
                             StopReason::EndTurn => {}
                             StopReason::MaxTokens => {}
+                            StopReason::Refusal => {}
                         }
                     }
                 })
@@ -2268,13 +2285,13 @@ impl AssistantContext {
         let mut completion_request = LanguageModelRequest {
             thread_id: None,
             prompt_id: None,
+            intent: Some(CompletionIntent::UserPrompt),
             mode: None,
             messages: Vec::new(),
             tools: Vec::new(),
             tool_choice: None,
             stop: Vec::new(),
-            temperature: model
-                .and_then(|model| AssistantSettings::temperature_for_model(model, cx)),
+            temperature: model.and_then(|model| AgentSettings::temperature_for_model(model, cx)),
         };
         for message in self.messages(cx) {
             if message.status != MessageStatus::Done {
@@ -2329,7 +2346,15 @@ impl AssistantContext {
                 completion_request.messages.push(request_message);
             }
         }
+        let supports_burn_mode = if let Some(model) = model {
+            model.supports_burn_mode()
+        } else {
+            false
+        };
 
+        if supports_burn_mode {
+            completion_request.mode = Some(self.completion_mode.into());
+        }
         completion_request
     }
 
@@ -2498,6 +2523,12 @@ impl AssistantContext {
             }
 
             let message = start_message;
+            let at_end = range.end >= message.offset_range.end.saturating_sub(1);
+            let role_after = if range.start == range.end || at_end {
+                Role::User
+            } else {
+                message.role
+            };
             let role = message.role;
             let mut edited_buffer = false;
 
@@ -2532,7 +2563,7 @@ impl AssistantContext {
             };
 
             let suffix_metadata = MessageMetadata {
-                role,
+                role: role_after,
                 status: MessageStatus::Done,
                 timestamp: suffix.id.0,
                 cache: None,
@@ -2881,22 +2912,34 @@ impl AssistantContext {
                 }
 
                 fs.create_dir(contexts_dir().as_ref()).await?;
-                fs.atomic_write(new_path.clone(), serde_json::to_string(&context).unwrap())
-                    .await?;
-                if let Some(old_path) = old_path {
+
+                // rename before write ensures that only one file exists
+                if let Some(old_path) = old_path.as_ref() {
                     if new_path.as_path() != old_path.as_ref() {
-                        fs.remove_file(
+                        fs.rename(
                             &old_path,
-                            RemoveOptions {
-                                recursive: false,
-                                ignore_if_not_exists: true,
+                            &new_path,
+                            RenameOptions {
+                                overwrite: true,
+                                ignore_if_exists: true,
                             },
                         )
                         .await?;
                     }
                 }
 
-                this.update(cx, |this, _| this.path = Some(new_path.into()))?;
+                // update path before write in case it fails
+                this.update(cx, {
+                    let new_path: Arc<Path> = new_path.clone().into();
+                    move |this, cx| {
+                        this.path = Some(new_path.clone());
+                        cx.emit(ContextEvent::PathChanged { old_path, new_path });
+                    }
+                })
+                .ok();
+
+                fs.atomic_write(new_path, serde_json::to_string(&context).unwrap())
+                    .await?;
             }
 
             Ok(())
@@ -3018,7 +3061,7 @@ impl SavedContext {
         let saved_context_json = serde_json::from_str::<serde_json::Value>(json)?;
         match saved_context_json
             .get("version")
-            .ok_or_else(|| anyhow!("version not found"))?
+            .context("version not found")?
         {
             serde_json::Value::String(version) => match version.as_str() {
                 SavedContext::VERSION => {
@@ -3039,9 +3082,9 @@ impl SavedContext {
                         serde_json::from_value::<SavedContextV0_1_0>(saved_context_json)?;
                     Ok(saved_context.upgrade())
                 }
-                _ => Err(anyhow!("unrecognized saved context version: {}", version)),
+                _ => anyhow::bail!("unrecognized saved context version: {version:?}"),
             },
-            _ => Err(anyhow!("version not found on saved context")),
+            _ => anyhow::bail!("version not found on saved context"),
         }
     }
 
@@ -3264,7 +3307,7 @@ impl SavedContextV0_1_0 {
 
 #[derive(Debug, Clone)]
 pub struct SavedContextMetadata {
-    pub title: String,
+    pub title: SharedString,
     pub path: Arc<Path>,
     pub mtime: chrono::DateTime<chrono::Local>,
 }

crates/assistant_context_editor/src/context/context_tests.rs → crates/assistant_context/src/assistant_context_tests.rs 🔗

@@ -1210,8 +1210,8 @@ async fn test_summarization(cx: &mut TestAppContext) {
     });
 
     cx.run_until_parked();
-    fake_model.stream_last_completion_response("Brief".into());
-    fake_model.stream_last_completion_response(" Introduction".into());
+    fake_model.stream_last_completion_response("Brief");
+    fake_model.stream_last_completion_response(" Introduction");
     fake_model.end_last_completion_stream();
     cx.run_until_parked();
 
@@ -1274,7 +1274,7 @@ async fn test_thread_summary_error_retry(cx: &mut TestAppContext) {
     });
 
     cx.run_until_parked();
-    fake_model.stream_last_completion_response("A successful summary".into());
+    fake_model.stream_last_completion_response("A successful summary");
     fake_model.end_last_completion_stream();
     cx.run_until_parked();
 
@@ -1356,7 +1356,7 @@ fn setup_context_editor_with_fake_model(
 
 fn simulate_successful_response(fake_model: &FakeLanguageModel, cx: &mut TestAppContext) {
     cx.run_until_parked();
-    fake_model.stream_last_completion_response("Assistant response".into());
+    fake_model.stream_last_completion_response("Assistant response");
     fake_model.end_last_completion_stream();
     cx.run_until_parked();
 }
@@ -1386,7 +1386,7 @@ fn init_test(cx: &mut App) {
     LanguageModelRegistry::test(cx);
     cx.set_global(settings_store);
     language::init(cx);
-    assistant_settings::init(cx);
+    agent_settings::init(cx);
     Project::init_settings(cx);
 }
 

crates/assistant_context_editor/src/context_store.rs → crates/assistant_context/src/context_store.rs 🔗

@@ -2,7 +2,7 @@ use crate::{
     AssistantContext, ContextEvent, ContextId, ContextOperation, ContextVersion, SavedContext,
     SavedContextMetadata,
 };
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use assistant_slash_command::{SlashCommandId, SlashCommandWorkingSet};
 use client::{Client, TypedEnvelope, proto, telemetry::Telemetry};
 use clock::ReplicaId;
@@ -164,16 +164,18 @@ impl ContextStore {
     ) -> Result<proto::OpenContextResponse> {
         let context_id = ContextId::from_proto(envelope.payload.context_id);
         let operations = this.update(&mut cx, |this, cx| {
-            if this.project.read(cx).is_via_collab() {
-                return Err(anyhow!("only the host contexts can be opened"));
-            }
+            anyhow::ensure!(
+                !this.project.read(cx).is_via_collab(),
+                "only the host contexts can be opened"
+            );
 
             let context = this
                 .loaded_context_for_id(&context_id, cx)
                 .context("context not found")?;
-            if context.read(cx).replica_id() != ReplicaId::default() {
-                return Err(anyhow!("context must be opened via the host"));
-            }
+            anyhow::ensure!(
+                context.read(cx).replica_id() == ReplicaId::default(),
+                "context must be opened via the host"
+            );
 
             anyhow::Ok(
                 context
@@ -193,9 +195,10 @@ impl ContextStore {
         mut cx: AsyncApp,
     ) -> Result<proto::CreateContextResponse> {
         let (context_id, operations) = this.update(&mut cx, |this, cx| {
-            if this.project.read(cx).is_via_collab() {
-                return Err(anyhow!("can only create contexts as the host"));
-            }
+            anyhow::ensure!(
+                !this.project.read(cx).is_via_collab(),
+                "can only create contexts as the host"
+            );
 
             let context = this.create(cx);
             let context_id = context.read(cx).id().clone();
@@ -237,9 +240,10 @@ impl ContextStore {
         mut cx: AsyncApp,
     ) -> Result<proto::SynchronizeContextsResponse> {
         this.update(&mut cx, |this, cx| {
-            if this.project.read(cx).is_via_collab() {
-                return Err(anyhow!("only the host can synchronize contexts"));
-            }
+            anyhow::ensure!(
+                !this.project.read(cx).is_via_collab(),
+                "only the host can synchronize contexts"
+            );
 
             let mut local_versions = Vec::new();
             for remote_version_proto in envelope.payload.contexts {
@@ -343,12 +347,6 @@ impl ContextStore {
         self.contexts_metadata.iter()
     }
 
-    pub fn reverse_chronological_contexts(&self) -> Vec<SavedContextMetadata> {
-        let mut contexts = self.contexts_metadata.iter().cloned().collect::<Vec<_>>();
-        contexts.sort_unstable_by_key(|thread| std::cmp::Reverse(thread.mtime));
-        contexts
-    }
-
     pub fn create(&mut self, cx: &mut Context<Self>) -> Entity<AssistantContext> {
         let context = cx.new(|cx| {
             AssistantContext::local(
@@ -370,7 +368,7 @@ impl ContextStore {
     ) -> Task<Result<Entity<AssistantContext>>> {
         let project = self.project.read(cx);
         let Some(project_id) = project.remote_id() else {
-            return Task::ready(Err(anyhow!("project was not remote")));
+            return Task::ready(Err(anyhow::anyhow!("project was not remote")));
         };
 
         let replica_id = project.replica_id();
@@ -533,7 +531,7 @@ impl ContextStore {
     ) -> Task<Result<Entity<AssistantContext>>> {
         let project = self.project.read(cx);
         let Some(project_id) = project.remote_id() else {
-            return Task::ready(Err(anyhow!("project was not remote")));
+            return Task::ready(Err(anyhow::anyhow!("project was not remote")));
         };
 
         if let Some(context) = self.loaded_context_for_id(&context_id, cx) {
@@ -614,6 +612,16 @@ impl ContextStore {
             ContextEvent::SummaryChanged => {
                 self.advertise_contexts(cx);
             }
+            ContextEvent::PathChanged { old_path, new_path } => {
+                if let Some(old_path) = old_path.as_ref() {
+                    for metadata in &mut self.contexts_metadata {
+                        if &metadata.path == old_path {
+                            metadata.path = new_path.clone();
+                            break;
+                        }
+                    }
+                }
+            }
             ContextEvent::Operation(operation) => {
                 let context_id = context.read(cx).id().to_proto();
                 let operation = operation.to_proto();
@@ -737,6 +745,7 @@ impl ContextStore {
                     &candidates,
                     &query,
                     false,
+                    true,
                     100,
                     &Default::default(),
                     executor,
@@ -788,7 +797,7 @@ impl ContextStore {
                         .next()
                     {
                         contexts.push(SavedContextMetadata {
-                            title: title.to_string(),
+                            title: title.to_string().into(),
                             path: path.into(),
                             mtime: metadata.mtime.timestamp_for_user().into(),
                         });
@@ -805,74 +814,37 @@ impl ContextStore {
     }
 
     fn register_context_server_handlers(&self, cx: &mut Context<Self>) {
-        cx.subscribe(
-            &self.project.read(cx).context_server_store(),
-            Self::handle_context_server_event,
-        )
-        .detach();
+        let context_server_store = self.project.read(cx).context_server_store();
+        cx.subscribe(&context_server_store, Self::handle_context_server_event)
+            .detach();
+
+        // Check for any servers that were already running before the handler was registered
+        for server in context_server_store.read(cx).running_servers() {
+            self.load_context_server_slash_commands(server.id(), context_server_store.clone(), cx);
+        }
     }
 
     fn handle_context_server_event(
         &mut self,
-        context_server_manager: Entity<ContextServerStore>,
+        context_server_store: Entity<ContextServerStore>,
         event: &project::context_server_store::Event,
         cx: &mut Context<Self>,
     ) {
-        let slash_command_working_set = self.slash_commands.clone();
         match event {
             project::context_server_store::Event::ServerStatusChanged { server_id, status } => {
                 match status {
                     ContextServerStatus::Running => {
-                        if let Some(server) = context_server_manager
-                            .read(cx)
-                            .get_running_server(server_id)
-                        {
-                            let context_server_manager = context_server_manager.clone();
-                            cx.spawn({
-                                let server = server.clone();
-                                let server_id = server_id.clone();
-                                async move |this, cx| {
-                                    let Some(protocol) = server.client() else {
-                                        return;
-                                    };
-
-                                    if protocol.capable(context_server::protocol::ServerCapability::Prompts) {
-                                        if let Some(prompts) = protocol.list_prompts().await.log_err() {
-                                            let slash_command_ids = prompts
-                                                .into_iter()
-                                                .filter(assistant_slash_commands::acceptable_prompt)
-                                                .map(|prompt| {
-                                                    log::info!(
-                                                        "registering context server command: {:?}",
-                                                        prompt.name
-                                                    );
-                                                    slash_command_working_set.insert(Arc::new(
-                                                        assistant_slash_commands::ContextServerSlashCommand::new(
-                                                            context_server_manager.clone(),
-                                                            server.id(),
-                                                            prompt,
-                                                        ),
-                                                    ))
-                                                })
-                                                .collect::<Vec<_>>();
-
-                                            this.update( cx, |this, _cx| {
-                                                this.context_server_slash_command_ids
-                                                    .insert(server_id.clone(), slash_command_ids);
-                                            })
-                                            .log_err();
-                                        }
-                                    }
-                                }
-                            })
-                            .detach();
-                        }
+                        self.load_context_server_slash_commands(
+                            server_id.clone(),
+                            context_server_store.clone(),
+                            cx,
+                        );
                     }
                     ContextServerStatus::Stopped | ContextServerStatus::Error(_) => {
                         if let Some(slash_command_ids) =
                             self.context_server_slash_command_ids.remove(server_id)
                         {
-                            slash_command_working_set.remove(&slash_command_ids);
+                            self.slash_commands.remove(&slash_command_ids);
                         }
                     }
                     _ => {}
@@ -880,4 +852,52 @@ impl ContextStore {
             }
         }
     }
+
+    fn load_context_server_slash_commands(
+        &self,
+        server_id: ContextServerId,
+        context_server_store: Entity<ContextServerStore>,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(server) = context_server_store.read(cx).get_running_server(&server_id) else {
+            return;
+        };
+        let slash_command_working_set = self.slash_commands.clone();
+        cx.spawn(async move |this, cx| {
+            let Some(protocol) = server.client() else {
+                return;
+            };
+
+            if protocol.capable(context_server::protocol::ServerCapability::Prompts) {
+                if let Some(response) = protocol
+                    .request::<context_server::types::requests::PromptsList>(())
+                    .await
+                    .log_err()
+                {
+                    let slash_command_ids = response
+                        .prompts
+                        .into_iter()
+                        .filter(assistant_slash_commands::acceptable_prompt)
+                        .map(|prompt| {
+                            log::info!("registering context server command: {:?}", prompt.name);
+                            slash_command_working_set.insert(Arc::new(
+                                assistant_slash_commands::ContextServerSlashCommand::new(
+                                    context_server_store.clone(),
+                                    server.id(),
+                                    prompt,
+                                ),
+                            ))
+                        })
+                        .collect::<Vec<_>>();
+
+                    this.update(cx, |this, _cx| {
+                        this.context_server_slash_command_ids
+                            .insert(server_id.clone(), slash_command_ids);
+                    })
+                    .log_err();
+                }
+            }
+        })
+        .detach();
+    }
 }

crates/assistant_context_editor/src/assistant_context_editor.rs 🔗

@@ -1,34 +0,0 @@
-mod context;
-mod context_editor;
-mod context_history;
-mod context_store;
-mod slash_command;
-mod slash_command_picker;
-
-use std::sync::Arc;
-
-use client::Client;
-use gpui::{App, Context};
-use workspace::Workspace;
-
-pub use crate::context::*;
-pub use crate::context_editor::*;
-pub use crate::context_history::*;
-pub use crate::context_store::*;
-pub use crate::slash_command::*;
-
-pub fn init(client: Arc<Client>, cx: &mut App) {
-    context_store::init(&client.into());
-    workspace::FollowableViewRegistry::register::<ContextEditor>(cx);
-
-    cx.observe_new(
-        |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
-            workspace
-                .register_action(ContextEditor::quote_selection)
-                .register_action(ContextEditor::insert_selection)
-                .register_action(ContextEditor::copy_code)
-                .register_action(ContextEditor::handle_insert_dragged_files);
-        },
-    )
-    .detach();
-}

crates/assistant_context_editor/src/context_history.rs 🔗

@@ -1,271 +0,0 @@
-use std::sync::Arc;
-
-use gpui::{App, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity};
-use picker::{Picker, PickerDelegate};
-use project::Project;
-use ui::utils::{DateTimeType, format_distance_from_now};
-use ui::{Avatar, ListItem, ListItemSpacing, prelude::*};
-use workspace::{Item, Workspace};
-
-use crate::{
-    AgentPanelDelegate, ContextStore, DEFAULT_TAB_TITLE, RemoteContextMetadata,
-    SavedContextMetadata,
-};
-
-#[derive(Clone)]
-pub enum ContextMetadata {
-    Remote(RemoteContextMetadata),
-    Saved(SavedContextMetadata),
-}
-
-enum SavedContextPickerEvent {
-    Confirmed(ContextMetadata),
-}
-
-pub struct ContextHistory {
-    picker: Entity<Picker<SavedContextPickerDelegate>>,
-    _subscriptions: Vec<Subscription>,
-    workspace: WeakEntity<Workspace>,
-}
-
-impl ContextHistory {
-    pub fn new(
-        project: Entity<Project>,
-        context_store: Entity<ContextStore>,
-        workspace: WeakEntity<Workspace>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Self {
-        let picker = cx.new(|cx| {
-            Picker::uniform_list(
-                SavedContextPickerDelegate::new(project, context_store.clone()),
-                window,
-                cx,
-            )
-            .modal(false)
-            .max_height(None)
-        });
-
-        let subscriptions = vec![
-            cx.observe_in(&context_store, window, |this, _, window, cx| {
-                this.picker
-                    .update(cx, |picker, cx| picker.refresh(window, cx));
-            }),
-            cx.subscribe_in(&picker, window, Self::handle_picker_event),
-        ];
-
-        Self {
-            picker,
-            _subscriptions: subscriptions,
-            workspace,
-        }
-    }
-
-    fn handle_picker_event(
-        &mut self,
-        _: &Entity<Picker<SavedContextPickerDelegate>>,
-        event: &SavedContextPickerEvent,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let SavedContextPickerEvent::Confirmed(context) = event;
-
-        let Some(agent_panel_delegate) = <dyn AgentPanelDelegate>::try_global(cx) else {
-            return;
-        };
-
-        self.workspace
-            .update(cx, |workspace, cx| match context {
-                ContextMetadata::Remote(metadata) => {
-                    agent_panel_delegate
-                        .open_remote_context(workspace, metadata.id.clone(), window, cx)
-                        .detach_and_log_err(cx);
-                }
-                ContextMetadata::Saved(metadata) => {
-                    agent_panel_delegate
-                        .open_saved_context(workspace, metadata.path.clone(), window, cx)
-                        .detach_and_log_err(cx);
-                }
-            })
-            .ok();
-    }
-}
-
-impl Render for ContextHistory {
-    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
-        div().size_full().child(self.picker.clone())
-    }
-}
-
-impl Focusable for ContextHistory {
-    fn focus_handle(&self, cx: &App) -> FocusHandle {
-        self.picker.focus_handle(cx)
-    }
-}
-
-impl EventEmitter<()> for ContextHistory {}
-
-impl Item for ContextHistory {
-    type Event = ();
-
-    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
-        "History".into()
-    }
-}
-
-struct SavedContextPickerDelegate {
-    store: Entity<ContextStore>,
-    project: Entity<Project>,
-    matches: Vec<ContextMetadata>,
-    selected_index: usize,
-}
-
-impl EventEmitter<SavedContextPickerEvent> for Picker<SavedContextPickerDelegate> {}
-
-impl SavedContextPickerDelegate {
-    fn new(project: Entity<Project>, store: Entity<ContextStore>) -> Self {
-        Self {
-            project,
-            store,
-            matches: Vec::new(),
-            selected_index: 0,
-        }
-    }
-}
-
-impl PickerDelegate for SavedContextPickerDelegate {
-    type ListItem = ListItem;
-
-    fn match_count(&self) -> usize {
-        self.matches.len()
-    }
-
-    fn selected_index(&self) -> usize {
-        self.selected_index
-    }
-
-    fn set_selected_index(
-        &mut self,
-        ix: usize,
-        _window: &mut Window,
-        _cx: &mut Context<Picker<Self>>,
-    ) {
-        self.selected_index = ix;
-    }
-
-    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
-        "Search...".into()
-    }
-
-    fn update_matches(
-        &mut self,
-        query: String,
-        _window: &mut Window,
-        cx: &mut Context<Picker<Self>>,
-    ) -> Task<()> {
-        let search = self.store.read(cx).search(query, cx);
-        cx.spawn(async move |this, cx| {
-            let matches = search.await;
-            this.update(cx, |this, cx| {
-                let host_contexts = this.delegate.store.read(cx).host_contexts();
-                this.delegate.matches = host_contexts
-                    .iter()
-                    .cloned()
-                    .map(ContextMetadata::Remote)
-                    .chain(matches.into_iter().map(ContextMetadata::Saved))
-                    .collect();
-                this.delegate.selected_index = 0;
-                cx.notify();
-            })
-            .ok();
-        })
-    }
-
-    fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
-        if let Some(metadata) = self.matches.get(self.selected_index) {
-            cx.emit(SavedContextPickerEvent::Confirmed(metadata.clone()));
-        }
-    }
-
-    fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
-
-    fn render_match(
-        &self,
-        ix: usize,
-        selected: bool,
-        _window: &mut Window,
-        cx: &mut Context<Picker<Self>>,
-    ) -> Option<Self::ListItem> {
-        let context = self.matches.get(ix)?;
-        let item = match context {
-            ContextMetadata::Remote(context) => {
-                let host_user = self.project.read(cx).host().and_then(|collaborator| {
-                    self.project
-                        .read(cx)
-                        .user_store()
-                        .read(cx)
-                        .get_cached_user(collaborator.user_id)
-                });
-                div()
-                    .flex()
-                    .w_full()
-                    .justify_between()
-                    .gap_2()
-                    .child(
-                        h_flex().flex_1().overflow_x_hidden().child(
-                            Label::new(context.summary.clone().unwrap_or(DEFAULT_TAB_TITLE.into()))
-                                .size(LabelSize::Small),
-                        ),
-                    )
-                    .child(
-                        h_flex()
-                            .gap_2()
-                            .children(if let Some(host_user) = host_user {
-                                vec![
-                                    Avatar::new(host_user.avatar_uri.clone()).into_any_element(),
-                                    Label::new(format!("Shared by @{}", host_user.github_login))
-                                        .color(Color::Muted)
-                                        .size(LabelSize::Small)
-                                        .into_any_element(),
-                                ]
-                            } else {
-                                vec![
-                                    Label::new("Shared by host")
-                                        .color(Color::Muted)
-                                        .size(LabelSize::Small)
-                                        .into_any_element(),
-                                ]
-                            }),
-                    )
-            }
-            ContextMetadata::Saved(context) => div()
-                .flex()
-                .w_full()
-                .justify_between()
-                .gap_2()
-                .child(
-                    h_flex()
-                        .flex_1()
-                        .child(Label::new(context.title.clone()).size(LabelSize::Small))
-                        .overflow_x_hidden(),
-                )
-                .child(
-                    Label::new(format_distance_from_now(
-                        DateTimeType::Local(context.mtime),
-                        false,
-                        true,
-                        true,
-                    ))
-                    .color(Color::Muted)
-                    .size(LabelSize::Small),
-                ),
-        };
-        Some(
-            ListItem::new(ix)
-                .inset(true)
-                .spacing(ListItemSpacing::Sparse)
-                .toggle_state(selected)
-                .child(item),
-        )
-    }
-}

crates/assistant_settings/src/assistant_settings.rs 🔗

@@ -1,1074 +0,0 @@
-mod agent_profile;
-
-use std::sync::Arc;
-
-use ::open_ai::Model as OpenAiModel;
-use anthropic::Model as AnthropicModel;
-use anyhow::{Result, bail};
-use collections::IndexMap;
-use deepseek::Model as DeepseekModel;
-use gpui::{App, Pixels, SharedString};
-use language_model::{CloudModel, LanguageModel};
-use lmstudio::Model as LmStudioModel;
-use ollama::Model as OllamaModel;
-use schemars::{JsonSchema, schema::Schema};
-use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
-
-pub use crate::agent_profile::*;
-
-pub fn init(cx: &mut App) {
-    AssistantSettings::register(cx);
-}
-
-#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema)]
-#[serde(rename_all = "snake_case")]
-pub enum AssistantDockPosition {
-    Left,
-    #[default]
-    Right,
-    Bottom,
-}
-
-#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
-#[serde(rename_all = "snake_case")]
-pub enum NotifyWhenAgentWaiting {
-    #[default]
-    PrimaryScreen,
-    AllScreens,
-    Never,
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
-#[serde(tag = "name", rename_all = "snake_case")]
-pub enum AssistantProviderContentV1 {
-    #[serde(rename = "zed.dev")]
-    ZedDotDev { default_model: Option<CloudModel> },
-    #[serde(rename = "openai")]
-    OpenAi {
-        default_model: Option<OpenAiModel>,
-        api_url: Option<String>,
-        available_models: Option<Vec<OpenAiModel>>,
-    },
-    #[serde(rename = "anthropic")]
-    Anthropic {
-        default_model: Option<AnthropicModel>,
-        api_url: Option<String>,
-    },
-    #[serde(rename = "ollama")]
-    Ollama {
-        default_model: Option<OllamaModel>,
-        api_url: Option<String>,
-    },
-    #[serde(rename = "lmstudio")]
-    LmStudio {
-        default_model: Option<LmStudioModel>,
-        api_url: Option<String>,
-    },
-    #[serde(rename = "deepseek")]
-    DeepSeek {
-        default_model: Option<DeepseekModel>,
-        api_url: Option<String>,
-    },
-}
-
-#[derive(Default, Clone, Debug)]
-pub struct AssistantSettings {
-    pub enabled: bool,
-    pub button: bool,
-    pub dock: AssistantDockPosition,
-    pub default_width: Pixels,
-    pub default_height: Pixels,
-    pub default_model: LanguageModelSelection,
-    pub inline_assistant_model: Option<LanguageModelSelection>,
-    pub commit_message_model: Option<LanguageModelSelection>,
-    pub thread_summary_model: Option<LanguageModelSelection>,
-    pub inline_alternatives: Vec<LanguageModelSelection>,
-    pub using_outdated_settings_version: bool,
-    pub default_profile: AgentProfileId,
-    pub profiles: IndexMap<AgentProfileId, AgentProfile>,
-    pub always_allow_tool_actions: bool,
-    pub notify_when_agent_waiting: NotifyWhenAgentWaiting,
-    pub stream_edits: bool,
-    pub single_file_review: bool,
-    pub model_parameters: Vec<LanguageModelParameters>,
-    pub preferred_completion_mode: CompletionMode,
-}
-
-impl AssistantSettings {
-    pub fn temperature_for_model(model: &Arc<dyn LanguageModel>, cx: &App) -> Option<f32> {
-        let settings = Self::get_global(cx);
-        settings
-            .model_parameters
-            .iter()
-            .rfind(|setting| setting.matches(model))
-            .and_then(|m| m.temperature)
-    }
-
-    pub fn set_inline_assistant_model(&mut self, provider: String, model: String) {
-        self.inline_assistant_model = Some(LanguageModelSelection {
-            provider: provider.into(),
-            model,
-        });
-    }
-
-    pub fn set_commit_message_model(&mut self, provider: String, model: String) {
-        self.commit_message_model = Some(LanguageModelSelection {
-            provider: provider.into(),
-            model,
-        });
-    }
-
-    pub fn set_thread_summary_model(&mut self, provider: String, model: String) {
-        self.thread_summary_model = Some(LanguageModelSelection {
-            provider: provider.into(),
-            model,
-        });
-    }
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
-pub struct LanguageModelParameters {
-    pub provider: Option<LanguageModelProviderSetting>,
-    pub model: Option<SharedString>,
-    pub temperature: Option<f32>,
-}
-
-impl LanguageModelParameters {
-    pub fn matches(&self, model: &Arc<dyn LanguageModel>) -> bool {
-        if let Some(provider) = &self.provider {
-            if provider.0 != model.provider_id().0 {
-                return false;
-            }
-        }
-        if let Some(setting_model) = &self.model {
-            if *setting_model != model.id().0 {
-                return false;
-            }
-        }
-        true
-    }
-}
-
-/// Assistant panel settings
-#[derive(Clone, Serialize, Deserialize, Debug, Default)]
-pub struct AssistantSettingsContent {
-    #[serde(flatten)]
-    pub inner: Option<AssistantSettingsContentInner>,
-}
-
-#[derive(Clone, Serialize, Deserialize, Debug)]
-#[serde(untagged)]
-pub enum AssistantSettingsContentInner {
-    Versioned(Box<VersionedAssistantSettingsContent>),
-    Legacy(LegacyAssistantSettingsContent),
-}
-
-impl AssistantSettingsContentInner {
-    fn for_v2(content: AssistantSettingsContentV2) -> Self {
-        AssistantSettingsContentInner::Versioned(Box::new(VersionedAssistantSettingsContent::V2(
-            content,
-        )))
-    }
-}
-
-impl JsonSchema for AssistantSettingsContent {
-    fn schema_name() -> String {
-        VersionedAssistantSettingsContent::schema_name()
-    }
-
-    fn json_schema(r#gen: &mut schemars::r#gen::SchemaGenerator) -> Schema {
-        VersionedAssistantSettingsContent::json_schema(r#gen)
-    }
-
-    fn is_referenceable() -> bool {
-        VersionedAssistantSettingsContent::is_referenceable()
-    }
-}
-
-impl AssistantSettingsContent {
-    pub fn is_version_outdated(&self) -> bool {
-        match &self.inner {
-            Some(AssistantSettingsContentInner::Versioned(settings)) => match **settings {
-                VersionedAssistantSettingsContent::V1(_) => true,
-                VersionedAssistantSettingsContent::V2(_) => false,
-            },
-            Some(AssistantSettingsContentInner::Legacy(_)) => true,
-            None => false,
-        }
-    }
-
-    fn upgrade(&self) -> AssistantSettingsContentV2 {
-        match &self.inner {
-            Some(AssistantSettingsContentInner::Versioned(settings)) => match **settings {
-                VersionedAssistantSettingsContent::V1(ref settings) => AssistantSettingsContentV2 {
-                    enabled: settings.enabled,
-                    button: settings.button,
-                    dock: settings.dock,
-                    default_width: settings.default_width,
-                    default_height: settings.default_width,
-                    default_model: settings
-                        .provider
-                        .clone()
-                        .and_then(|provider| match provider {
-                            AssistantProviderContentV1::ZedDotDev { default_model } => {
-                                default_model.map(|model| LanguageModelSelection {
-                                    provider: "zed.dev".into(),
-                                    model: model.id().to_string(),
-                                })
-                            }
-                            AssistantProviderContentV1::OpenAi { default_model, .. } => {
-                                default_model.map(|model| LanguageModelSelection {
-                                    provider: "openai".into(),
-                                    model: model.id().to_string(),
-                                })
-                            }
-                            AssistantProviderContentV1::Anthropic { default_model, .. } => {
-                                default_model.map(|model| LanguageModelSelection {
-                                    provider: "anthropic".into(),
-                                    model: model.id().to_string(),
-                                })
-                            }
-                            AssistantProviderContentV1::Ollama { default_model, .. } => {
-                                default_model.map(|model| LanguageModelSelection {
-                                    provider: "ollama".into(),
-                                    model: model.id().to_string(),
-                                })
-                            }
-                            AssistantProviderContentV1::LmStudio { default_model, .. } => {
-                                default_model.map(|model| LanguageModelSelection {
-                                    provider: "lmstudio".into(),
-                                    model: model.id().to_string(),
-                                })
-                            }
-                            AssistantProviderContentV1::DeepSeek { default_model, .. } => {
-                                default_model.map(|model| LanguageModelSelection {
-                                    provider: "deepseek".into(),
-                                    model: model.id().to_string(),
-                                })
-                            }
-                        }),
-                    inline_assistant_model: None,
-                    commit_message_model: None,
-                    thread_summary_model: None,
-                    inline_alternatives: None,
-                    default_profile: None,
-                    profiles: None,
-                    always_allow_tool_actions: None,
-                    notify_when_agent_waiting: None,
-                    stream_edits: None,
-                    single_file_review: None,
-                    model_parameters: Vec::new(),
-                    preferred_completion_mode: None,
-                },
-                VersionedAssistantSettingsContent::V2(ref settings) => settings.clone(),
-            },
-            Some(AssistantSettingsContentInner::Legacy(settings)) => AssistantSettingsContentV2 {
-                enabled: None,
-                button: settings.button,
-                dock: settings.dock,
-                default_width: settings.default_width,
-                default_height: settings.default_height,
-                default_model: Some(LanguageModelSelection {
-                    provider: "openai".into(),
-                    model: settings
-                        .default_open_ai_model
-                        .clone()
-                        .unwrap_or_default()
-                        .id()
-                        .to_string(),
-                }),
-                inline_assistant_model: None,
-                commit_message_model: None,
-                thread_summary_model: None,
-                inline_alternatives: None,
-                default_profile: None,
-                profiles: None,
-                always_allow_tool_actions: None,
-                notify_when_agent_waiting: None,
-                stream_edits: None,
-                single_file_review: None,
-                model_parameters: Vec::new(),
-                preferred_completion_mode: None,
-            },
-            None => AssistantSettingsContentV2::default(),
-        }
-    }
-
-    pub fn set_dock(&mut self, dock: AssistantDockPosition) {
-        match &mut self.inner {
-            Some(AssistantSettingsContentInner::Versioned(settings)) => match **settings {
-                VersionedAssistantSettingsContent::V1(ref mut settings) => {
-                    settings.dock = Some(dock);
-                }
-                VersionedAssistantSettingsContent::V2(ref mut settings) => {
-                    settings.dock = Some(dock);
-                }
-            },
-            Some(AssistantSettingsContentInner::Legacy(settings)) => {
-                settings.dock = Some(dock);
-            }
-            None => {
-                self.inner = Some(AssistantSettingsContentInner::for_v2(
-                    AssistantSettingsContentV2 {
-                        dock: Some(dock),
-                        ..Default::default()
-                    },
-                ))
-            }
-        }
-    }
-
-    pub fn set_model(&mut self, language_model: Arc<dyn LanguageModel>) {
-        let model = language_model.id().0.to_string();
-        let provider = language_model.provider_id().0.to_string();
-
-        match &mut self.inner {
-            Some(AssistantSettingsContentInner::Versioned(settings)) => match **settings {
-                VersionedAssistantSettingsContent::V1(ref mut settings) => {
-                    match provider.as_ref() {
-                        "zed.dev" => {
-                            log::warn!("attempted to set zed.dev model on outdated settings");
-                        }
-                        "anthropic" => {
-                            let api_url = match &settings.provider {
-                                Some(AssistantProviderContentV1::Anthropic { api_url, .. }) => {
-                                    api_url.clone()
-                                }
-                                _ => None,
-                            };
-                            settings.provider = Some(AssistantProviderContentV1::Anthropic {
-                                default_model: AnthropicModel::from_id(&model).ok(),
-                                api_url,
-                            });
-                        }
-                        "ollama" => {
-                            let api_url = match &settings.provider {
-                                Some(AssistantProviderContentV1::Ollama { api_url, .. }) => {
-                                    api_url.clone()
-                                }
-                                _ => None,
-                            };
-                            settings.provider = Some(AssistantProviderContentV1::Ollama {
-                                default_model: Some(ollama::Model::new(
-                                    &model,
-                                    None,
-                                    None,
-                                    Some(language_model.supports_tools()),
-                                )),
-                                api_url,
-                            });
-                        }
-                        "lmstudio" => {
-                            let api_url = match &settings.provider {
-                                Some(AssistantProviderContentV1::LmStudio { api_url, .. }) => {
-                                    api_url.clone()
-                                }
-                                _ => None,
-                            };
-                            settings.provider = Some(AssistantProviderContentV1::LmStudio {
-                                default_model: Some(lmstudio::Model::new(&model, None, None)),
-                                api_url,
-                            });
-                        }
-                        "openai" => {
-                            let (api_url, available_models) = match &settings.provider {
-                                Some(AssistantProviderContentV1::OpenAi {
-                                    api_url,
-                                    available_models,
-                                    ..
-                                }) => (api_url.clone(), available_models.clone()),
-                                _ => (None, None),
-                            };
-                            settings.provider = Some(AssistantProviderContentV1::OpenAi {
-                                default_model: OpenAiModel::from_id(&model).ok(),
-                                api_url,
-                                available_models,
-                            });
-                        }
-                        "deepseek" => {
-                            let api_url = match &settings.provider {
-                                Some(AssistantProviderContentV1::DeepSeek { api_url, .. }) => {
-                                    api_url.clone()
-                                }
-                                _ => None,
-                            };
-                            settings.provider = Some(AssistantProviderContentV1::DeepSeek {
-                                default_model: DeepseekModel::from_id(&model).ok(),
-                                api_url,
-                            });
-                        }
-                        _ => {}
-                    }
-                }
-                VersionedAssistantSettingsContent::V2(ref mut settings) => {
-                    settings.default_model = Some(LanguageModelSelection {
-                        provider: provider.into(),
-                        model,
-                    });
-                }
-            },
-            Some(AssistantSettingsContentInner::Legacy(settings)) => {
-                if let Ok(model) = OpenAiModel::from_id(&language_model.id().0) {
-                    settings.default_open_ai_model = Some(model);
-                }
-            }
-            None => {
-                self.inner = Some(AssistantSettingsContentInner::for_v2(
-                    AssistantSettingsContentV2 {
-                        default_model: Some(LanguageModelSelection {
-                            provider: provider.into(),
-                            model,
-                        }),
-                        ..Default::default()
-                    },
-                ));
-            }
-        }
-    }
-
-    pub fn set_inline_assistant_model(&mut self, provider: String, model: String) {
-        self.v2_setting(|setting| {
-            setting.inline_assistant_model = Some(LanguageModelSelection {
-                provider: provider.into(),
-                model,
-            });
-            Ok(())
-        })
-        .ok();
-    }
-
-    pub fn set_commit_message_model(&mut self, provider: String, model: String) {
-        self.v2_setting(|setting| {
-            setting.commit_message_model = Some(LanguageModelSelection {
-                provider: provider.into(),
-                model,
-            });
-            Ok(())
-        })
-        .ok();
-    }
-
-    pub fn v2_setting(
-        &mut self,
-        f: impl FnOnce(&mut AssistantSettingsContentV2) -> anyhow::Result<()>,
-    ) -> anyhow::Result<()> {
-        match self.inner.get_or_insert_with(|| {
-            AssistantSettingsContentInner::for_v2(AssistantSettingsContentV2 {
-                ..Default::default()
-            })
-        }) {
-            AssistantSettingsContentInner::Versioned(boxed) => {
-                if let VersionedAssistantSettingsContent::V2(ref mut settings) = **boxed {
-                    f(settings)
-                } else {
-                    Ok(())
-                }
-            }
-            _ => Ok(()),
-        }
-    }
-
-    pub fn set_thread_summary_model(&mut self, provider: String, model: String) {
-        self.v2_setting(|setting| {
-            setting.thread_summary_model = Some(LanguageModelSelection {
-                provider: provider.into(),
-                model,
-            });
-            Ok(())
-        })
-        .ok();
-    }
-
-    pub fn set_always_allow_tool_actions(&mut self, allow: bool) {
-        self.v2_setting(|setting| {
-            setting.always_allow_tool_actions = Some(allow);
-            Ok(())
-        })
-        .ok();
-    }
-
-    pub fn set_single_file_review(&mut self, allow: bool) {
-        self.v2_setting(|setting| {
-            setting.single_file_review = Some(allow);
-            Ok(())
-        })
-        .ok();
-    }
-
-    pub fn set_profile(&mut self, profile_id: AgentProfileId) {
-        self.v2_setting(|setting| {
-            setting.default_profile = Some(profile_id);
-            Ok(())
-        })
-        .ok();
-    }
-
-    pub fn create_profile(
-        &mut self,
-        profile_id: AgentProfileId,
-        profile: AgentProfile,
-    ) -> Result<()> {
-        self.v2_setting(|settings| {
-            let profiles = settings.profiles.get_or_insert_default();
-            if profiles.contains_key(&profile_id) {
-                bail!("profile with ID '{profile_id}' already exists");
-            }
-
-            profiles.insert(
-                profile_id,
-                AgentProfileContent {
-                    name: profile.name.into(),
-                    tools: profile.tools,
-                    enable_all_context_servers: Some(profile.enable_all_context_servers),
-                    context_servers: profile
-                        .context_servers
-                        .into_iter()
-                        .map(|(server_id, preset)| {
-                            (
-                                server_id,
-                                ContextServerPresetContent {
-                                    tools: preset.tools,
-                                },
-                            )
-                        })
-                        .collect(),
-                },
-            );
-
-            Ok(())
-        })
-    }
-}
-
-#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)]
-#[serde(tag = "version")]
-pub enum VersionedAssistantSettingsContent {
-    #[serde(rename = "1")]
-    V1(AssistantSettingsContentV1),
-    #[serde(rename = "2")]
-    V2(AssistantSettingsContentV2),
-}
-
-impl Default for VersionedAssistantSettingsContent {
-    fn default() -> Self {
-        Self::V2(AssistantSettingsContentV2 {
-            enabled: None,
-            button: None,
-            dock: None,
-            default_width: None,
-            default_height: None,
-            default_model: None,
-            inline_assistant_model: None,
-            commit_message_model: None,
-            thread_summary_model: None,
-            inline_alternatives: None,
-            default_profile: None,
-            profiles: None,
-            always_allow_tool_actions: None,
-            notify_when_agent_waiting: None,
-            stream_edits: None,
-            single_file_review: None,
-            model_parameters: Vec::new(),
-            preferred_completion_mode: None,
-        })
-    }
-}
-
-#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug, Default)]
-pub struct AssistantSettingsContentV2 {
-    /// Whether the Assistant is enabled.
-    ///
-    /// Default: true
-    enabled: Option<bool>,
-    /// Whether to show the assistant panel button in the status bar.
-    ///
-    /// Default: true
-    button: Option<bool>,
-    /// Where to dock the assistant.
-    ///
-    /// Default: right
-    dock: Option<AssistantDockPosition>,
-    /// Default width in pixels when the assistant is docked to the left or right.
-    ///
-    /// Default: 640
-    default_width: Option<f32>,
-    /// Default height in pixels when the assistant is docked to the bottom.
-    ///
-    /// Default: 320
-    default_height: Option<f32>,
-    /// The default model to use when creating new chats and for other features when a specific model is not specified.
-    default_model: Option<LanguageModelSelection>,
-    /// Model to use for the inline assistant. Defaults to default_model when not specified.
-    inline_assistant_model: Option<LanguageModelSelection>,
-    /// Model to use for generating git commit messages. Defaults to default_model when not specified.
-    commit_message_model: Option<LanguageModelSelection>,
-    /// Model to use for generating thread summaries. Defaults to default_model when not specified.
-    thread_summary_model: Option<LanguageModelSelection>,
-    /// Additional models with which to generate alternatives when performing inline assists.
-    inline_alternatives: Option<Vec<LanguageModelSelection>>,
-    /// The default profile to use in the Agent.
-    ///
-    /// Default: write
-    default_profile: Option<AgentProfileId>,
-    /// The available agent profiles.
-    pub profiles: Option<IndexMap<AgentProfileId, AgentProfileContent>>,
-    /// Whenever a tool action would normally wait for your confirmation
-    /// that you allow it, always choose to allow it.
-    ///
-    /// Default: false
-    always_allow_tool_actions: Option<bool>,
-    /// Where to show a popup notification when the agent is waiting for user input.
-    ///
-    /// Default: "primary_screen"
-    notify_when_agent_waiting: Option<NotifyWhenAgentWaiting>,
-    /// Whether to stream edits from the agent as they are received.
-    ///
-    /// Default: false
-    stream_edits: Option<bool>,
-    /// Whether to display agent edits in single-file editors in addition to the review multibuffer pane.
-    ///
-    /// Default: true
-    single_file_review: Option<bool>,
-    /// Additional parameters for language model requests. When making a request
-    /// to a model, parameters will be taken from the last entry in this list
-    /// that matches the model's provider and name. In each entry, both provider
-    /// and model are optional, so that you can specify parameters for either
-    /// one.
-    ///
-    /// Default: []
-    #[serde(default)]
-    model_parameters: Vec<LanguageModelParameters>,
-
-    /// What completion mode to enable for new threads
-    ///
-    /// Default: normal
-    preferred_completion_mode: Option<CompletionMode>,
-}
-
-#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
-#[serde(rename_all = "snake_case")]
-pub enum CompletionMode {
-    #[default]
-    Normal,
-    Max,
-}
-
-impl From<CompletionMode> for zed_llm_client::CompletionMode {
-    fn from(value: CompletionMode) -> Self {
-        match value {
-            CompletionMode::Normal => zed_llm_client::CompletionMode::Normal,
-            CompletionMode::Max => zed_llm_client::CompletionMode::Max,
-        }
-    }
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
-pub struct LanguageModelSelection {
-    pub provider: LanguageModelProviderSetting,
-    pub model: String,
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
-pub struct LanguageModelProviderSetting(pub String);
-
-impl JsonSchema for LanguageModelProviderSetting {
-    fn schema_name() -> String {
-        "LanguageModelProviderSetting".into()
-    }
-
-    fn json_schema(_: &mut schemars::r#gen::SchemaGenerator) -> Schema {
-        schemars::schema::SchemaObject {
-            enum_values: Some(vec![
-                "anthropic".into(),
-                "bedrock".into(),
-                "google".into(),
-                "lmstudio".into(),
-                "ollama".into(),
-                "openai".into(),
-                "zed.dev".into(),
-                "copilot_chat".into(),
-                "deepseek".into(),
-            ]),
-            ..Default::default()
-        }
-        .into()
-    }
-}
-
-impl From<String> for LanguageModelProviderSetting {
-    fn from(provider: String) -> Self {
-        Self(provider)
-    }
-}
-
-impl From<&str> for LanguageModelProviderSetting {
-    fn from(provider: &str) -> Self {
-        Self(provider.to_string())
-    }
-}
-
-impl Default for LanguageModelSelection {
-    fn default() -> Self {
-        Self {
-            provider: LanguageModelProviderSetting("openai".to_string()),
-            model: "gpt-4".to_string(),
-        }
-    }
-}
-
-#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, JsonSchema)]
-pub struct AgentProfileContent {
-    pub name: Arc<str>,
-    #[serde(default)]
-    pub tools: IndexMap<Arc<str>, bool>,
-    /// Whether all context servers are enabled by default.
-    pub enable_all_context_servers: Option<bool>,
-    #[serde(default)]
-    pub context_servers: IndexMap<Arc<str>, ContextServerPresetContent>,
-}
-
-#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize, JsonSchema)]
-pub struct ContextServerPresetContent {
-    pub tools: IndexMap<Arc<str>, bool>,
-}
-
-#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)]
-pub struct AssistantSettingsContentV1 {
-    /// Whether the Assistant is enabled.
-    ///
-    /// Default: true
-    enabled: Option<bool>,
-    /// Whether to show the assistant panel button in the status bar.
-    ///
-    /// Default: true
-    button: Option<bool>,
-    /// Where to dock the assistant.
-    ///
-    /// Default: right
-    dock: Option<AssistantDockPosition>,
-    /// Default width in pixels when the assistant is docked to the left or right.
-    ///
-    /// Default: 640
-    default_width: Option<f32>,
-    /// Default height in pixels when the assistant is docked to the bottom.
-    ///
-    /// Default: 320
-    default_height: Option<f32>,
-    /// The provider of the assistant service.
-    ///
-    /// This can be "openai", "anthropic", "ollama", "lmstudio", "deepseek", "zed.dev"
-    /// each with their respective default models and configurations.
-    provider: Option<AssistantProviderContentV1>,
-}
-
-#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)]
-pub struct LegacyAssistantSettingsContent {
-    /// Whether to show the assistant panel button in the status bar.
-    ///
-    /// Default: true
-    pub button: Option<bool>,
-    /// Where to dock the assistant.
-    ///
-    /// Default: right
-    pub dock: Option<AssistantDockPosition>,
-    /// Default width in pixels when the assistant is docked to the left or right.
-    ///
-    /// Default: 640
-    pub default_width: Option<f32>,
-    /// Default height in pixels when the assistant is docked to the bottom.
-    ///
-    /// Default: 320
-    pub default_height: Option<f32>,
-    /// The default OpenAI model to use when creating new chats.
-    ///
-    /// Default: gpt-4-1106-preview
-    pub default_open_ai_model: Option<OpenAiModel>,
-    /// OpenAI API base URL to use when creating new chats.
-    ///
-    /// Default: <https://api.openai.com/v1>
-    pub openai_api_url: Option<String>,
-}
-
-impl Settings for AssistantSettings {
-    const KEY: Option<&'static str> = Some("agent");
-
-    const FALLBACK_KEY: Option<&'static str> = Some("assistant");
-
-    const PRESERVED_KEYS: Option<&'static [&'static str]> = Some(&["version"]);
-
-    type FileContent = AssistantSettingsContent;
-
-    fn load(
-        sources: SettingsSources<Self::FileContent>,
-        _: &mut gpui::App,
-    ) -> anyhow::Result<Self> {
-        let mut settings = AssistantSettings::default();
-
-        for value in sources.defaults_and_customizations() {
-            if value.is_version_outdated() {
-                settings.using_outdated_settings_version = true;
-            }
-
-            let value = value.upgrade();
-            merge(&mut settings.enabled, value.enabled);
-            merge(&mut settings.button, value.button);
-            merge(&mut settings.dock, value.dock);
-            merge(
-                &mut settings.default_width,
-                value.default_width.map(Into::into),
-            );
-            merge(
-                &mut settings.default_height,
-                value.default_height.map(Into::into),
-            );
-            merge(&mut settings.default_model, value.default_model);
-            settings.inline_assistant_model = value
-                .inline_assistant_model
-                .or(settings.inline_assistant_model.take());
-            settings.commit_message_model = value
-                .commit_message_model
-                .or(settings.commit_message_model.take());
-            settings.thread_summary_model = value
-                .thread_summary_model
-                .or(settings.thread_summary_model.take());
-            merge(&mut settings.inline_alternatives, value.inline_alternatives);
-            merge(
-                &mut settings.always_allow_tool_actions,
-                value.always_allow_tool_actions,
-            );
-            merge(
-                &mut settings.notify_when_agent_waiting,
-                value.notify_when_agent_waiting,
-            );
-            merge(&mut settings.stream_edits, value.stream_edits);
-            merge(&mut settings.single_file_review, value.single_file_review);
-            merge(&mut settings.default_profile, value.default_profile);
-            merge(
-                &mut settings.preferred_completion_mode,
-                value.preferred_completion_mode,
-            );
-
-            settings
-                .model_parameters
-                .extend_from_slice(&value.model_parameters);
-
-            if let Some(profiles) = value.profiles {
-                settings
-                    .profiles
-                    .extend(profiles.into_iter().map(|(id, profile)| {
-                        (
-                            id,
-                            AgentProfile {
-                                name: profile.name.into(),
-                                tools: profile.tools,
-                                enable_all_context_servers: profile
-                                    .enable_all_context_servers
-                                    .unwrap_or_default(),
-                                context_servers: profile
-                                    .context_servers
-                                    .into_iter()
-                                    .map(|(context_server_id, preset)| {
-                                        (
-                                            context_server_id,
-                                            ContextServerPreset {
-                                                tools: preset.tools.clone(),
-                                            },
-                                        )
-                                    })
-                                    .collect(),
-                            },
-                        )
-                    }));
-            }
-        }
-
-        Ok(settings)
-    }
-
-    fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) {
-        if let Some(b) = vscode
-            .read_value("chat.agent.enabled")
-            .and_then(|b| b.as_bool())
-        {
-            match &mut current.inner {
-                Some(AssistantSettingsContentInner::Versioned(versioned)) => {
-                    match versioned.as_mut() {
-                        VersionedAssistantSettingsContent::V1(setting) => {
-                            setting.enabled = Some(b);
-                            setting.button = Some(b);
-                        }
-
-                        VersionedAssistantSettingsContent::V2(setting) => {
-                            setting.enabled = Some(b);
-                            setting.button = Some(b);
-                        }
-                    }
-                }
-                Some(AssistantSettingsContentInner::Legacy(setting)) => setting.button = Some(b),
-                None => {
-                    current.inner = Some(AssistantSettingsContentInner::for_v2(
-                        AssistantSettingsContentV2 {
-                            enabled: Some(b),
-                            button: Some(b),
-                            ..Default::default()
-                        },
-                    ));
-                }
-            }
-        }
-    }
-}
-
-fn merge<T>(target: &mut T, value: Option<T>) {
-    if let Some(value) = value {
-        *target = value;
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use fs::Fs;
-    use gpui::{ReadGlobal, TestAppContext};
-    use settings::SettingsStore;
-
-    use super::*;
-
-    #[gpui::test]
-    async fn test_deserialize_assistant_settings_with_version(cx: &mut TestAppContext) {
-        let fs = fs::FakeFs::new(cx.executor().clone());
-        fs.create_dir(paths::settings_file().parent().unwrap())
-            .await
-            .unwrap();
-
-        cx.update(|cx| {
-            let test_settings = settings::SettingsStore::test(cx);
-            cx.set_global(test_settings);
-            AssistantSettings::register(cx);
-        });
-
-        cx.update(|cx| {
-            assert!(!AssistantSettings::get_global(cx).using_outdated_settings_version);
-            assert_eq!(
-                AssistantSettings::get_global(cx).default_model,
-                LanguageModelSelection {
-                    provider: "zed.dev".into(),
-                    model: "claude-3-7-sonnet-latest".into(),
-                }
-            );
-        });
-
-        cx.update(|cx| {
-            settings::SettingsStore::global(cx).update_settings_file::<AssistantSettings>(
-                fs.clone(),
-                |settings, _| {
-                    *settings = AssistantSettingsContent {
-                        inner: Some(AssistantSettingsContentInner::for_v2(
-                            AssistantSettingsContentV2 {
-                                default_model: Some(LanguageModelSelection {
-                                    provider: "test-provider".into(),
-                                    model: "gpt-99".into(),
-                                }),
-                                inline_assistant_model: None,
-                                commit_message_model: None,
-                                thread_summary_model: None,
-                                inline_alternatives: None,
-                                enabled: None,
-                                button: None,
-                                dock: None,
-                                default_width: None,
-                                default_height: None,
-                                default_profile: None,
-                                profiles: None,
-                                always_allow_tool_actions: None,
-                                notify_when_agent_waiting: None,
-                                stream_edits: None,
-                                single_file_review: None,
-                                model_parameters: Vec::new(),
-                                preferred_completion_mode: None,
-                            },
-                        )),
-                    }
-                },
-            );
-        });
-
-        cx.run_until_parked();
-
-        let raw_settings_value = fs.load(paths::settings_file()).await.unwrap();
-        assert!(raw_settings_value.contains(r#""version": "2""#));
-
-        #[derive(Debug, Deserialize)]
-        struct AssistantSettingsTest {
-            agent: AssistantSettingsContent,
-        }
-
-        let assistant_settings: AssistantSettingsTest =
-            serde_json_lenient::from_str(&raw_settings_value).unwrap();
-
-        assert!(!assistant_settings.agent.is_version_outdated());
-    }
-
-    #[gpui::test]
-    async fn test_load_settings_from_old_key(cx: &mut TestAppContext) {
-        let fs = fs::FakeFs::new(cx.executor().clone());
-        fs.create_dir(paths::settings_file().parent().unwrap())
-            .await
-            .unwrap();
-
-        cx.update(|cx| {
-            let mut test_settings = settings::SettingsStore::test(cx);
-            let user_settings_content = r#"{
-            "assistant": {
-                "enabled": true,
-                "version": "2",
-                "default_model": {
-                  "provider": "zed.dev",
-                  "model": "gpt-99"
-                },
-            }}"#;
-            test_settings
-                .set_user_settings(user_settings_content, cx)
-                .unwrap();
-            cx.set_global(test_settings);
-            AssistantSettings::register(cx);
-        });
-
-        cx.run_until_parked();
-
-        let assistant_settings = cx.update(|cx| AssistantSettings::get_global(cx).clone());
-        assert!(assistant_settings.enabled);
-        assert!(!assistant_settings.using_outdated_settings_version);
-        assert_eq!(assistant_settings.default_model.model, "gpt-99");
-
-        cx.update_global::<SettingsStore, _>(|settings_store, cx| {
-            settings_store.update_user_settings::<AssistantSettings>(cx, |settings| {
-                *settings = AssistantSettingsContent {
-                    inner: Some(AssistantSettingsContentInner::for_v2(
-                        AssistantSettingsContentV2 {
-                            enabled: Some(false),
-                            default_model: Some(LanguageModelSelection {
-                                provider: "xai".to_owned().into(),
-                                model: "grok".to_owned(),
-                            }),
-                            ..Default::default()
-                        },
-                    )),
-                };
-            });
-        });
-
-        cx.run_until_parked();
-
-        let settings = cx.update(|cx| SettingsStore::global(cx).raw_user_settings().clone());
-
-        #[derive(Debug, Deserialize)]
-        struct AssistantSettingsTest {
-            assistant: AssistantSettingsContent,
-            agent: Option<serde_json_lenient::Value>,
-        }
-
-        let assistant_settings: AssistantSettingsTest = serde_json::from_value(settings).unwrap();
-        assert!(assistant_settings.agent.is_none());
-    }
-}

crates/assistant_slash_command/src/assistant_slash_command.rs 🔗

@@ -9,6 +9,7 @@ use anyhow::Result;
 use futures::StreamExt;
 use futures::stream::{self, BoxStream};
 use gpui::{App, SharedString, Task, WeakEntity, Window};
+use language::HighlightId;
 use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate, OffsetRangeExt};
 pub use language_model::Role;
 use serde::{Deserialize, Serialize};
@@ -16,6 +17,7 @@ use std::{
     ops::Range,
     sync::{Arc, atomic::AtomicBool},
 };
+use ui::ActiveTheme;
 use workspace::{Workspace, ui::IconName};
 
 pub fn init(cx: &mut App) {
@@ -325,6 +327,18 @@ impl SlashCommandLine {
     }
 }
 
+pub fn create_label_for_command(command_name: &str, arguments: &[&str], cx: &App) -> CodeLabel {
+    let mut label = CodeLabel::default();
+    label.push_str(command_name, None);
+    label.push_str(" ", None);
+    label.push_str(
+        &arguments.join(" "),
+        cx.theme().syntax().highlight_id("comment").map(HighlightId),
+    );
+    label.filter_range = 0..command_name.len();
+    label
+}
+
 #[cfg(test)]
 mod tests {
     use pretty_assertions::assert_eq;

crates/assistant_slash_commands/Cargo.toml 🔗

@@ -35,7 +35,6 @@ rope.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 smol.workspace = true
-terminal_view.workspace = true
 text.workspace = true
 toml.workspace = true
 ui.workspace = true
@@ -45,6 +44,6 @@ worktree.workspace = true
 workspace-hack.workspace = true
 
 [dev-dependencies]
-env_logger.workspace = true
 pretty_assertions.workspace = true
 settings.workspace = true
+zlog.workspace = true

crates/assistant_slash_commands/src/assistant_slash_commands.rs 🔗

@@ -12,11 +12,6 @@ mod selection_command;
 mod streaming_example_command;
 mod symbols_command;
 mod tab_command;
-mod terminal_command;
-
-use gpui::App;
-use language::{CodeLabel, HighlightId};
-use ui::ActiveTheme as _;
 
 pub use crate::cargo_workspace_command::*;
 pub use crate::context_server_command::*;
@@ -32,16 +27,5 @@ pub use crate::selection_command::*;
 pub use crate::streaming_example_command::*;
 pub use crate::symbols_command::*;
 pub use crate::tab_command::*;
-pub use crate::terminal_command::*;
 
-pub fn create_label_for_command(command_name: &str, arguments: &[&str], cx: &App) -> CodeLabel {
-    let mut label = CodeLabel::default();
-    label.push_str(command_name, None);
-    label.push_str(" ", None);
-    label.push_str(
-        &arguments.join(" "),
-        cx.theme().syntax().highlight_id("comment").map(HighlightId),
-    );
-    label.filter_range = 0..command_name.len();
-    label
-}
+use assistant_slash_command::create_label_for_command;

crates/assistant_slash_commands/src/context_server_command.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use assistant_slash_command::{
     AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandOutput,
     SlashCommandOutputSection, SlashCommandResult,
@@ -84,24 +84,28 @@ impl SlashCommand for ContextServerSlashCommand {
 
         if let Some(server) = self.store.read(cx).get_running_server(&server_id) {
             cx.foreground_executor().spawn(async move {
-                let Some(protocol) = server.client() else {
-                    return Err(anyhow!("Context server not initialized"));
-                };
-
-                let completion_result = protocol
-                    .completion(
-                        context_server::types::CompletionReference::Prompt(
-                            context_server::types::PromptReference {
-                                r#type: context_server::types::PromptReferenceType::Prompt,
-                                name: prompt_name,
+                let protocol = server.client().context("Context server not initialized")?;
+
+                let response = protocol
+                    .request::<context_server::types::requests::CompletionComplete>(
+                        context_server::types::CompletionCompleteParams {
+                            reference: context_server::types::CompletionReference::Prompt(
+                                context_server::types::PromptReference {
+                                    ty: context_server::types::PromptReferenceType::Prompt,
+                                    name: prompt_name,
+                                },
+                            ),
+                            argument: context_server::types::CompletionArgument {
+                                name: arg_name,
+                                value: arg_value,
                             },
-                        ),
-                        arg_name,
-                        arg_value,
+                            meta: None,
+                        },
                     )
                     .await?;
 
-                let completions = completion_result
+                let completions = response
+                    .completion
                     .values
                     .into_iter()
                     .map(|value| ArgumentCompletion {
@@ -139,24 +143,27 @@ impl SlashCommand for ContextServerSlashCommand {
         let store = self.store.read(cx);
         if let Some(server) = store.get_running_server(&server_id) {
             cx.foreground_executor().spawn(async move {
-                let Some(protocol) = server.client() else {
-                    return Err(anyhow!("Context server not initialized"));
-                };
-                let result = protocol.run_prompt(&prompt_name, prompt_args).await?;
+                let protocol = server.client().context("Context server not initialized")?;
+                let response = protocol
+                    .request::<context_server::types::requests::PromptsGet>(
+                        context_server::types::PromptsGetParams {
+                            name: prompt_name.clone(),
+                            arguments: Some(prompt_args),
+                            meta: None,
+                        },
+                    )
+                    .await?;
 
-                // Check that there are only user roles
-                if result
-                    .messages
-                    .iter()
-                    .any(|msg| !matches!(msg.role, context_server::types::Role::User))
-                {
-                    return Err(anyhow!(
-                        "Prompt contains non-user roles, which is not supported"
-                    ));
-                }
+                anyhow::ensure!(
+                    response
+                        .messages
+                        .iter()
+                        .all(|msg| matches!(msg.role, context_server::types::Role::User)),
+                    "Prompt contains non-user roles, which is not supported"
+                );
 
                 // Extract text from user messages into a single prompt string
-                let mut prompt = result
+                let mut prompt = response
                     .messages
                     .into_iter()
                     .filter_map(|msg| match msg.content {
@@ -174,7 +181,7 @@ impl SlashCommand for ContextServerSlashCommand {
                         range: 0..(prompt.len()),
                         icon: IconName::ZedAssistant,
                         label: SharedString::from(
-                            result
+                            response
                                 .description
                                 .unwrap_or(format!("Result from {}", prompt_name)),
                         ),
@@ -192,9 +199,7 @@ impl SlashCommand for ContextServerSlashCommand {
 }
 
 fn completion_argument(prompt: &Prompt, arguments: &[String]) -> Result<(String, String)> {
-    if arguments.is_empty() {
-        return Err(anyhow!("No arguments given"));
-    }
+    anyhow::ensure!(!arguments.is_empty(), "No arguments given");
 
     match &prompt.arguments {
         Some(args) if args.len() == 1 => {
@@ -202,16 +207,16 @@ fn completion_argument(prompt: &Prompt, arguments: &[String]) -> Result<(String,
             let arg_value = arguments.join(" ");
             Ok((arg_name, arg_value))
         }
-        Some(_) => Err(anyhow!("Prompt must have exactly one argument")),
-        None => Err(anyhow!("Prompt has no arguments")),
+        Some(_) => anyhow::bail!("Prompt must have exactly one argument"),
+        None => anyhow::bail!("Prompt has no arguments"),
     }
 }
 
 fn prompt_arguments(prompt: &Prompt, arguments: &[String]) -> Result<HashMap<String, String>> {
     match &prompt.arguments {
-        Some(args) if args.len() > 1 => Err(anyhow!(
-            "Prompt has more than one argument, which is not supported"
-        )),
+        Some(args) if args.len() > 1 => {
+            anyhow::bail!("Prompt has more than one argument, which is not supported");
+        }
         Some(args) if args.len() == 1 => {
             if !arguments.is_empty() {
                 let mut map = HashMap::default();
@@ -220,15 +225,15 @@ fn prompt_arguments(prompt: &Prompt, arguments: &[String]) -> Result<HashMap<Str
             } else if arguments.is_empty() && args[0].required == Some(false) {
                 Ok(HashMap::default())
             } else {
-                Err(anyhow!("Prompt expects argument but none given"))
+                anyhow::bail!("Prompt expects argument but none given");
             }
         }
         Some(_) | None => {
-            if arguments.is_empty() {
-                Ok(HashMap::default())
-            } else {
-                Err(anyhow!("Prompt expects no arguments but some were given"))
-            }
+            anyhow::ensure!(
+                arguments.is_empty(),
+                "Prompt expects no arguments but some were given"
+            );
+            Ok(HashMap::default())
         }
     }
 }

crates/assistant_slash_commands/src/delta_command.rs 🔗

@@ -74,7 +74,7 @@ impl SlashCommand for DeltaSlashCommand {
                             .slice(section.range.to_offset(&context_buffer)),
                     );
                     file_command_new_outputs.push(Arc::new(FileSlashCommand).run(
-                        &[metadata.path.clone()],
+                        std::slice::from_ref(&metadata.path),
                         context_slash_command_output_sections,
                         context_buffer.clone(),
                         workspace.clone(),
@@ -118,10 +118,7 @@ impl SlashCommand for DeltaSlashCommand {
                 }
             }
 
-            if !changes_detected {
-                return Err(anyhow!("no new changes detected"));
-            }
-
+            anyhow::ensure!(changes_detected, "no new changes detected");
             Ok(output.to_event_stream())
         })
     }

crates/assistant_slash_commands/src/diagnostics_command.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use assistant_slash_command::{
     ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
     SlashCommandResult,
@@ -147,6 +147,7 @@ impl SlashCommand for DiagnosticsSlashCommand {
                     &Options::match_candidates_for_args(),
                     &query,
                     false,
+                    true,
                     10,
                     &cancellation_flag,
                     executor,
@@ -189,7 +190,7 @@ impl SlashCommand for DiagnosticsSlashCommand {
         window.spawn(cx, async move |_| {
             task.await?
                 .map(|output| output.to_event_stream())
-                .ok_or_else(|| anyhow!("No diagnostics found"))
+                .context("No diagnostics found")
         })
     }
 }

crates/assistant_slash_commands/src/docs_command.rs 🔗

@@ -3,7 +3,7 @@ use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
 use std::time::Duration;
 
-use anyhow::{Result, anyhow, bail};
+use anyhow::{Context as _, Result, anyhow, bail};
 use assistant_slash_command::{
     ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
     SlashCommandResult,
@@ -52,15 +52,16 @@ impl DocsSlashCommand {
             .is_none()
         {
             let index_provider_deps = maybe!({
-                let workspace = workspace.clone().ok_or_else(|| anyhow!("no workspace"))?;
                 let workspace = workspace
+                    .as_ref()
+                    .context("no workspace")?
                     .upgrade()
-                    .ok_or_else(|| anyhow!("workspace was dropped"))?;
+                    .context("workspace dropped")?;
                 let project = workspace.read(cx).project().clone();
                 let fs = project.read(cx).fs().clone();
                 let cargo_workspace_root = Self::path_to_cargo_toml(project, cx)
                     .and_then(|path| path.parent().map(|path| path.to_path_buf()))
-                    .ok_or_else(|| anyhow!("no Cargo workspace root found"))?;
+                    .context("no Cargo workspace root found")?;
 
                 anyhow::Ok((fs, cargo_workspace_root))
             });
@@ -78,10 +79,11 @@ impl DocsSlashCommand {
             .is_none()
         {
             let http_client = maybe!({
-                let workspace = workspace.ok_or_else(|| anyhow!("no workspace"))?;
                 let workspace = workspace
+                    .as_ref()
+                    .context("no workspace")?
                     .upgrade()
-                    .ok_or_else(|| anyhow!("workspace was dropped"))?;
+                    .context("workspace was dropped")?;
                 let project = workspace.read(cx).project().clone();
                 anyhow::Ok(project.read(cx).client().http_client())
             });
@@ -174,7 +176,7 @@ impl SlashCommand for DocsSlashCommand {
         let args = DocsSlashCommandArgs::parse(arguments);
         let store = args
             .provider()
-            .ok_or_else(|| anyhow!("no docs provider specified"))
+            .context("no docs provider specified")
             .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
         cx.background_spawn(async move {
             fn build_completions(items: Vec<String>) -> Vec<ArgumentCompletion> {
@@ -287,7 +289,7 @@ impl SlashCommand for DocsSlashCommand {
         let task = cx.background_spawn({
             let store = args
                 .provider()
-                .ok_or_else(|| anyhow!("no docs provider specified"))
+                .context("no docs provider specified")
                 .and_then(|provider| IndexedDocsStore::try_global(provider, cx));
             async move {
                 let (provider, key) = match args.clone() {

crates/assistant_slash_commands/src/fetch_command.rs 🔗

@@ -3,7 +3,7 @@ use std::rc::Rc;
 use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
 
-use anyhow::{Context, Result, anyhow, bail};
+use anyhow::{Context as _, Result, anyhow, bail};
 use assistant_slash_command::{
     ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
     SlashCommandResult,

crates/assistant_slash_commands/src/file_command.rs 🔗

@@ -230,7 +230,10 @@ fn collect_files(
         })
         .collect::<anyhow::Result<Vec<custom_path_matcher::PathMatcher>>>()
     else {
-        return futures::stream::once(async { Err(anyhow!("invalid path")) }).boxed();
+        return futures::stream::once(async {
+            anyhow::bail!("invalid path");
+        })
+        .boxed();
     };
 
     let project_handle = project.downgrade();
@@ -579,14 +582,12 @@ mod test {
     use serde_json::json;
     use settings::SettingsStore;
     use smol::stream::StreamExt;
-    use util::{path, separator};
+    use util::path;
 
     use super::collect_files;
 
     pub fn init_test(cx: &mut gpui::TestAppContext) {
-        if std::env::var("RUST_LOG").is_ok() {
-            env_logger::try_init().ok();
-        }
+        zlog::init_test();
 
         cx.update(|cx| {
             let settings_store = SettingsStore::test(cx);
@@ -626,7 +627,7 @@ mod test {
             .await
             .unwrap();
 
-        assert!(result_1.text.starts_with(separator!("root/dir")));
+        assert!(result_1.text.starts_with(path!("root/dir")));
         // 4 files + 2 directories
         assert_eq!(result_1.sections.len(), 6);
 
@@ -642,7 +643,7 @@ mod test {
             cx.update(|cx| collect_files(project.clone(), &["root/dir*".to_string()], cx).boxed());
         let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
 
-        assert!(result.text.starts_with(separator!("root/dir")));
+        assert!(result.text.starts_with(path!("root/dir")));
         // 5 files + 2 directories
         assert_eq!(result.sections.len(), 7);
 
@@ -690,24 +691,20 @@ mod test {
             .unwrap();
 
         // Sanity check
-        assert!(result.text.starts_with(separator!("zed/assets/themes\n")));
+        assert!(result.text.starts_with(path!("zed/assets/themes\n")));
         assert_eq!(result.sections.len(), 7);
 
         // Ensure that full file paths are included in the real output
         assert!(
             result
                 .text
-                .contains(separator!("zed/assets/themes/andromeda/LICENSE"))
+                .contains(path!("zed/assets/themes/andromeda/LICENSE"))
         );
+        assert!(result.text.contains(path!("zed/assets/themes/ayu/LICENSE")));
         assert!(
             result
                 .text
-                .contains(separator!("zed/assets/themes/ayu/LICENSE"))
-        );
-        assert!(
-            result
-                .text
-                .contains(separator!("zed/assets/themes/summercamp/LICENSE"))
+                .contains(path!("zed/assets/themes/summercamp/LICENSE"))
         );
 
         assert_eq!(result.sections[5].label, "summercamp");
@@ -715,17 +712,17 @@ mod test {
         // Ensure that things are in descending order, with properly relativized paths
         assert_eq!(
             result.sections[0].label,
-            separator!("zed/assets/themes/andromeda/LICENSE")
+            path!("zed/assets/themes/andromeda/LICENSE")
         );
         assert_eq!(result.sections[1].label, "andromeda");
         assert_eq!(
             result.sections[2].label,
-            separator!("zed/assets/themes/ayu/LICENSE")
+            path!("zed/assets/themes/ayu/LICENSE")
         );
         assert_eq!(result.sections[3].label, "ayu");
         assert_eq!(
             result.sections[4].label,
-            separator!("zed/assets/themes/summercamp/LICENSE")
+            path!("zed/assets/themes/summercamp/LICENSE")
         );
 
         // Ensure that the project lasts until after the last await
@@ -766,31 +763,28 @@ mod test {
             .await
             .unwrap();
 
-        assert!(result.text.starts_with(separator!("zed/assets/themes\n")));
-        assert_eq!(
-            result.sections[0].label,
-            separator!("zed/assets/themes/LICENSE")
-        );
+        assert!(result.text.starts_with(path!("zed/assets/themes\n")));
+        assert_eq!(result.sections[0].label, path!("zed/assets/themes/LICENSE"));
         assert_eq!(
             result.sections[1].label,
-            separator!("zed/assets/themes/summercamp/LICENSE")
+            path!("zed/assets/themes/summercamp/LICENSE")
         );
         assert_eq!(
             result.sections[2].label,
-            separator!("zed/assets/themes/summercamp/subdir/LICENSE")
+            path!("zed/assets/themes/summercamp/subdir/LICENSE")
         );
         assert_eq!(
             result.sections[3].label,
-            separator!("zed/assets/themes/summercamp/subdir/subsubdir/LICENSE")
+            path!("zed/assets/themes/summercamp/subdir/subsubdir/LICENSE")
         );
         assert_eq!(result.sections[4].label, "subsubdir");
         assert_eq!(result.sections[5].label, "subdir");
         assert_eq!(result.sections[6].label, "summercamp");
-        assert_eq!(result.sections[7].label, separator!("zed/assets/themes"));
+        assert_eq!(result.sections[7].label, path!("zed/assets/themes"));
 
         assert_eq!(
             result.text,
-            separator!(
+            path!(
                 "zed/assets/themes\n```zed/assets/themes/LICENSE\n1\n```\n\nsummercamp\n```zed/assets/themes/summercamp/LICENSE\n1\n```\n\nsubdir\n```zed/assets/themes/summercamp/subdir/LICENSE\n1\n```\n\nsubsubdir\n```zed/assets/themes/summercamp/subdir/subsubdir/LICENSE\n3\n```\n\n"
             )
         );

crates/assistant_tool/Cargo.toml 🔗

@@ -29,6 +29,7 @@ serde.workspace = true
 serde_json.workspace = true
 text.workspace = true
 util.workspace = true
+watch.workspace = true
 workspace.workspace = true
 workspace-hack.workspace = true
 
@@ -37,7 +38,6 @@ buffer_diff = { workspace = true, features = ["test-support"] }
 collections = { workspace = true, features = ["test-support"] }
 clock = { workspace = true, features = ["test-support"] }
 ctor.workspace = true
-env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
 language_model = { workspace = true, features = ["test-support"] }
@@ -48,3 +48,4 @@ rand.workspace = true
 settings = { workspace = true, features = ["test-support"] }
 text = { workspace = true, features = ["test-support"] }
 util = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/assistant_tool/src/action_log.rs 🔗

@@ -1,7 +1,7 @@
 use anyhow::{Context as _, Result};
 use buffer_diff::BufferDiff;
 use collections::BTreeMap;
-use futures::{StreamExt, channel::mpsc};
+use futures::{FutureExt, StreamExt, channel::mpsc};
 use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task, WeakEntity};
 use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint};
 use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle};
@@ -49,6 +49,37 @@ impl ActionLog {
         is_created: bool,
         cx: &mut Context<Self>,
     ) -> &mut TrackedBuffer {
+        let status = if is_created {
+            if let Some(tracked) = self.tracked_buffers.remove(&buffer) {
+                match tracked.status {
+                    TrackedBufferStatus::Created {
+                        existing_file_content,
+                    } => TrackedBufferStatus::Created {
+                        existing_file_content,
+                    },
+                    TrackedBufferStatus::Modified | TrackedBufferStatus::Deleted => {
+                        TrackedBufferStatus::Created {
+                            existing_file_content: Some(tracked.diff_base),
+                        }
+                    }
+                }
+            } else if buffer
+                .read(cx)
+                .file()
+                .map_or(false, |file| file.disk_state().exists())
+            {
+                TrackedBufferStatus::Created {
+                    existing_file_content: Some(buffer.read(cx).as_rope().clone()),
+                }
+            } else {
+                TrackedBufferStatus::Created {
+                    existing_file_content: None,
+                }
+            }
+        } else {
+            TrackedBufferStatus::Modified
+        };
+
         let tracked_buffer = self
             .tracked_buffers
             .entry(buffer.clone())
@@ -60,37 +91,22 @@ impl ActionLog {
                 let text_snapshot = buffer.read(cx).text_snapshot();
                 let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
                 let (diff_update_tx, diff_update_rx) = mpsc::unbounded();
-                let base_text;
-                let status;
-                let unreviewed_changes;
+                let diff_base;
+                let unreviewed_edits;
                 if is_created {
-                    let existing_file_content = if buffer
-                        .read(cx)
-                        .file()
-                        .map_or(false, |file| file.disk_state().exists())
-                    {
-                        Some(text_snapshot.as_rope().clone())
-                    } else {
-                        None
-                    };
-
-                    base_text = Rope::default();
-                    status = TrackedBufferStatus::Created {
-                        existing_file_content,
-                    };
-                    unreviewed_changes = Patch::new(vec![Edit {
+                    diff_base = Rope::default();
+                    unreviewed_edits = Patch::new(vec![Edit {
                         old: 0..1,
                         new: 0..text_snapshot.max_point().row + 1,
                     }])
                 } else {
-                    base_text = buffer.read(cx).as_rope().clone();
-                    status = TrackedBufferStatus::Modified;
-                    unreviewed_changes = Patch::default();
+                    diff_base = buffer.read(cx).as_rope().clone();
+                    unreviewed_edits = Patch::default();
                 }
                 TrackedBuffer {
                     buffer: buffer.clone(),
-                    base_text,
-                    unreviewed_changes,
+                    diff_base,
+                    unreviewed_edits: unreviewed_edits,
                     snapshot: text_snapshot.clone(),
                     status,
                     version: buffer.read(cx).version(),
@@ -159,7 +175,7 @@ impl ActionLog {
                     .map_or(false, |file| file.disk_state() != DiskState::Deleted)
                 {
                     // If the buffer had been deleted by a tool, but it got
-                    // resurrected externally, we want to clear the changes we
+                    // resurrected externally, we want to clear the edits we
                     // were tracking and reset the buffer's state.
                     self.tracked_buffers.remove(&buffer);
                     self.track_buffer_internal(buffer, false, cx);
@@ -172,122 +188,286 @@ impl ActionLog {
     async fn maintain_diff(
         this: WeakEntity<Self>,
         buffer: Entity<Buffer>,
-        mut diff_update: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>,
+        mut buffer_updates: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>,
         cx: &mut AsyncApp,
     ) -> Result<()> {
-        while let Some((author, buffer_snapshot)) = diff_update.next().await {
-            let (rebase, diff, language, language_registry) =
-                this.read_with(cx, |this, cx| {
-                    let tracked_buffer = this
-                        .tracked_buffers
-                        .get(&buffer)
-                        .context("buffer not tracked")?;
-
-                    let rebase = cx.background_spawn({
-                        let mut base_text = tracked_buffer.base_text.clone();
-                        let old_snapshot = tracked_buffer.snapshot.clone();
-                        let new_snapshot = buffer_snapshot.clone();
-                        let unreviewed_changes = tracked_buffer.unreviewed_changes.clone();
-                        async move {
-                            let edits = diff_snapshots(&old_snapshot, &new_snapshot);
-                            if let ChangeAuthor::User = author {
-                                apply_non_conflicting_edits(
-                                    &unreviewed_changes,
-                                    edits,
-                                    &mut base_text,
-                                    new_snapshot.as_rope(),
-                                );
+        let git_store = this.read_with(cx, |this, cx| this.project.read(cx).git_store().clone())?;
+        let git_diff = this
+            .update(cx, |this, cx| {
+                this.project.update(cx, |project, cx| {
+                    project.open_uncommitted_diff(buffer.clone(), cx)
+                })
+            })?
+            .await
+            .ok();
+        let buffer_repo = git_store.read_with(cx, |git_store, cx| {
+            git_store.repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx)
+        })?;
+
+        let (mut git_diff_updates_tx, mut git_diff_updates_rx) = watch::channel(());
+        let _repo_subscription =
+            if let Some((git_diff, (buffer_repo, _))) = git_diff.as_ref().zip(buffer_repo) {
+                cx.update(|cx| {
+                    let mut old_head = buffer_repo.read(cx).head_commit.clone();
+                    Some(cx.subscribe(git_diff, move |_, event, cx| match event {
+                        buffer_diff::BufferDiffEvent::DiffChanged { .. } => {
+                            let new_head = buffer_repo.read(cx).head_commit.clone();
+                            if new_head != old_head {
+                                old_head = new_head;
+                                git_diff_updates_tx.send(()).ok();
                             }
-                            (Arc::new(base_text.to_string()), base_text)
                         }
-                    });
+                        _ => {}
+                    }))
+                })?
+            } else {
+                None
+            };
+
+        loop {
+            futures::select_biased! {
+                buffer_update = buffer_updates.next() => {
+                    if let Some((author, buffer_snapshot)) = buffer_update {
+                        Self::track_edits(&this, &buffer, author, buffer_snapshot, cx).await?;
+                    } else {
+                        break;
+                    }
+                }
+                _ = git_diff_updates_rx.changed().fuse() => {
+                    if let Some(git_diff) = git_diff.as_ref() {
+                        Self::keep_committed_edits(&this, &buffer, &git_diff, cx).await?;
+                    }
+                }
+            }
+        }
 
-                    anyhow::Ok((
-                        rebase,
-                        tracked_buffer.diff.clone(),
-                        tracked_buffer.buffer.read(cx).language().cloned(),
-                        tracked_buffer.buffer.read(cx).language_registry(),
-                    ))
-                })??;
-
-            let (new_base_text, new_base_text_rope) = rebase.await;
-            let diff_snapshot = BufferDiff::update_diff(
-                diff.clone(),
-                buffer_snapshot.clone(),
-                Some(new_base_text),
-                true,
-                false,
-                language,
-                language_registry,
-                cx,
-            )
-            .await;
+        Ok(())
+    }
 
-            let mut unreviewed_changes = Patch::default();
-            if let Ok(diff_snapshot) = diff_snapshot {
-                unreviewed_changes = cx
-                    .background_spawn({
-                        let diff_snapshot = diff_snapshot.clone();
-                        let buffer_snapshot = buffer_snapshot.clone();
-                        let new_base_text_rope = new_base_text_rope.clone();
-                        async move {
-                            let mut unreviewed_changes = Patch::default();
-                            for hunk in diff_snapshot.hunks_intersecting_range(
-                                Anchor::MIN..Anchor::MAX,
-                                &buffer_snapshot,
-                            ) {
-                                let old_range = new_base_text_rope
-                                    .offset_to_point(hunk.diff_base_byte_range.start)
-                                    ..new_base_text_rope
-                                        .offset_to_point(hunk.diff_base_byte_range.end);
-                                let new_range = hunk.range.start..hunk.range.end;
-                                unreviewed_changes.push(point_to_row_edit(
-                                    Edit {
-                                        old: old_range,
-                                        new: new_range,
-                                    },
-                                    &new_base_text_rope,
-                                    &buffer_snapshot.as_rope(),
-                                ));
-                            }
-                            unreviewed_changes
-                        }
-                    })
-                    .await;
+    async fn track_edits(
+        this: &WeakEntity<ActionLog>,
+        buffer: &Entity<Buffer>,
+        author: ChangeAuthor,
+        buffer_snapshot: text::BufferSnapshot,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        let rebase = this.read_with(cx, |this, cx| {
+            let tracked_buffer = this
+                .tracked_buffers
+                .get(buffer)
+                .context("buffer not tracked")?;
+
+            let rebase = cx.background_spawn({
+                let mut base_text = tracked_buffer.diff_base.clone();
+                let old_snapshot = tracked_buffer.snapshot.clone();
+                let new_snapshot = buffer_snapshot.clone();
+                let unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
+                async move {
+                    let edits = diff_snapshots(&old_snapshot, &new_snapshot);
+                    if let ChangeAuthor::User = author {
+                        apply_non_conflicting_edits(
+                            &unreviewed_edits,
+                            edits,
+                            &mut base_text,
+                            new_snapshot.as_rope(),
+                        );
+                    }
+                    (Arc::new(base_text.to_string()), base_text)
+                }
+            });
 
-                diff.update(cx, |diff, cx| {
-                    diff.set_snapshot(diff_snapshot, &buffer_snapshot, cx)
-                })?;
-            }
-            this.update(cx, |this, cx| {
+            anyhow::Ok(rebase)
+        })??;
+        let (new_base_text, new_diff_base) = rebase.await;
+        Self::update_diff(
+            this,
+            buffer,
+            buffer_snapshot,
+            new_base_text,
+            new_diff_base,
+            cx,
+        )
+        .await
+    }
+
+    async fn keep_committed_edits(
+        this: &WeakEntity<ActionLog>,
+        buffer: &Entity<Buffer>,
+        git_diff: &Entity<BufferDiff>,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        let buffer_snapshot = this.read_with(cx, |this, _cx| {
+            let tracked_buffer = this
+                .tracked_buffers
+                .get(buffer)
+                .context("buffer not tracked")?;
+            anyhow::Ok(tracked_buffer.snapshot.clone())
+        })??;
+        let (new_base_text, new_diff_base) = this
+            .read_with(cx, |this, cx| {
                 let tracked_buffer = this
                     .tracked_buffers
-                    .get_mut(&buffer)
+                    .get(buffer)
                     .context("buffer not tracked")?;
-                tracked_buffer.base_text = new_base_text_rope;
-                tracked_buffer.snapshot = buffer_snapshot;
-                tracked_buffer.unreviewed_changes = unreviewed_changes;
-                cx.notify();
-                anyhow::Ok(())
-            })??;
-        }
+                let old_unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
+                let agent_diff_base = tracked_buffer.diff_base.clone();
+                let git_diff_base = git_diff.read(cx).base_text().as_rope().clone();
+                let buffer_text = tracked_buffer.snapshot.as_rope().clone();
+                anyhow::Ok(cx.background_spawn(async move {
+                    let mut old_unreviewed_edits = old_unreviewed_edits.into_iter().peekable();
+                    let committed_edits = language::line_diff(
+                        &agent_diff_base.to_string(),
+                        &git_diff_base.to_string(),
+                    )
+                    .into_iter()
+                    .map(|(old, new)| Edit { old, new });
+
+                    let mut new_agent_diff_base = agent_diff_base.clone();
+                    let mut row_delta = 0i32;
+                    for committed in committed_edits {
+                        while let Some(unreviewed) = old_unreviewed_edits.peek() {
+                            // If the committed edit matches the unreviewed
+                            // edit, assume the user wants to keep it.
+                            if committed.old == unreviewed.old {
+                                let unreviewed_new =
+                                    buffer_text.slice_rows(unreviewed.new.clone()).to_string();
+                                let committed_new =
+                                    git_diff_base.slice_rows(committed.new.clone()).to_string();
+                                if unreviewed_new == committed_new {
+                                    let old_byte_start =
+                                        new_agent_diff_base.point_to_offset(Point::new(
+                                            (unreviewed.old.start as i32 + row_delta) as u32,
+                                            0,
+                                        ));
+                                    let old_byte_end =
+                                        new_agent_diff_base.point_to_offset(cmp::min(
+                                            Point::new(
+                                                (unreviewed.old.end as i32 + row_delta) as u32,
+                                                0,
+                                            ),
+                                            new_agent_diff_base.max_point(),
+                                        ));
+                                    new_agent_diff_base
+                                        .replace(old_byte_start..old_byte_end, &unreviewed_new);
+                                    row_delta +=
+                                        unreviewed.new_len() as i32 - unreviewed.old_len() as i32;
+                                }
+                            } else if unreviewed.old.start >= committed.old.end {
+                                break;
+                            }
 
-        Ok(())
+                            old_unreviewed_edits.next().unwrap();
+                        }
+                    }
+
+                    (
+                        Arc::new(new_agent_diff_base.to_string()),
+                        new_agent_diff_base,
+                    )
+                }))
+            })??
+            .await;
+
+        Self::update_diff(
+            this,
+            buffer,
+            buffer_snapshot,
+            new_base_text,
+            new_diff_base,
+            cx,
+        )
+        .await
+    }
+
+    async fn update_diff(
+        this: &WeakEntity<ActionLog>,
+        buffer: &Entity<Buffer>,
+        buffer_snapshot: text::BufferSnapshot,
+        new_base_text: Arc<String>,
+        new_diff_base: Rope,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        let (diff, language, language_registry) = this.read_with(cx, |this, cx| {
+            let tracked_buffer = this
+                .tracked_buffers
+                .get(buffer)
+                .context("buffer not tracked")?;
+            anyhow::Ok((
+                tracked_buffer.diff.clone(),
+                buffer.read(cx).language().cloned(),
+                buffer.read(cx).language_registry().clone(),
+            ))
+        })??;
+        let diff_snapshot = BufferDiff::update_diff(
+            diff.clone(),
+            buffer_snapshot.clone(),
+            Some(new_base_text),
+            true,
+            false,
+            language,
+            language_registry,
+            cx,
+        )
+        .await;
+        let mut unreviewed_edits = Patch::default();
+        if let Ok(diff_snapshot) = diff_snapshot {
+            unreviewed_edits = cx
+                .background_spawn({
+                    let diff_snapshot = diff_snapshot.clone();
+                    let buffer_snapshot = buffer_snapshot.clone();
+                    let new_diff_base = new_diff_base.clone();
+                    async move {
+                        let mut unreviewed_edits = Patch::default();
+                        for hunk in diff_snapshot
+                            .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer_snapshot)
+                        {
+                            let old_range = new_diff_base
+                                .offset_to_point(hunk.diff_base_byte_range.start)
+                                ..new_diff_base.offset_to_point(hunk.diff_base_byte_range.end);
+                            let new_range = hunk.range.start..hunk.range.end;
+                            unreviewed_edits.push(point_to_row_edit(
+                                Edit {
+                                    old: old_range,
+                                    new: new_range,
+                                },
+                                &new_diff_base,
+                                &buffer_snapshot.as_rope(),
+                            ));
+                        }
+                        unreviewed_edits
+                    }
+                })
+                .await;
+
+            diff.update(cx, |diff, cx| {
+                diff.set_snapshot(diff_snapshot, &buffer_snapshot, cx);
+            })?;
+        }
+        this.update(cx, |this, cx| {
+            let tracked_buffer = this
+                .tracked_buffers
+                .get_mut(buffer)
+                .context("buffer not tracked")?;
+            tracked_buffer.diff_base = new_diff_base;
+            tracked_buffer.snapshot = buffer_snapshot;
+            tracked_buffer.unreviewed_edits = unreviewed_edits;
+            cx.notify();
+            anyhow::Ok(())
+        })?
     }
 
-    /// Track a buffer as read, so we can notify the model about user edits.
+    /// Track a buffer as read by agent, so we can notify the model about user edits.
     pub fn buffer_read(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
         self.track_buffer_internal(buffer, false, cx);
     }
 
-    /// Mark a buffer as edited, so we can refresh it in the context
+    /// Mark a buffer as created by agent, so we can refresh it in the context
     pub fn buffer_created(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
         self.edited_since_project_diagnostics_check = true;
-        self.tracked_buffers.remove(&buffer);
         self.track_buffer_internal(buffer.clone(), true, cx);
     }
 
-    /// Mark a buffer as edited, so we can refresh it in the context
+    /// Mark a buffer as edited by agent, so we can refresh it in the context
     pub fn buffer_edited(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
         self.edited_since_project_diagnostics_check = true;
 
@@ -336,7 +516,7 @@ impl ActionLog {
                     buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer);
                 let mut delta = 0i32;
 
-                tracked_buffer.unreviewed_changes.retain_mut(|edit| {
+                tracked_buffer.unreviewed_edits.retain_mut(|edit| {
                     edit.old.start = (edit.old.start as i32 + delta) as u32;
                     edit.old.end = (edit.old.end as i32 + delta) as u32;
 
@@ -346,11 +526,11 @@ impl ActionLog {
                         true
                     } else {
                         let old_range = tracked_buffer
-                            .base_text
+                            .diff_base
                             .point_to_offset(Point::new(edit.old.start, 0))
-                            ..tracked_buffer.base_text.point_to_offset(cmp::min(
+                            ..tracked_buffer.diff_base.point_to_offset(cmp::min(
                                 Point::new(edit.old.end, 0),
-                                tracked_buffer.base_text.max_point(),
+                                tracked_buffer.diff_base.max_point(),
                             ));
                         let new_range = tracked_buffer
                             .snapshot
@@ -359,7 +539,7 @@ impl ActionLog {
                                 Point::new(edit.new.end, 0),
                                 tracked_buffer.snapshot.max_point(),
                             ));
-                        tracked_buffer.base_text.replace(
+                        tracked_buffer.diff_base.replace(
                             old_range,
                             &tracked_buffer
                                 .snapshot
@@ -401,14 +581,38 @@ impl ActionLog {
                     self.project
                         .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
                 } else {
-                    buffer
-                        .read(cx)
-                        .entry_id(cx)
-                        .and_then(|entry_id| {
-                            self.project
-                                .update(cx, |project, cx| project.delete_entry(entry_id, false, cx))
-                        })
-                        .unwrap_or(Task::ready(Ok(())))
+                    // For a file created by AI with no pre-existing content,
+                    // only delete the file if we're certain it contains only AI content
+                    // with no edits from the user.
+
+                    let initial_version = tracked_buffer.version.clone();
+                    let current_version = buffer.read(cx).version();
+
+                    let current_content = buffer.read(cx).text();
+                    let tracked_content = tracked_buffer.snapshot.text();
+
+                    let is_ai_only_content =
+                        initial_version == current_version && current_content == tracked_content;
+
+                    if is_ai_only_content {
+                        buffer
+                            .read(cx)
+                            .entry_id(cx)
+                            .and_then(|entry_id| {
+                                self.project.update(cx, |project, cx| {
+                                    project.delete_entry(entry_id, false, cx)
+                                })
+                            })
+                            .unwrap_or(Task::ready(Ok(())))
+                    } else {
+                        // Not sure how to disentangle edits made by the user
+                        // from edits made by the AI at this point.
+                        // For now, preserve both to avoid data loss.
+                        //
+                        // TODO: Better solution (disable "Reject" after user makes some
+                        // edit or find a way to differentiate between AI and user edits)
+                        Task::ready(Ok(()))
+                    }
                 };
 
                 self.tracked_buffers.remove(&buffer);
@@ -417,13 +621,13 @@ impl ActionLog {
             }
             TrackedBufferStatus::Deleted => {
                 buffer.update(cx, |buffer, cx| {
-                    buffer.set_text(tracked_buffer.base_text.to_string(), cx)
+                    buffer.set_text(tracked_buffer.diff_base.to_string(), cx)
                 });
                 let save = self
                     .project
                     .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
 
-                // Clear all tracked changes for this buffer and start over as if we just read it.
+                // Clear all tracked edits for this buffer and start over as if we just read it.
                 self.tracked_buffers.remove(&buffer);
                 self.buffer_read(buffer.clone(), cx);
                 cx.notify();
@@ -439,7 +643,7 @@ impl ActionLog {
                         .peekable();
 
                     let mut edits_to_revert = Vec::new();
-                    for edit in tracked_buffer.unreviewed_changes.edits() {
+                    for edit in tracked_buffer.unreviewed_edits.edits() {
                         let new_range = tracked_buffer
                             .snapshot
                             .anchor_before(Point::new(edit.new.start, 0))
@@ -464,14 +668,14 @@ impl ActionLog {
 
                         if revert {
                             let old_range = tracked_buffer
-                                .base_text
+                                .diff_base
                                 .point_to_offset(Point::new(edit.old.start, 0))
-                                ..tracked_buffer.base_text.point_to_offset(cmp::min(
+                                ..tracked_buffer.diff_base.point_to_offset(cmp::min(
                                     Point::new(edit.old.end, 0),
-                                    tracked_buffer.base_text.max_point(),
+                                    tracked_buffer.diff_base.max_point(),
                                 ));
                             let old_text = tracked_buffer
-                                .base_text
+                                .diff_base
                                 .chunks_in_range(old_range)
                                 .collect::<String>();
                             edits_to_revert.push((new_range, old_text));
@@ -491,8 +695,8 @@ impl ActionLog {
             .retain(|_buffer, tracked_buffer| match tracked_buffer.status {
                 TrackedBufferStatus::Deleted => false,
                 _ => {
-                    tracked_buffer.unreviewed_changes.clear();
-                    tracked_buffer.base_text = tracked_buffer.snapshot.as_rope().clone();
+                    tracked_buffer.unreviewed_edits.clear();
+                    tracked_buffer.diff_base = tracked_buffer.snapshot.as_rope().clone();
                     tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
                     true
                 }
@@ -500,11 +704,11 @@ impl ActionLog {
         cx.notify();
     }
 
-    /// Returns the set of buffers that contain changes that haven't been reviewed by the user.
+    /// Returns the set of buffers that contain edits that haven't been reviewed by the user.
     pub fn changed_buffers(&self, cx: &App) -> BTreeMap<Entity<Buffer>, Entity<BufferDiff>> {
         self.tracked_buffers
             .iter()
-            .filter(|(_, tracked)| tracked.has_changes(cx))
+            .filter(|(_, tracked)| tracked.has_edits(cx))
             .map(|(buffer, tracked)| (buffer.clone(), tracked.diff.clone()))
             .collect()
     }
@@ -624,11 +828,7 @@ fn point_to_row_edit(edit: Edit<Point>, old_text: &Rope, new_text: &Rope) -> Edi
             old: edit.old.start.row + 1..edit.old.end.row + 1,
             new: edit.new.start.row + 1..edit.new.end.row + 1,
         }
-    } else if edit.old.start.column == 0
-        && edit.old.end.column == 0
-        && edit.new.end.column == 0
-        && edit.old.end != old_text.max_point()
-    {
+    } else if edit.old.start.column == 0 && edit.old.end.column == 0 && edit.new.end.column == 0 {
         Edit {
             old: edit.old.start.row..edit.old.end.row,
             new: edit.new.start.row..edit.new.end.row,
@@ -655,8 +855,8 @@ enum TrackedBufferStatus {
 
 struct TrackedBuffer {
     buffer: Entity<Buffer>,
-    base_text: Rope,
-    unreviewed_changes: Patch<u32>,
+    diff_base: Rope,
+    unreviewed_edits: Patch<u32>,
     status: TrackedBufferStatus,
     version: clock::Global,
     diff: Entity<BufferDiff>,
@@ -668,7 +868,7 @@ struct TrackedBuffer {
 }
 
 impl TrackedBuffer {
-    fn has_changes(&self, cx: &App) -> bool {
+    fn has_edits(&self, cx: &App) -> bool {
         self.diff
             .read(cx)
             .hunks(&self.buffer.read(cx), cx)
@@ -689,8 +889,6 @@ pub struct ChangedBuffer {
 
 #[cfg(test)]
 mod tests {
-    use std::env;
-
     use super::*;
     use buffer_diff::DiffHunkStatusKind;
     use gpui::TestAppContext;
@@ -699,13 +897,12 @@ mod tests {
     use rand::prelude::*;
     use serde_json::json;
     use settings::SettingsStore;
+    use std::env;
     use util::{RandomCharIter, path};
 
     #[ctor::ctor]
     fn init_logger() {
-        if std::env::var("RUST_LOG").is_ok() {
-            env_logger::init();
-        }
+        zlog::init_test();
     }
 
     fn init_test(cx: &mut TestAppContext) {
@@ -1094,6 +1291,86 @@ mod tests {
         );
     }
 
+    #[gpui::test(iterations = 10)]
+    async fn test_overwriting_previously_edited_files(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/dir"),
+            json!({
+                "file1": "Lorem ipsum dolor"
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let file_path = project
+            .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
+            .unwrap();
+
+        let buffer = project
+            .update(cx, |project, cx| project.open_buffer(file_path, cx))
+            .await
+            .unwrap();
+        cx.update(|cx| {
+            action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
+            buffer.update(cx, |buffer, cx| buffer.append(" sit amet consecteur", cx));
+            action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
+        });
+        project
+            .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
+            .await
+            .unwrap();
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer.clone(),
+                vec![HunkStatus {
+                    range: Point::new(0, 0)..Point::new(0, 37),
+                    diff_status: DiffHunkStatusKind::Modified,
+                    old_text: "Lorem ipsum dolor".into(),
+                }],
+            )]
+        );
+
+        cx.update(|cx| {
+            action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
+            buffer.update(cx, |buffer, cx| buffer.set_text("rewritten", cx));
+            action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
+        });
+        project
+            .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
+            .await
+            .unwrap();
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer.clone(),
+                vec![HunkStatus {
+                    range: Point::new(0, 0)..Point::new(0, 9),
+                    diff_status: DiffHunkStatusKind::Added,
+                    old_text: "".into(),
+                }],
+            )]
+        );
+
+        action_log
+            .update(cx, |log, cx| {
+                log.reject_edits_in_ranges(buffer.clone(), vec![2..5], cx)
+            })
+            .await
+            .unwrap();
+        cx.run_until_parked();
+        assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
+        assert_eq!(
+            buffer.read_with(cx, |buffer, _cx| buffer.text()),
+            "Lorem ipsum dolor"
+        );
+    }
+
     #[gpui::test(iterations = 10)]
     async fn test_deleting_files(cx: &mut TestAppContext) {
         init_test(cx);
@@ -1484,7 +1761,6 @@ mod tests {
                 project.find_project_path("dir/new_file", cx)
             })
             .unwrap();
-
         let buffer = project
             .update(cx, |project, cx| project.open_buffer(file_path, cx))
             .await
@@ -1527,6 +1803,72 @@ mod tests {
         assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
     }
 
+    #[gpui::test]
+    async fn test_reject_created_file_with_user_edits(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+
+        let file_path = project
+            .read_with(cx, |project, cx| {
+                project.find_project_path("dir/new_file", cx)
+            })
+            .unwrap();
+        let buffer = project
+            .update(cx, |project, cx| project.open_buffer(file_path, cx))
+            .await
+            .unwrap();
+
+        // AI creates file with initial content
+        cx.update(|cx| {
+            action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
+            buffer.update(cx, |buffer, cx| buffer.set_text("ai content", cx));
+            action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
+        });
+
+        project
+            .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
+            .await
+            .unwrap();
+
+        cx.run_until_parked();
+
+        // User makes additional edits
+        cx.update(|cx| {
+            buffer.update(cx, |buffer, cx| {
+                buffer.edit([(10..10, "\nuser added this line")], None, cx);
+            });
+        });
+
+        project
+            .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
+            .await
+            .unwrap();
+
+        assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
+
+        // Reject all
+        action_log
+            .update(cx, |log, cx| {
+                log.reject_edits_in_ranges(
+                    buffer.clone(),
+                    vec![Point::new(0, 0)..Point::new(100, 0)],
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+        cx.run_until_parked();
+
+        // File should still contain all the content
+        assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
+
+        let content = buffer.read_with(cx, |buffer, _| buffer.text());
+        assert_eq!(content, "ai content\nuser added this line");
+    }
+
     #[gpui::test(iterations = 100)]
     async fn test_random_diffs(mut rng: StdRng, cx: &mut TestAppContext) {
         init_test(cx);
@@ -1570,15 +1912,15 @@ mod tests {
                         .unwrap();
                 }
                 _ => {
-                    let is_agent_change = rng.gen_bool(0.5);
-                    if is_agent_change {
+                    let is_agent_edit = rng.gen_bool(0.5);
+                    if is_agent_edit {
                         log::info!("agent edit");
                     } else {
                         log::info!("user edit");
                     }
                     cx.update(|cx| {
                         buffer.update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx));
-                        if is_agent_change {
+                        if is_agent_edit {
                             action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
                         }
                     });
@@ -1601,9 +1943,9 @@ mod tests {
             cx.run_until_parked();
             action_log.update(cx, |log, cx| {
                 let tracked_buffer = log.tracked_buffers.get(&buffer).unwrap();
-                let mut old_text = tracked_buffer.base_text.clone();
+                let mut old_text = tracked_buffer.diff_base.clone();
                 let new_text = buffer.read(cx).as_rope();
-                for edit in tracked_buffer.unreviewed_changes.edits() {
+                for edit in tracked_buffer.unreviewed_edits.edits() {
                     let old_start = old_text.point_to_offset(Point::new(edit.new.start, 0));
                     let old_end = old_text.point_to_offset(cmp::min(
                         Point::new(edit.new.start + edit.old_len(), 0),
@@ -1619,6 +1961,171 @@ mod tests {
         }
     }
 
+    #[gpui::test]
+    async fn test_keep_edits_on_commit(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "file.txt": "a\nb\nc\nd\ne\nf\ng\nh\ni\nj",
+            }),
+        )
+        .await;
+        fs.set_head_for_repo(
+            path!("/project/.git").as_ref(),
+            &[("file.txt".into(), "a\nb\nc\nd\ne\nf\ng\nh\ni\nj".into())],
+            "0000000",
+        );
+        cx.run_until_parked();
+
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+
+        let file_path = project
+            .read_with(cx, |project, cx| {
+                project.find_project_path(path!("/project/file.txt"), cx)
+            })
+            .unwrap();
+        let buffer = project
+            .update(cx, |project, cx| project.open_buffer(file_path, cx))
+            .await
+            .unwrap();
+
+        cx.update(|cx| {
+            action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
+            buffer.update(cx, |buffer, cx| {
+                buffer.edit(
+                    [
+                        // Edit at the very start: a -> A
+                        (Point::new(0, 0)..Point::new(0, 1), "A"),
+                        // Deletion in the middle: remove lines d and e
+                        (Point::new(3, 0)..Point::new(5, 0), ""),
+                        // Modification: g -> GGG
+                        (Point::new(6, 0)..Point::new(6, 1), "GGG"),
+                        // Addition: insert new line after h
+                        (Point::new(7, 1)..Point::new(7, 1), "\nNEW"),
+                        // Edit the very last character: j -> J
+                        (Point::new(9, 0)..Point::new(9, 1), "J"),
+                    ],
+                    None,
+                    cx,
+                );
+            });
+            action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer.clone(),
+                vec![
+                    HunkStatus {
+                        range: Point::new(0, 0)..Point::new(1, 0),
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "a\n".into()
+                    },
+                    HunkStatus {
+                        range: Point::new(3, 0)..Point::new(3, 0),
+                        diff_status: DiffHunkStatusKind::Deleted,
+                        old_text: "d\ne\n".into()
+                    },
+                    HunkStatus {
+                        range: Point::new(4, 0)..Point::new(5, 0),
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "g\n".into()
+                    },
+                    HunkStatus {
+                        range: Point::new(6, 0)..Point::new(7, 0),
+                        diff_status: DiffHunkStatusKind::Added,
+                        old_text: "".into()
+                    },
+                    HunkStatus {
+                        range: Point::new(8, 0)..Point::new(8, 1),
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "j".into()
+                    }
+                ]
+            )]
+        );
+
+        // Simulate a git commit that matches some edits but not others:
+        // - Accepts the first edit (a -> A)
+        // - Accepts the deletion (remove d and e)
+        // - Makes a different change to g (g -> G instead of GGG)
+        // - Ignores the NEW line addition
+        // - Ignores the last line edit (j stays as j)
+        fs.set_head_for_repo(
+            path!("/project/.git").as_ref(),
+            &[("file.txt".into(), "A\nb\nc\nf\nG\nh\ni\nj".into())],
+            "0000001",
+        );
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer.clone(),
+                vec![
+                    HunkStatus {
+                        range: Point::new(4, 0)..Point::new(5, 0),
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "g\n".into()
+                    },
+                    HunkStatus {
+                        range: Point::new(6, 0)..Point::new(7, 0),
+                        diff_status: DiffHunkStatusKind::Added,
+                        old_text: "".into()
+                    },
+                    HunkStatus {
+                        range: Point::new(8, 0)..Point::new(8, 1),
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "j".into()
+                    }
+                ]
+            )]
+        );
+
+        // Make another commit that accepts the NEW line but with different content
+        fs.set_head_for_repo(
+            path!("/project/.git").as_ref(),
+            &[(
+                "file.txt".into(),
+                "A\nb\nc\nf\nGGG\nh\nDIFFERENT\ni\nj".into(),
+            )],
+            "0000002",
+        );
+        cx.run_until_parked();
+        assert_eq!(
+            unreviewed_hunks(&action_log, cx),
+            vec![(
+                buffer.clone(),
+                vec![
+                    HunkStatus {
+                        range: Point::new(6, 0)..Point::new(7, 0),
+                        diff_status: DiffHunkStatusKind::Added,
+                        old_text: "".into()
+                    },
+                    HunkStatus {
+                        range: Point::new(8, 0)..Point::new(8, 1),
+                        diff_status: DiffHunkStatusKind::Modified,
+                        old_text: "j".into()
+                    }
+                ]
+            )]
+        );
+
+        // Final commit that accepts all remaining edits
+        fs.set_head_for_repo(
+            path!("/project/.git").as_ref(),
+            &[("file.txt".into(), "A\nb\nc\nf\nGGG\nh\nNEW\ni\nJ".into())],
+            "0000003",
+        );
+        cx.run_until_parked();
+        assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
+    }
+
     #[derive(Debug, Clone, PartialEq, Eq)]
     struct HunkStatus {
         range: Range<Point>,

crates/assistant_tool/src/assistant_tool.rs 🔗

@@ -19,6 +19,7 @@ use gpui::Window;
 use gpui::{App, Entity, SharedString, Task, WeakEntity};
 use icons::IconName;
 use language_model::LanguageModel;
+use language_model::LanguageModelImage;
 use language_model::LanguageModelRequest;
 use language_model::LanguageModelToolSchemaFormat;
 use project::Project;
@@ -65,21 +66,50 @@ impl ToolUseStatus {
 
 #[derive(Debug)]
 pub struct ToolResultOutput {
-    pub content: String,
+    pub content: ToolResultContent,
     pub output: Option<serde_json::Value>,
 }
 
+#[derive(Debug, PartialEq, Eq)]
+pub enum ToolResultContent {
+    Text(String),
+    Image(LanguageModelImage),
+}
+
+impl ToolResultContent {
+    pub fn len(&self) -> usize {
+        match self {
+            ToolResultContent::Text(str) => str.len(),
+            ToolResultContent::Image(image) => image.len(),
+        }
+    }
+
+    pub fn is_empty(&self) -> bool {
+        match self {
+            ToolResultContent::Text(str) => str.is_empty(),
+            ToolResultContent::Image(image) => image.is_empty(),
+        }
+    }
+
+    pub fn as_str(&self) -> Option<&str> {
+        match self {
+            ToolResultContent::Text(str) => Some(str),
+            ToolResultContent::Image(_) => None,
+        }
+    }
+}
+
 impl From<String> for ToolResultOutput {
     fn from(value: String) -> Self {
         ToolResultOutput {
-            content: value,
+            content: ToolResultContent::Text(value),
             output: None,
         }
     }
 }
 
 impl Deref for ToolResultOutput {
-    type Target = String;
+    type Target = ToolResultContent;
 
     fn deref(&self) -> &Self::Target {
         &self.content
@@ -184,10 +214,13 @@ pub trait Tool: 'static + Send + Sync {
         ToolSource::Native
     }
 
-    /// Returns true iff the tool needs the users's confirmation
+    /// Returns true if the tool needs the users's confirmation
     /// before having permission to run.
     fn needs_confirmation(&self, input: &serde_json::Value, cx: &App) -> bool;
 
+    /// Returns true if the tool may perform edits.
+    fn may_perform_edits(&self) -> bool;
+
     /// Returns the JSON schema that describes the tool's input.
     fn input_schema(&self, _: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
         Ok(serde_json::Value::Object(serde_json::Map::default()))

crates/assistant_tool/src/outline.rs 🔗

@@ -1,5 +1,5 @@
 use crate::ActionLog;
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use gpui::{AsyncApp, Entity};
 use language::{OutlineItem, ParseStatus};
 use project::Project;
@@ -22,7 +22,7 @@ pub async fn file_outline(
         let project_path = project.read_with(cx, |project, cx| {
             project
                 .find_project_path(&path, cx)
-                .ok_or_else(|| anyhow!("Path {path} not found in project"))
+                .with_context(|| format!("Path {path} not found in project"))
         })??;
 
         project
@@ -41,9 +41,9 @@ pub async fn file_outline(
     }
 
     let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
-    let Some(outline) = snapshot.outline(None) else {
-        return Err(anyhow!("No outline information available for this file."));
-    };
+    let outline = snapshot
+        .outline(None)
+        .context("No outline information available for this file at path {path}")?;
 
     render_outline(
         outline

crates/assistant_tool/src/tool_schema.rs 🔗

@@ -16,34 +16,49 @@ pub fn adapt_schema_to_format(
     }
 
     match format {
-        LanguageModelToolSchemaFormat::JsonSchema => Ok(()),
+        LanguageModelToolSchemaFormat::JsonSchema => preprocess_json_schema(json),
         LanguageModelToolSchemaFormat::JsonSchemaSubset => adapt_to_json_schema_subset(json),
     }
 }
 
+fn preprocess_json_schema(json: &mut Value) -> Result<()> {
+    // `additionalProperties` defaults to `false` unless explicitly specified.
+    // This prevents models from hallucinating tool parameters.
+    if let Value::Object(obj) = json {
+        if let Some(Value::String(type_str)) = obj.get("type") {
+            if type_str == "object" && !obj.contains_key("additionalProperties") {
+                obj.insert("additionalProperties".to_string(), Value::Bool(false));
+            }
+        }
+    }
+    Ok(())
+}
+
 /// Tries to adapt the json schema so that it is compatible with https://ai.google.dev/api/caching#Schema
 fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> {
     if let Value::Object(obj) = json {
         const UNSUPPORTED_KEYS: [&str; 4] = ["if", "then", "else", "$ref"];
 
         for key in UNSUPPORTED_KEYS {
-            if obj.contains_key(key) {
-                return Err(anyhow::anyhow!(
-                    "Schema cannot be made compatible because it contains \"{}\" ",
-                    key
-                ));
-            }
+            anyhow::ensure!(
+                !obj.contains_key(key),
+                "Schema cannot be made compatible because it contains \"{key}\""
+            );
         }
 
-        const KEYS_TO_REMOVE: [&str; 5] = [
-            "format",
-            "additionalProperties",
-            "exclusiveMinimum",
-            "exclusiveMaximum",
-            "optional",
+        const KEYS_TO_REMOVE: [(&str, fn(&Value) -> bool); 5] = [
+            ("format", |value| value.is_string()),
+            ("additionalProperties", |value| value.is_boolean()),
+            ("exclusiveMinimum", |value| value.is_number()),
+            ("exclusiveMaximum", |value| value.is_number()),
+            ("optional", |value| value.is_boolean()),
         ];
-        for key in KEYS_TO_REMOVE {
-            obj.remove(key);
+        for (key, predicate) in KEYS_TO_REMOVE {
+            if let Some(value) = obj.get(key) {
+                if predicate(value) {
+                    obj.remove(key);
+                }
+            }
         }
 
         // If a type is not specified for an input parameter, add a default type
@@ -142,6 +157,24 @@ mod tests {
                 "type": "integer"
             })
         );
+
+        // Ensure that we do not remove keys that are actually supported (e.g. "format" can just be used as another property)
+        let mut json = json!({
+            "description": "A test field",
+            "type": "integer",
+            "format": {},
+        });
+
+        adapt_to_json_schema_subset(&mut json).unwrap();
+
+        assert_eq!(
+            json,
+            json!({
+                "description": "A test field",
+                "type": "integer",
+                "format": {},
+            })
+        );
     }
 
     #[test]
@@ -239,4 +272,59 @@ mod tests {
 
         assert!(adapt_to_json_schema_subset(&mut json).is_err());
     }
+
+    #[test]
+    fn test_preprocess_json_schema_adds_additional_properties() {
+        let mut json = json!({
+            "type": "object",
+            "properties": {
+                "name": {
+                    "type": "string"
+                }
+            }
+        });
+
+        preprocess_json_schema(&mut json).unwrap();
+
+        assert_eq!(
+            json,
+            json!({
+                "type": "object",
+                "properties": {
+                    "name": {
+                        "type": "string"
+                    }
+                },
+                "additionalProperties": false
+            })
+        );
+    }
+
+    #[test]
+    fn test_preprocess_json_schema_preserves_additional_properties() {
+        let mut json = json!({
+            "type": "object",
+            "properties": {
+                "name": {
+                    "type": "string"
+                }
+            },
+            "additionalProperties": true
+        });
+
+        preprocess_json_schema(&mut json).unwrap();
+
+        assert_eq!(
+            json,
+            json!({
+                "type": "object",
+                "properties": {
+                    "name": {
+                        "type": "string"
+                    }
+                },
+                "additionalProperties": true
+            })
+        );
+    }
 }

crates/assistant_tool/src/tool_working_set.rs 🔗

@@ -1,7 +1,7 @@
 use std::sync::Arc;
 
-use collections::{HashMap, HashSet, IndexMap};
-use gpui::{App, Context, EventEmitter};
+use collections::{HashMap, IndexMap};
+use gpui::App;
 
 use crate::{Tool, ToolRegistry, ToolSource};
 
@@ -13,17 +13,9 @@ pub struct ToolId(usize);
 pub struct ToolWorkingSet {
     context_server_tools_by_id: HashMap<ToolId, Arc<dyn Tool>>,
     context_server_tools_by_name: HashMap<String, Arc<dyn Tool>>,
-    enabled_sources: HashSet<ToolSource>,
-    enabled_tools_by_source: HashMap<ToolSource, HashSet<Arc<str>>>,
     next_tool_id: ToolId,
 }
 
-pub enum ToolWorkingSetEvent {
-    EnabledToolsChanged,
-}
-
-impl EventEmitter<ToolWorkingSetEvent> for ToolWorkingSet {}
-
 impl ToolWorkingSet {
     pub fn tool(&self, name: &str, cx: &App) -> Option<Arc<dyn Tool>> {
         self.context_server_tools_by_name
@@ -57,42 +49,6 @@ impl ToolWorkingSet {
         tools_by_source
     }
 
-    pub fn enabled_tools(&self, cx: &App) -> Vec<Arc<dyn Tool>> {
-        let all_tools = self.tools(cx);
-
-        all_tools
-            .into_iter()
-            .filter(|tool| self.is_enabled(&tool.source(), &tool.name().into()))
-            .collect()
-    }
-
-    pub fn disable_all_tools(&mut self, cx: &mut Context<Self>) {
-        self.enabled_tools_by_source.clear();
-        cx.emit(ToolWorkingSetEvent::EnabledToolsChanged);
-    }
-
-    pub fn enable_source(&mut self, source: ToolSource, cx: &mut Context<Self>) {
-        self.enabled_sources.insert(source.clone());
-
-        let tools_by_source = self.tools_by_source(cx);
-        if let Some(tools) = tools_by_source.get(&source) {
-            self.enabled_tools_by_source.insert(
-                source,
-                tools
-                    .into_iter()
-                    .map(|tool| tool.name().into())
-                    .collect::<HashSet<_>>(),
-            );
-        }
-        cx.emit(ToolWorkingSetEvent::EnabledToolsChanged);
-    }
-
-    pub fn disable_source(&mut self, source: &ToolSource, cx: &mut Context<Self>) {
-        self.enabled_sources.remove(source);
-        self.enabled_tools_by_source.remove(source);
-        cx.emit(ToolWorkingSetEvent::EnabledToolsChanged);
-    }
-
     pub fn insert(&mut self, tool: Arc<dyn Tool>) -> ToolId {
         let tool_id = self.next_tool_id;
         self.next_tool_id.0 += 1;
@@ -102,42 +58,6 @@ impl ToolWorkingSet {
         tool_id
     }
 
-    pub fn is_enabled(&self, source: &ToolSource, name: &Arc<str>) -> bool {
-        self.enabled_tools_by_source
-            .get(source)
-            .map_or(false, |enabled_tools| enabled_tools.contains(name))
-    }
-
-    pub fn is_disabled(&self, source: &ToolSource, name: &Arc<str>) -> bool {
-        !self.is_enabled(source, name)
-    }
-
-    pub fn enable(
-        &mut self,
-        source: ToolSource,
-        tools_to_enable: &[Arc<str>],
-        cx: &mut Context<Self>,
-    ) {
-        self.enabled_tools_by_source
-            .entry(source)
-            .or_default()
-            .extend(tools_to_enable.into_iter().cloned());
-        cx.emit(ToolWorkingSetEvent::EnabledToolsChanged);
-    }
-
-    pub fn disable(
-        &mut self,
-        source: ToolSource,
-        tools_to_disable: &[Arc<str>],
-        cx: &mut Context<Self>,
-    ) {
-        self.enabled_tools_by_source
-            .entry(source)
-            .or_default()
-            .retain(|name| !tools_to_disable.contains(name));
-        cx.emit(ToolWorkingSetEvent::EnabledToolsChanged);
-    }
-
     pub fn remove(&mut self, tool_ids_to_remove: &[ToolId]) {
         self.context_server_tools_by_id
             .retain(|id, _| !tool_ids_to_remove.contains(id));

crates/assistant_tools/Cargo.toml 🔗

@@ -15,9 +15,8 @@ path = "src/assistant_tools.rs"
 eval = []
 
 [dependencies]
-aho-corasick.workspace = true
+agent_settings.workspace = true
 anyhow.workspace = true
-assistant_settings.workspace = true
 assistant_tool.workspace = true
 buffer_diff.workspace = true
 chrono.workspace = true
@@ -35,13 +34,14 @@ indoc.workspace = true
 itertools.workspace = true
 language.workspace = true
 language_model.workspace = true
-linkme.workspace = true
 log.workspace = true
+lsp.workspace = true
 markdown.workspace = true
 open.workspace = true
 paths.workspace = true
 portable-pty.workspace = true
 project.workspace = true
+prompt_store.workspace = true
 regex.workspace = true
 rust-embed.workspace = true
 schemars.workspace = true
@@ -57,6 +57,7 @@ terminal_view.workspace = true
 theme.workspace = true
 ui.workspace = true
 util.workspace = true
+watch.workspace = true
 web_search.workspace = true
 which.workspace = true
 workspace-hack.workspace = true
@@ -64,6 +65,7 @@ workspace.workspace = true
 zed_llm_client.workspace = true
 
 [dev-dependencies]
+lsp = { workspace = true, features = ["test-support"] }
 client = { workspace = true, features = ["test-support"] }
 clock = { workspace = true, features = ["test-support"] }
 collections = { workspace = true, features = ["test-support"] }
@@ -78,6 +80,7 @@ rand.workspace = true
 pretty_assertions.workspace = true
 reqwest_client.workspace = true
 settings = { workspace = true, features = ["test-support"] }
+smol.workspace = true
 task = { workspace = true, features = ["test-support"]}
 tempfile.workspace = true
 theme.workspace = true

crates/assistant_tools/src/assistant_tools.rs 🔗

@@ -37,13 +37,13 @@ use crate::diagnostics_tool::DiagnosticsTool;
 use crate::edit_file_tool::EditFileTool;
 use crate::fetch_tool::FetchTool;
 use crate::find_path_tool::FindPathTool;
-use crate::grep_tool::GrepTool;
 use crate::list_directory_tool::ListDirectoryTool;
 use crate::now_tool::NowTool;
 use crate::thinking_tool::ThinkingTool;
 
-pub use edit_file_tool::EditFileToolInput;
+pub use edit_file_tool::{EditFileMode, EditFileToolInput};
 pub use find_path_tool::FindPathToolInput;
+pub use grep_tool::{GrepTool, GrepToolInput};
 pub use open_tool::OpenTool;
 pub use read_file_tool::{ReadFileTool, ReadFileToolInput};
 pub use terminal_tool::TerminalTool;
@@ -96,7 +96,7 @@ fn register_web_search_tool(registry: &Entity<LanguageModelRegistry>, cx: &mut A
 #[cfg(test)]
 mod tests {
     use super::*;
-    use assistant_settings::AssistantSettings;
+    use agent_settings::AgentSettings;
     use client::Client;
     use clock::FakeSystemClock;
     use http_client::FakeHttpClient;
@@ -126,6 +126,7 @@ mod tests {
                     }
                 },
                 "required": ["location"],
+                "additionalProperties": false
             })
         );
     }
@@ -133,7 +134,7 @@ mod tests {
     #[gpui::test]
     fn test_builtin_tool_schema_compatibility(cx: &mut App) {
         settings::init(cx);
-        AssistantSettings::register(cx);
+        AgentSettings::register(cx);
 
         let client = Client::new(
             Arc::new(FakeSystemClock::new()),

crates/assistant_tools/src/batch_tool/description.md 🔗

@@ -1,9 +0,0 @@
-Invoke multiple other tool calls either sequentially or concurrently.
-
-This tool is useful when you need to perform several operations at once, improving efficiency by reducing the number of back-and-forth interactions needed to complete complex tasks.
-
-If the tool calls are set to be run sequentially, then each tool call within the batch is executed in the order provided. If it's set to run concurrently, then they may run in a different order. Regardless, all tool calls will have the same permissions and context as if they were called individually.
-
-This tool should never be used to run a total of one tool. Instead, just run that one tool directly. You can run batches within batches if desired, which is a way you can mix concurrent and sequential tool call execution.
-
-When it's possible to run tools in a batch, you should run as many as possible in the batch, up to a maximum of 32. For example, don't run multiple consecutive batches of 10 when you could instead run one batch of 30.

crates/assistant_tools/src/code_action_tool/description.md 🔗

@@ -1,19 +0,0 @@
-A tool for applying code actions to specific sections of your code. It uses language servers to provide refactoring capabilities similar to what you'd find in an IDE.
-
-This tool can:
-- List all available code actions for a selected text range
-- Execute a specific code action on that range
-- Rename symbols across your codebase. This tool is the preferred way to rename things, and you should always prefer to rename code symbols using this tool rather than using textual find/replace when both are available.
-
-Use this tool when you want to:
-- Discover what code actions are available for a piece of code
-- Apply automatic fixes and code transformations
-- Rename variables, functions, or other symbols consistently throughout your project
-- Clean up imports, implement interfaces, or perform other language-specific operations
-
-- If unsure what actions are available, call the tool without specifying an action to get a list
-- For common operations, you can directly specify actions like "quickfix.all" or "source.organizeImports"
-- For renaming, use the special "textDocument/rename" action and provide the new name in the arguments field
-- Be specific with your text range and context to ensure the tool identifies the correct code location
-
-The tool will automatically save any changes it makes to your files.

crates/assistant_tools/src/code_symbols_tool/description.md 🔗

@@ -1,39 +0,0 @@
-Returns either an outline of the public code symbols in the entire project (grouped by file) or else an outline of both the public and private code symbols within a particular file.
-
-When a path is provided, this tool returns a hierarchical outline of code symbols for that specific file.
-When no path is provided, it returns a list of all public code symbols in the project, organized by file.
-
-You can also provide an optional regular expression which filters the output by only showing code symbols which match that regex.
-
-Results are paginated with 2000 entries per page. Use the optional 'offset' parameter to request subsequent pages.
-
-Markdown headings indicate the structure of the output; just like
-with markdown headings, the more # symbols there are at the beginning of a line,
-the deeper it is in the hierarchy.
-
-Each code symbol entry ends with a line number or range, which tells you what portion of the
-underlying source code file corresponds to that part of the outline. You can use
-that line information with other tools, to strategically read portions of the source code.
-
-For example, you can use this tool to find a relevant symbol in the project, then get the outline of the file which contains that symbol, then use the line number information from that file's outline to read different sections of that file, without having to read the entire file all at once (which can be slow, or use a lot of tokens).
-
-<example>
-# class Foo [L123-136]
-## method do_something(arg1, arg2) [L124-126]
-## method process_data(data) [L128-135]
-# class Bar [L145-161]
-## method initialize() [L146-149]
-## method update_state(new_state) [L160]
-## private method _validate_state(state) [L161-162]
-</example>
-
-This example shows how tree-sitter outlines the structure of source code:
-
-1. `class Foo` is defined on lines 123-136
-   - It contains a method `do_something` spanning lines 124-126
-   - It also has a method `process_data` spanning lines 128-135
-
-2. `class Bar` is defined on lines 145-161
-   - It has an `initialize` method spanning lines 146-149
-   - It has an `update_state` method on line 160
-   - It has a private method `_validate_state` spanning lines 161-162

crates/assistant_tools/src/contents_tool/description.md 🔗

@@ -1,9 +0,0 @@
-Reads the contents of a path on the filesystem.
-
-If the path is a directory, this lists all files and directories within that path.
-If the path is a file, this returns the file's contents.
-
-When reading a file, if the file is too big and no line range is specified, an outline of the file's code symbols is listed instead, which can be used to request specific line ranges in a subsequent call.
-
-Similarly, if a directory has too many entries to show at once, a subset of entries will be shown,
-and subsequent requests can use starting and ending line numbers to get other subsets.

crates/assistant_tools/src/copy_path_tool.rs 🔗

@@ -1,5 +1,5 @@
 use crate::schema::json_schema_for;
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use assistant_tool::{ActionLog, Tool, ToolResult};
 use gpui::AnyWindowHandle;
 use gpui::{App, AppContext, Entity, Task};
@@ -48,6 +48,10 @@ impl Tool for CopyPathTool {
         false
     }
 
+    fn may_perform_edits(&self) -> bool {
+        true
+    }
+
     fn description(&self) -> String {
         include_str!("./copy_path_tool/description.md").into()
     }
@@ -107,17 +111,13 @@ impl Tool for CopyPathTool {
         });
 
         cx.background_spawn(async move {
-            match copy_task.await {
-                Ok(_) => Ok(
-                    format!("Copied {} to {}", input.source_path, input.destination_path).into(),
-                ),
-                Err(err) => Err(anyhow!(
-                    "Failed to copy {} to {}: {}",
-                    input.source_path,
-                    input.destination_path,
-                    err
-                )),
-            }
+            let _ = copy_task.await.with_context(|| {
+                format!(
+                    "Copying {} to {}",
+                    input.source_path, input.destination_path
+                )
+            })?;
+            Ok(format!("Copied {} to {}", input.source_path, input.destination_path).into())
         })
         .into()
     }

crates/assistant_tools/src/create_directory_tool.rs 🔗

@@ -1,5 +1,5 @@
 use crate::schema::json_schema_for;
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use assistant_tool::{ActionLog, Tool, ToolResult};
 use gpui::AnyWindowHandle;
 use gpui::{App, Entity, Task};
@@ -33,12 +33,16 @@ impl Tool for CreateDirectoryTool {
         "create_directory".into()
     }
 
+    fn description(&self) -> String {
+        include_str!("./create_directory_tool/description.md").into()
+    }
+
     fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
         false
     }
 
-    fn description(&self) -> String {
-        include_str!("./create_directory_tool/description.md").into()
+    fn may_perform_edits(&self) -> bool {
+        false
     }
 
     fn icon(&self) -> IconName {
@@ -86,7 +90,7 @@ impl Tool for CreateDirectoryTool {
                     project.create_entry(project_path.clone(), true, cx)
                 })?
                 .await
-                .map_err(|err| anyhow!("Unable to create directory {destination_path}: {err}"))?;
+                .with_context(|| format!("Creating directory {destination_path}"))?;
 
             Ok(format!("Created directory {destination_path}").into())
         })

crates/assistant_tools/src/delete_path_tool.rs 🔗

@@ -1,5 +1,5 @@
 use crate::schema::json_schema_for;
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use assistant_tool::{ActionLog, Tool, ToolResult};
 use futures::{SinkExt, StreamExt, channel::mpsc};
 use gpui::{AnyWindowHandle, App, AppContext, Entity, Task};
@@ -37,6 +37,10 @@ impl Tool for DeletePathTool {
         false
     }
 
+    fn may_perform_edits(&self) -> bool {
+        true
+    }
+
     fn description(&self) -> String {
         include_str!("./delete_path_tool/description.md").into()
     }
@@ -122,19 +126,17 @@ impl Tool for DeletePathTool {
                 }
             }
 
-            let delete = project.update(cx, |project, cx| {
-                project.delete_file(project_path, false, cx)
-            })?;
-
-            match delete {
-                Some(deletion_task) => match deletion_task.await {
-                    Ok(()) => Ok(format!("Deleted {path_str}").into()),
-                    Err(err) => Err(anyhow!("Failed to delete {path_str}: {err}")),
-                },
-                None => Err(anyhow!(
-                    "Couldn't delete {path_str} because that path isn't in this project."
-                )),
-            }
+            let deletion_task = project
+                .update(cx, |project, cx| {
+                    project.delete_file(project_path, false, cx)
+                })?
+                .with_context(|| {
+                    format!("Couldn't delete {path_str} because that path isn't in this project.")
+                })?;
+            deletion_task
+                .await
+                .with_context(|| format!("Deleting {path_str}"))?;
+            Ok(format!("Deleted {path_str}").into())
         })
         .into()
     }

crates/assistant_tools/src/diagnostics_tool.rs 🔗

@@ -50,6 +50,10 @@ impl Tool for DiagnosticsTool {
         false
     }
 
+    fn may_perform_edits(&self) -> bool {
+        false
+    }
+
     fn description(&self) -> String {
         include_str!("./diagnostics_tool/description.md").into()
     }

crates/assistant_tools/src/edit_agent.rs 🔗

@@ -1,11 +1,14 @@
+mod create_file_parser;
 mod edit_parser;
 #[cfg(test)]
 mod evals;
+mod streaming_fuzzy_matcher;
 
 use crate::{Template, Templates};
-use aho_corasick::AhoCorasick;
 use anyhow::Result;
 use assistant_tool::ActionLog;
+use create_file_parser::{CreateFileParser, CreateFileParserEvent};
+pub use edit_parser::EditFormat;
 use edit_parser::{EditParser, EditParserEvent, EditParserMetrics};
 use futures::{
     Stream, StreamExt,
@@ -13,8 +16,8 @@ use futures::{
     pin_mut,
     stream::BoxStream,
 };
-use gpui::{AppContext, AsyncApp, Entity, SharedString, Task};
-use language::{Bias, Buffer, BufferSnapshot, LineIndent, Point};
+use gpui::{AppContext, AsyncApp, Entity, Task};
+use language::{Anchor, Buffer, BufferSnapshot, LineIndent, Point, TextBufferSnapshot};
 use language_model::{
     LanguageModel, LanguageModelCompletionError, LanguageModelRequest, LanguageModelRequestMessage,
     LanguageModelToolChoice, MessageContent, Role,
@@ -22,8 +25,11 @@ use language_model::{
 use project::{AgentLocation, Project};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use std::{cmp, iter, mem, ops::Range, path::PathBuf, sync::Arc, task::Poll};
+use std::{cmp, iter, mem, ops::Range, path::PathBuf, pin::Pin, sync::Arc, task::Poll};
 use streaming_diff::{CharOperation, StreamingDiff};
+use streaming_fuzzy_matcher::StreamingFuzzyMatcher;
+use util::debug_panic;
+use zed_llm_client::CompletionIntent;
 
 #[derive(Serialize)]
 struct CreateFilePromptTemplate {
@@ -36,19 +42,31 @@ impl Template for CreateFilePromptTemplate {
 }
 
 #[derive(Serialize)]
-struct EditFilePromptTemplate {
+struct EditFileXmlPromptTemplate {
     path: Option<PathBuf>,
     edit_description: String,
 }
 
-impl Template for EditFilePromptTemplate {
-    const TEMPLATE_NAME: &'static str = "edit_file_prompt.hbs";
+impl Template for EditFileXmlPromptTemplate {
+    const TEMPLATE_NAME: &'static str = "edit_file_prompt_xml.hbs";
+}
+
+#[derive(Serialize)]
+struct EditFileDiffFencedPromptTemplate {
+    path: Option<PathBuf>,
+    edit_description: String,
+}
+
+impl Template for EditFileDiffFencedPromptTemplate {
+    const TEMPLATE_NAME: &'static str = "edit_file_prompt_diff_fenced.hbs";
 }
 
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum EditAgentOutputEvent {
+    ResolvingEditRange(Range<Anchor>),
+    UnresolvedEditRange,
+    AmbiguousEditRange(Vec<Range<usize>>),
     Edited,
-    OldTextNotFound(SharedString),
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
@@ -63,6 +81,7 @@ pub struct EditAgent {
     action_log: Entity<ActionLog>,
     project: Entity<Project>,
     templates: Arc<Templates>,
+    edit_format: EditFormat,
 }
 
 impl EditAgent {
@@ -71,12 +90,14 @@ impl EditAgent {
         project: Entity<Project>,
         action_log: Entity<ActionLog>,
         templates: Arc<Templates>,
+        edit_format: EditFormat,
     ) -> Self {
         EditAgent {
             model,
             project,
             action_log,
             templates,
+            edit_format,
         }
     }
 
@@ -101,7 +122,9 @@ impl EditAgent {
                 edit_description,
             }
             .render(&this.templates)?;
-            let new_chunks = this.request(conversation, prompt, cx).await?;
+            let new_chunks = this
+                .request(conversation, CompletionIntent::CreateFile, prompt, cx)
+                .await?;
 
             let (output, mut inner_events) = this.overwrite_with_chunks(buffer, new_chunks, cx);
             while let Some(event) = inner_events.next().await {
@@ -122,16 +145,14 @@ impl EditAgent {
         mpsc::UnboundedReceiver<EditAgentOutputEvent>,
     ) {
         let (output_events_tx, output_events_rx) = mpsc::unbounded();
+        let (parse_task, parse_rx) = Self::parse_create_file_chunks(edit_chunks, cx);
         let this = self.clone();
         let task = cx.spawn(async move |cx| {
             this.action_log
                 .update(cx, |log, cx| log.buffer_created(buffer.clone(), cx))?;
-            let output = this
-                .overwrite_with_chunks_internal(buffer, edit_chunks, output_events_tx, cx)
-                .await;
-            this.project
-                .update(cx, |project, cx| project.set_agent_location(None, cx))?;
-            output
+            this.overwrite_with_chunks_internal(buffer, parse_rx, output_events_tx, cx)
+                .await?;
+            parse_task.await
         });
         (task, output_events_rx)
     }
@@ -139,10 +160,10 @@ impl EditAgent {
     async fn overwrite_with_chunks_internal(
         &self,
         buffer: Entity<Buffer>,
-        edit_chunks: impl 'static + Send + Stream<Item = Result<String, LanguageModelCompletionError>>,
+        mut parse_rx: UnboundedReceiver<Result<CreateFileParserEvent>>,
         output_events_tx: mpsc::UnboundedSender<EditAgentOutputEvent>,
         cx: &mut AsyncApp,
-    ) -> Result<EditAgentOutput> {
+    ) -> Result<()> {
         cx.update(|cx| {
             buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
             self.action_log.update(cx, |log, cx| {
@@ -162,34 +183,31 @@ impl EditAgent {
                 .ok();
         })?;
 
-        let mut raw_edits = String::new();
-        pin_mut!(edit_chunks);
-        while let Some(chunk) = edit_chunks.next().await {
-            let chunk = chunk?;
-            raw_edits.push_str(&chunk);
-            cx.update(|cx| {
-                buffer.update(cx, |buffer, cx| buffer.append(chunk, cx));
-                self.action_log
-                    .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
-                self.project.update(cx, |project, cx| {
-                    project.set_agent_location(
-                        Some(AgentLocation {
-                            buffer: buffer.downgrade(),
-                            position: language::Anchor::MAX,
-                        }),
-                        cx,
-                    )
-                });
-            })?;
-            output_events_tx
-                .unbounded_send(EditAgentOutputEvent::Edited)
-                .ok();
+        while let Some(event) = parse_rx.next().await {
+            match event? {
+                CreateFileParserEvent::NewTextChunk { chunk } => {
+                    cx.update(|cx| {
+                        buffer.update(cx, |buffer, cx| buffer.append(chunk, cx));
+                        self.action_log
+                            .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
+                        self.project.update(cx, |project, cx| {
+                            project.set_agent_location(
+                                Some(AgentLocation {
+                                    buffer: buffer.downgrade(),
+                                    position: language::Anchor::MAX,
+                                }),
+                                cx,
+                            )
+                        });
+                    })?;
+                    output_events_tx
+                        .unbounded_send(EditAgentOutputEvent::Edited)
+                        .ok();
+                }
+            }
         }
 
-        Ok(EditAgentOutput {
-            raw_edits,
-            parser_metrics: EditParserMetrics::default(),
-        })
+        Ok(())
     }
 
     pub fn edit(
@@ -202,163 +220,116 @@ impl EditAgent {
         Task<Result<EditAgentOutput>>,
         mpsc::UnboundedReceiver<EditAgentOutputEvent>,
     ) {
-        self.project
-            .update(cx, |project, cx| {
-                project.set_agent_location(
-                    Some(AgentLocation {
-                        buffer: buffer.downgrade(),
-                        position: language::Anchor::MIN,
-                    }),
-                    cx,
-                );
-            })
-            .ok();
-
         let this = self.clone();
         let (events_tx, events_rx) = mpsc::unbounded();
         let conversation = conversation.clone();
+        let edit_format = self.edit_format;
         let output = cx.spawn(async move |cx| {
             let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
             let path = cx.update(|cx| snapshot.resolve_file_path(cx, true))?;
-            let prompt = EditFilePromptTemplate {
-                path,
-                edit_description,
-            }
-            .render(&this.templates)?;
-            let edit_chunks = this.request(conversation, prompt, cx).await?;
+            let prompt = match edit_format {
+                EditFormat::XmlTags => EditFileXmlPromptTemplate {
+                    path,
+                    edit_description,
+                }
+                .render(&this.templates)?,
+                EditFormat::DiffFenced => EditFileDiffFencedPromptTemplate {
+                    path,
+                    edit_description,
+                }
+                .render(&this.templates)?,
+            };
 
-            let (output, mut inner_events) = this.apply_edit_chunks(buffer, edit_chunks, cx);
-            while let Some(event) = inner_events.next().await {
-                events_tx.unbounded_send(event).ok();
-            }
-            output.await
+            let edit_chunks = this
+                .request(conversation, CompletionIntent::EditFile, prompt, cx)
+                .await?;
+            this.apply_edit_chunks(buffer, edit_chunks, events_tx, cx)
+                .await
         });
         (output, events_rx)
     }
 
-    fn apply_edit_chunks(
-        &self,
-        buffer: Entity<Buffer>,
-        edit_chunks: impl 'static + Send + Stream<Item = Result<String, LanguageModelCompletionError>>,
-        cx: &mut AsyncApp,
-    ) -> (
-        Task<Result<EditAgentOutput>>,
-        mpsc::UnboundedReceiver<EditAgentOutputEvent>,
-    ) {
-        let (output_events_tx, output_events_rx) = mpsc::unbounded();
-        let this = self.clone();
-        let task = cx.spawn(async move |mut cx| {
-            this.action_log
-                .update(cx, |log, cx| log.buffer_read(buffer.clone(), cx))?;
-            let output = this
-                .apply_edit_chunks_internal(buffer, edit_chunks, output_events_tx, &mut cx)
-                .await;
-            this.project
-                .update(cx, |project, cx| project.set_agent_location(None, cx))?;
-            output
-        });
-        (task, output_events_rx)
-    }
-
-    async fn apply_edit_chunks_internal(
+    async fn apply_edit_chunks(
         &self,
         buffer: Entity<Buffer>,
         edit_chunks: impl 'static + Send + Stream<Item = Result<String, LanguageModelCompletionError>>,
         output_events: mpsc::UnboundedSender<EditAgentOutputEvent>,
         cx: &mut AsyncApp,
     ) -> Result<EditAgentOutput> {
-        let (output, mut edit_events) = Self::parse_edit_chunks(edit_chunks, cx);
-        while let Some(edit_event) = edit_events.next().await {
-            let EditParserEvent::OldText(old_text_query) = edit_event? else {
+        self.action_log
+            .update(cx, |log, cx| log.buffer_read(buffer.clone(), cx))?;
+
+        let (output, edit_events) = Self::parse_edit_chunks(edit_chunks, self.edit_format, cx);
+        let mut edit_events = edit_events.peekable();
+        while let Some(edit_event) = Pin::new(&mut edit_events).peek().await {
+            // Skip events until we're at the start of a new edit.
+            let Ok(EditParserEvent::OldTextChunk { .. }) = edit_event else {
+                edit_events.next().await.unwrap()?;
                 continue;
             };
 
-            // Skip edits with an empty old text.
-            if old_text_query.is_empty() {
-                continue;
-            }
-
-            let old_text_query = SharedString::from(old_text_query);
-
-            let (edits_tx, edits_rx) = mpsc::unbounded();
-            let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
-            let old_range = cx
-                .background_spawn({
-                    let snapshot = snapshot.clone();
-                    let old_text_query = old_text_query.clone();
-                    async move { Self::resolve_location(&snapshot, &old_text_query) }
-                })
-                .await;
-            let Some(old_range) = old_range else {
-                // We couldn't find the old text in the buffer. Report the error.
-                output_events
-                    .unbounded_send(EditAgentOutputEvent::OldTextNotFound(old_text_query))
-                    .ok();
-                continue;
-            };
-
-            let compute_edits = cx.background_spawn(async move {
-                let buffer_start_indent =
-                    snapshot.line_indent_for_row(snapshot.offset_to_point(old_range.start).row);
-                let old_text_start_indent = old_text_query
-                    .lines()
-                    .next()
-                    .map_or(buffer_start_indent, |line| {
-                        LineIndent::from_iter(line.chars())
-                    });
-                let indent_delta = if buffer_start_indent.tabs > 0 {
-                    IndentDelta::Tabs(
-                        buffer_start_indent.tabs as isize - old_text_start_indent.tabs as isize,
-                    )
-                } else {
-                    IndentDelta::Spaces(
-                        buffer_start_indent.spaces as isize - old_text_start_indent.spaces as isize,
-                    )
-                };
-
-                let old_text = snapshot
-                    .text_for_range(old_range.clone())
-                    .collect::<String>();
-                let mut diff = StreamingDiff::new(old_text);
-                let mut edit_start = old_range.start;
-                let mut new_text_chunks =
-                    Self::reindent_new_text_chunks(indent_delta, &mut edit_events);
-                let mut done = false;
-                while !done {
-                    let char_operations = if let Some(new_text_chunk) = new_text_chunks.next().await
-                    {
-                        diff.push_new(&new_text_chunk?)
-                    } else {
-                        done = true;
-                        mem::take(&mut diff).finish()
-                    };
+            let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
 
-                    for op in char_operations {
-                        match op {
-                            CharOperation::Insert { text } => {
-                                let edit_start = snapshot.anchor_after(edit_start);
-                                edits_tx
-                                    .unbounded_send((edit_start..edit_start, Arc::from(text)))?;
-                            }
-                            CharOperation::Delete { bytes } => {
-                                let edit_end = edit_start + bytes;
-                                let edit_range = snapshot.anchor_after(edit_start)
-                                    ..snapshot.anchor_before(edit_end);
-                                edit_start = edit_end;
-                                edits_tx.unbounded_send((edit_range, Arc::from("")))?;
-                            }
-                            CharOperation::Keep { bytes } => edit_start += bytes,
-                        }
-                    }
+            // Resolve the old text in the background, updating the agent
+            // location as we keep refining which range it corresponds to.
+            let (resolve_old_text, mut old_range) =
+                Self::resolve_old_text(snapshot.text.clone(), edit_events, cx);
+            while let Ok(old_range) = old_range.recv().await {
+                if let Some(old_range) = old_range {
+                    let old_range = snapshot.anchor_before(old_range.start)
+                        ..snapshot.anchor_before(old_range.end);
+                    self.project.update(cx, |project, cx| {
+                        project.set_agent_location(
+                            Some(AgentLocation {
+                                buffer: buffer.downgrade(),
+                                position: old_range.end,
+                            }),
+                            cx,
+                        );
+                    })?;
+                    output_events
+                        .unbounded_send(EditAgentOutputEvent::ResolvingEditRange(old_range))
+                        .ok();
                 }
+            }
 
-                drop(new_text_chunks);
-                anyhow::Ok(edit_events)
-            });
+            let (edit_events_, mut resolved_old_text) = resolve_old_text.await?;
+            edit_events = edit_events_;
+
+            // If we can't resolve the old text, restart the loop waiting for a
+            // new edit (or for the stream to end).
+            let resolved_old_text = match resolved_old_text.len() {
+                1 => resolved_old_text.pop().unwrap(),
+                0 => {
+                    output_events
+                        .unbounded_send(EditAgentOutputEvent::UnresolvedEditRange)
+                        .ok();
+                    continue;
+                }
+                _ => {
+                    let ranges = resolved_old_text
+                        .into_iter()
+                        .map(|text| {
+                            let start_line =
+                                (snapshot.offset_to_point(text.range.start).row + 1) as usize;
+                            let end_line =
+                                (snapshot.offset_to_point(text.range.end).row + 1) as usize;
+                            start_line..end_line
+                        })
+                        .collect();
+                    output_events
+                        .unbounded_send(EditAgentOutputEvent::AmbiguousEditRange(ranges))
+                        .ok();
+                    continue;
+                }
+            };
 
-            // TODO: group all edits into one transaction
-            let mut edits_rx = edits_rx.ready_chunks(32);
-            while let Some(edits) = edits_rx.next().await {
+            // Compute edits in the background and apply them as they become
+            // available.
+            let (compute_edits, edits) =
+                Self::compute_edits(snapshot, resolved_old_text, edit_events, cx);
+            let mut edits = edits.ready_chunks(32);
+            while let Some(edits) = edits.next().await {
                 if edits.is_empty() {
                     continue;
                 }
@@ -402,6 +373,7 @@ impl EditAgent {
 
     fn parse_edit_chunks(
         chunks: impl 'static + Send + Stream<Item = Result<String, LanguageModelCompletionError>>,
+        edit_format: EditFormat,
         cx: &mut AsyncApp,
     ) -> (
         Task<Result<EditAgentOutput>>,
@@ -411,7 +383,7 @@ impl EditAgent {
         let output = cx.background_spawn(async move {
             pin_mut!(chunks);
 
-            let mut parser = EditParser::new();
+            let mut parser = EditParser::new(edit_format);
             let mut raw_edits = String::new();
             while let Some(chunk) = chunks.next().await {
                 match chunk {
@@ -434,6 +406,172 @@ impl EditAgent {
         (output, rx)
     }
 
+    fn parse_create_file_chunks(
+        chunks: impl 'static + Send + Stream<Item = Result<String, LanguageModelCompletionError>>,
+        cx: &mut AsyncApp,
+    ) -> (
+        Task<Result<EditAgentOutput>>,
+        UnboundedReceiver<Result<CreateFileParserEvent>>,
+    ) {
+        let (tx, rx) = mpsc::unbounded();
+        let output = cx.background_spawn(async move {
+            pin_mut!(chunks);
+
+            let mut parser = CreateFileParser::new();
+            let mut raw_edits = String::new();
+            while let Some(chunk) = chunks.next().await {
+                match chunk {
+                    Ok(chunk) => {
+                        raw_edits.push_str(&chunk);
+                        for event in parser.push(Some(&chunk)) {
+                            tx.unbounded_send(Ok(event))?;
+                        }
+                    }
+                    Err(error) => {
+                        tx.unbounded_send(Err(error.into()))?;
+                    }
+                }
+            }
+            // Send final events with None to indicate completion
+            for event in parser.push(None) {
+                tx.unbounded_send(Ok(event))?;
+            }
+            Ok(EditAgentOutput {
+                raw_edits,
+                parser_metrics: EditParserMetrics::default(),
+            })
+        });
+        (output, rx)
+    }
+
+    fn resolve_old_text<T>(
+        snapshot: TextBufferSnapshot,
+        mut edit_events: T,
+        cx: &mut AsyncApp,
+    ) -> (
+        Task<Result<(T, Vec<ResolvedOldText>)>>,
+        watch::Receiver<Option<Range<usize>>>,
+    )
+    where
+        T: 'static + Send + Unpin + Stream<Item = Result<EditParserEvent>>,
+    {
+        let (mut old_range_tx, old_range_rx) = watch::channel(None);
+        let task = cx.background_spawn(async move {
+            let mut matcher = StreamingFuzzyMatcher::new(snapshot);
+            while let Some(edit_event) = edit_events.next().await {
+                let EditParserEvent::OldTextChunk {
+                    chunk,
+                    done,
+                    line_hint,
+                } = edit_event?
+                else {
+                    break;
+                };
+
+                old_range_tx.send(matcher.push(&chunk, line_hint))?;
+                if done {
+                    break;
+                }
+            }
+
+            let matches = matcher.finish();
+            let best_match = matcher.select_best_match();
+
+            old_range_tx.send(best_match.clone())?;
+
+            let indent = LineIndent::from_iter(
+                matcher
+                    .query_lines()
+                    .first()
+                    .unwrap_or(&String::new())
+                    .chars(),
+            );
+
+            let resolved_old_texts = if let Some(best_match) = best_match {
+                vec![ResolvedOldText {
+                    range: best_match,
+                    indent,
+                }]
+            } else {
+                matches
+                    .into_iter()
+                    .map(|range| ResolvedOldText { range, indent })
+                    .collect::<Vec<_>>()
+            };
+
+            Ok((edit_events, resolved_old_texts))
+        });
+
+        (task, old_range_rx)
+    }
+
+    fn compute_edits<T>(
+        snapshot: BufferSnapshot,
+        resolved_old_text: ResolvedOldText,
+        mut edit_events: T,
+        cx: &mut AsyncApp,
+    ) -> (
+        Task<Result<T>>,
+        UnboundedReceiver<(Range<Anchor>, Arc<str>)>,
+    )
+    where
+        T: 'static + Send + Unpin + Stream<Item = Result<EditParserEvent>>,
+    {
+        let (edits_tx, edits_rx) = mpsc::unbounded();
+        let compute_edits = cx.background_spawn(async move {
+            let buffer_start_indent = snapshot
+                .line_indent_for_row(snapshot.offset_to_point(resolved_old_text.range.start).row);
+            let indent_delta = if buffer_start_indent.tabs > 0 {
+                IndentDelta::Tabs(
+                    buffer_start_indent.tabs as isize - resolved_old_text.indent.tabs as isize,
+                )
+            } else {
+                IndentDelta::Spaces(
+                    buffer_start_indent.spaces as isize - resolved_old_text.indent.spaces as isize,
+                )
+            };
+
+            let old_text = snapshot
+                .text_for_range(resolved_old_text.range.clone())
+                .collect::<String>();
+            let mut diff = StreamingDiff::new(old_text);
+            let mut edit_start = resolved_old_text.range.start;
+            let mut new_text_chunks =
+                Self::reindent_new_text_chunks(indent_delta, &mut edit_events);
+            let mut done = false;
+            while !done {
+                let char_operations = if let Some(new_text_chunk) = new_text_chunks.next().await {
+                    diff.push_new(&new_text_chunk?)
+                } else {
+                    done = true;
+                    mem::take(&mut diff).finish()
+                };
+
+                for op in char_operations {
+                    match op {
+                        CharOperation::Insert { text } => {
+                            let edit_start = snapshot.anchor_after(edit_start);
+                            edits_tx.unbounded_send((edit_start..edit_start, Arc::from(text)))?;
+                        }
+                        CharOperation::Delete { bytes } => {
+                            let edit_end = edit_start + bytes;
+                            let edit_range =
+                                snapshot.anchor_after(edit_start)..snapshot.anchor_before(edit_end);
+                            edit_start = edit_end;
+                            edits_tx.unbounded_send((edit_range, Arc::from("")))?;
+                        }
+                        CharOperation::Keep { bytes } => edit_start += bytes,
+                    }
+                }
+            }
+
+            drop(new_text_chunks);
+            anyhow::Ok(edit_events)
+        });
+
+        (compute_edits, edits_rx)
+    }
+
     fn reindent_new_text_chunks(
         delta: IndentDelta,
         mut stream: impl Unpin + Stream<Item = Result<EditParserEvent>>,
@@ -516,6 +654,7 @@ impl EditAgent {
     async fn request(
         &self,
         mut conversation: LanguageModelRequest,
+        intent: CompletionIntent,
         prompt: String,
         cx: &mut AsyncApp,
     ) -> Result<BoxStream<'static, Result<String, LanguageModelCompletionError>>> {
@@ -543,6 +682,11 @@ impl EditAgent {
                 if last_message.content.is_empty() {
                     conversation.messages.pop();
                 }
+            } else {
+                debug_panic!(
+                    "Last message must be an Assistant tool calling! Got {:?}",
+                    last_message.content
+                );
             }
         }
 
@@ -568,6 +712,7 @@ impl EditAgent {
         let request = LanguageModelRequest {
             thread_id: conversation.thread_id,
             prompt_id: conversation.prompt_id,
+            intent: Some(intent),
             mode: conversation.mode,
             messages: conversation.messages,
             tool_choice,
@@ -578,134 +723,11 @@ impl EditAgent {
 
         Ok(self.model.stream_completion_text(request, cx).await?.stream)
     }
-
-    fn resolve_location(buffer: &BufferSnapshot, search_query: &str) -> Option<Range<usize>> {
-        let range = Self::resolve_location_exact(buffer, search_query)
-            .or_else(|| Self::resolve_location_fuzzy(buffer, search_query))?;
-
-        // Expand the range to include entire lines.
-        let mut start = buffer.offset_to_point(buffer.clip_offset(range.start, Bias::Left));
-        start.column = 0;
-        let mut end = buffer.offset_to_point(buffer.clip_offset(range.end, Bias::Right));
-        if end.column > 0 {
-            end.column = buffer.line_len(end.row);
-        }
-
-        Some(buffer.point_to_offset(start)..buffer.point_to_offset(end))
-    }
-
-    fn resolve_location_exact(buffer: &BufferSnapshot, search_query: &str) -> Option<Range<usize>> {
-        let search = AhoCorasick::new([search_query]).ok()?;
-        let mat = search
-            .stream_find_iter(buffer.bytes_in_range(0..buffer.len()))
-            .next()?
-            .expect("buffer can't error");
-        Some(mat.range())
-    }
-
-    fn resolve_location_fuzzy(buffer: &BufferSnapshot, search_query: &str) -> Option<Range<usize>> {
-        const INSERTION_COST: u32 = 3;
-        const DELETION_COST: u32 = 10;
-
-        let buffer_line_count = buffer.max_point().row as usize + 1;
-        let query_line_count = search_query.lines().count();
-        let mut matrix = SearchMatrix::new(query_line_count + 1, buffer_line_count + 1);
-        let mut leading_deletion_cost = 0_u32;
-        for (row, query_line) in search_query.lines().enumerate() {
-            let query_line = query_line.trim();
-            leading_deletion_cost = leading_deletion_cost.saturating_add(DELETION_COST);
-            matrix.set(
-                row + 1,
-                0,
-                SearchState::new(leading_deletion_cost, SearchDirection::Diagonal),
-            );
-
-            let mut buffer_lines = buffer.as_rope().chunks().lines();
-            let mut col = 0;
-            while let Some(buffer_line) = buffer_lines.next() {
-                let buffer_line = buffer_line.trim();
-                let up = SearchState::new(
-                    matrix.get(row, col + 1).cost.saturating_add(DELETION_COST),
-                    SearchDirection::Up,
-                );
-                let left = SearchState::new(
-                    matrix.get(row + 1, col).cost.saturating_add(INSERTION_COST),
-                    SearchDirection::Left,
-                );
-                let diagonal = SearchState::new(
-                    if fuzzy_eq(query_line, buffer_line) {
-                        matrix.get(row, col).cost
-                    } else {
-                        matrix
-                            .get(row, col)
-                            .cost
-                            .saturating_add(DELETION_COST + INSERTION_COST)
-                    },
-                    SearchDirection::Diagonal,
-                );
-                matrix.set(row + 1, col + 1, up.min(left).min(diagonal));
-                col += 1;
-            }
-        }
-
-        // Traceback to find the best match
-        let mut buffer_row_end = buffer_line_count as u32;
-        let mut best_cost = u32::MAX;
-        for col in 1..=buffer_line_count {
-            let cost = matrix.get(query_line_count, col).cost;
-            if cost < best_cost {
-                best_cost = cost;
-                buffer_row_end = col as u32;
-            }
-        }
-
-        let mut matched_lines = 0;
-        let mut query_row = query_line_count;
-        let mut buffer_row_start = buffer_row_end;
-        while query_row > 0 && buffer_row_start > 0 {
-            let current = matrix.get(query_row, buffer_row_start as usize);
-            match current.direction {
-                SearchDirection::Diagonal => {
-                    query_row -= 1;
-                    buffer_row_start -= 1;
-                    matched_lines += 1;
-                }
-                SearchDirection::Up => {
-                    query_row -= 1;
-                }
-                SearchDirection::Left => {
-                    buffer_row_start -= 1;
-                }
-            }
-        }
-
-        let matched_buffer_row_count = buffer_row_end - buffer_row_start;
-        let matched_ratio =
-            matched_lines as f32 / (matched_buffer_row_count as f32).max(query_line_count as f32);
-        if matched_ratio >= 0.8 {
-            let buffer_start_ix = buffer.point_to_offset(Point::new(buffer_row_start, 0));
-            let buffer_end_ix = buffer.point_to_offset(Point::new(
-                buffer_row_end - 1,
-                buffer.line_len(buffer_row_end - 1),
-            ));
-            Some(buffer_start_ix..buffer_end_ix)
-        } else {
-            None
-        }
-    }
 }
 
-fn fuzzy_eq(left: &str, right: &str) -> bool {
-    const THRESHOLD: f64 = 0.8;
-
-    let min_levenshtein = left.len().abs_diff(right.len());
-    let min_normalized_levenshtein =
-        1. - (min_levenshtein as f64 / cmp::max(left.len(), right.len()) as f64);
-    if min_normalized_levenshtein < THRESHOLD {
-        return false;
-    }
-
-    strsim::normalized_levenshtein(left, right) >= THRESHOLD
+struct ResolvedOldText {
+    range: Range<usize>,
+    indent: LineIndent,
 }
 
 #[derive(Copy, Clone, Debug)]
@@ -730,61 +752,18 @@ impl IndentDelta {
     }
 }
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
-enum SearchDirection {
-    Up,
-    Left,
-    Diagonal,
-}
-
-#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
-struct SearchState {
-    cost: u32,
-    direction: SearchDirection,
-}
-
-impl SearchState {
-    fn new(cost: u32, direction: SearchDirection) -> Self {
-        Self { cost, direction }
-    }
-}
-
-struct SearchMatrix {
-    cols: usize,
-    data: Vec<SearchState>,
-}
-
-impl SearchMatrix {
-    fn new(rows: usize, cols: usize) -> Self {
-        SearchMatrix {
-            cols,
-            data: vec![SearchState::new(0, SearchDirection::Diagonal); rows * cols],
-        }
-    }
-
-    fn get(&self, row: usize, col: usize) -> SearchState {
-        self.data[row * self.cols + col]
-    }
-
-    fn set(&mut self, row: usize, col: usize, cost: SearchState) {
-        self.data[row * self.cols + col] = cost;
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
     use fs::FakeFs;
     use futures::stream;
-    use gpui::{App, AppContext, TestAppContext};
+    use gpui::{AppContext, TestAppContext};
     use indoc::indoc;
     use language_model::fake_provider::FakeLanguageModel;
     use project::{AgentLocation, Project};
     use rand::prelude::*;
     use rand::rngs::StdRng;
     use std::cmp;
-    use unindent::Unindent;
-    use util::test::{generate_marked_text, marked_text_ranges};
 
     #[gpui::test(iterations = 100)]
     async fn test_empty_old_text(cx: &mut TestAppContext, mut rng: StdRng) {
@@ -799,7 +778,16 @@ mod tests {
                 cx,
             )
         });
-        let raw_edits = simulate_llm_output(
+        let (apply, _events) = agent.edit(
+            buffer.clone(),
+            String::new(),
+            &LanguageModelRequest::default(),
+            &mut cx.to_async(),
+        );
+        cx.run_until_parked();
+
+        simulate_llm_output(
+            &agent,
             indoc! {"
                 <old_text></old_text>
                 <new_text>jkl</new_text>
@@ -809,9 +797,8 @@ mod tests {
             &mut rng,
             cx,
         );
-        let (apply, _events) =
-            agent.apply_edit_chunks(buffer.clone(), raw_edits, &mut cx.to_async());
         apply.await.unwrap();
+
         pretty_assertions::assert_eq!(
             buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
             indoc! {"
@@ -836,7 +823,16 @@ mod tests {
                 cx,
             )
         });
-        let raw_edits = simulate_llm_output(
+        let (apply, _events) = agent.edit(
+            buffer.clone(),
+            String::new(),
+            &LanguageModelRequest::default(),
+            &mut cx.to_async(),
+        );
+        cx.run_until_parked();
+
+        simulate_llm_output(
+            &agent,
             indoc! {"
                 <old_text>
                     ipsum
@@ -853,9 +849,8 @@ mod tests {
             &mut rng,
             cx,
         );
-        let (apply, _events) =
-            agent.apply_edit_chunks(buffer.clone(), raw_edits, &mut cx.to_async());
         apply.await.unwrap();
+
         pretty_assertions::assert_eq!(
             buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
             indoc! {"
@@ -872,7 +867,16 @@ mod tests {
     async fn test_dependent_edits(cx: &mut TestAppContext, mut rng: StdRng) {
         let agent = init_test(cx).await;
         let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi", cx));
-        let raw_edits = simulate_llm_output(
+        let (apply, _events) = agent.edit(
+            buffer.clone(),
+            String::new(),
+            &LanguageModelRequest::default(),
+            &mut cx.to_async(),
+        );
+        cx.run_until_parked();
+
+        simulate_llm_output(
+            &agent,
             indoc! {"
                 <old_text>
                 def
@@ -891,9 +895,8 @@ mod tests {
             &mut rng,
             cx,
         );
-        let (apply, _events) =
-            agent.apply_edit_chunks(buffer.clone(), raw_edits, &mut cx.to_async());
         apply.await.unwrap();
+
         assert_eq!(
             buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
             "abc\nDeF\nghi"
@@ -904,7 +907,16 @@ mod tests {
     async fn test_old_text_hallucination(cx: &mut TestAppContext, mut rng: StdRng) {
         let agent = init_test(cx).await;
         let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi", cx));
-        let raw_edits = simulate_llm_output(
+        let (apply, _events) = agent.edit(
+            buffer.clone(),
+            String::new(),
+            &LanguageModelRequest::default(),
+            &mut cx.to_async(),
+        );
+        cx.run_until_parked();
+
+        simulate_llm_output(
+            &agent,
             indoc! {"
                 <old_text>
                 jkl
@@ -923,9 +935,8 @@ mod tests {
             &mut rng,
             cx,
         );
-        let (apply, _events) =
-            agent.apply_edit_chunks(buffer.clone(), raw_edits, &mut cx.to_async());
         apply.await.unwrap();
+
         assert_eq!(
             buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
             "ABC\ndef\nghi"
@@ -935,47 +946,61 @@ mod tests {
     #[gpui::test]
     async fn test_edit_events(cx: &mut TestAppContext) {
         let agent = init_test(cx).await;
+        let model = agent.model.as_fake();
         let project = agent
             .action_log
             .read_with(cx, |log, _| log.project().clone());
-        let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi", cx));
-        let (chunks_tx, chunks_rx) = mpsc::unbounded();
-        let (apply, mut events) = agent.apply_edit_chunks(
+        let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi\njkl", cx));
+
+        let mut async_cx = cx.to_async();
+        let (apply, mut events) = agent.edit(
             buffer.clone(),
-            chunks_rx.map(|chunk: &str| Ok(chunk.to_string())),
-            &mut cx.to_async(),
+            String::new(),
+            &LanguageModelRequest::default(),
+            &mut async_cx,
         );
+        cx.run_until_parked();
 
-        chunks_tx.unbounded_send("<old_text>a").unwrap();
+        model.stream_last_completion_response("<old_text>a");
         cx.run_until_parked();
         assert_eq!(drain_events(&mut events), vec![]);
         assert_eq!(
             buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
-            "abc\ndef\nghi"
+            "abc\ndef\nghi\njkl"
         );
         assert_eq!(
             project.read_with(cx, |project, _| project.agent_location()),
             None
         );
 
-        chunks_tx.unbounded_send("bc</old_text>").unwrap();
+        model.stream_last_completion_response("bc</old_text>");
         cx.run_until_parked();
-        assert_eq!(drain_events(&mut events), vec![]);
+        assert_eq!(
+            drain_events(&mut events),
+            vec![EditAgentOutputEvent::ResolvingEditRange(buffer.read_with(
+                cx,
+                |buffer, _| buffer.anchor_before(Point::new(0, 0))
+                    ..buffer.anchor_before(Point::new(0, 3))
+            ))]
+        );
         assert_eq!(
             buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
-            "abc\ndef\nghi"
+            "abc\ndef\nghi\njkl"
         );
         assert_eq!(
             project.read_with(cx, |project, _| project.agent_location()),
-            None
+            Some(AgentLocation {
+                buffer: buffer.downgrade(),
+                position: buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(0, 3)))
+            })
         );
 
-        chunks_tx.unbounded_send("<new_text>abX").unwrap();
+        model.stream_last_completion_response("<new_text>abX");
         cx.run_until_parked();
         assert_eq!(drain_events(&mut events), [EditAgentOutputEvent::Edited]);
         assert_eq!(
             buffer.read_with(cx, |buffer, _| buffer.snapshot().text()),
-            "abXc\ndef\nghi"
+            "abXc\ndef\nghi\njkl"
         );
         assert_eq!(
             project.read_with(cx, |project, _| project.agent_location()),

crates/assistant_tools/src/edit_agent/create_file_parser.rs 🔗

@@ -0,0 +1,234 @@
+use regex::Regex;
+use smallvec::SmallVec;
+use std::cell::LazyCell;
+use util::debug_panic;
+
+const START_MARKER: LazyCell<Regex> = LazyCell::new(|| Regex::new(r"\n?```\S*\n").unwrap());
+const END_MARKER: LazyCell<Regex> = LazyCell::new(|| Regex::new(r"(^|\n)```\s*$").unwrap());
+
+#[derive(Debug)]
+pub enum CreateFileParserEvent {
+    NewTextChunk { chunk: String },
+}
+
+#[derive(Debug)]
+pub struct CreateFileParser {
+    state: ParserState,
+    buffer: String,
+}
+
+#[derive(Debug, PartialEq)]
+enum ParserState {
+    Pending,
+    WithinText,
+    Finishing,
+    Finished,
+}
+
+impl CreateFileParser {
+    pub fn new() -> Self {
+        CreateFileParser {
+            state: ParserState::Pending,
+            buffer: String::new(),
+        }
+    }
+
+    pub fn push(&mut self, chunk: Option<&str>) -> SmallVec<[CreateFileParserEvent; 1]> {
+        if chunk.is_none() {
+            self.state = ParserState::Finishing;
+        }
+
+        let chunk = chunk.unwrap_or_default();
+
+        self.buffer.push_str(chunk);
+
+        let mut edit_events = SmallVec::new();
+        loop {
+            match &mut self.state {
+                ParserState::Pending => {
+                    if let Some(m) = START_MARKER.find(&self.buffer) {
+                        self.buffer.drain(..m.end());
+                        self.state = ParserState::WithinText;
+                    } else {
+                        break;
+                    }
+                }
+                ParserState::WithinText => {
+                    let text = self.buffer.trim_end_matches(&['`', '\n', ' ']);
+                    let text_len = text.len();
+
+                    if text_len > 0 {
+                        edit_events.push(CreateFileParserEvent::NewTextChunk {
+                            chunk: self.buffer.drain(..text_len).collect(),
+                        });
+                    }
+                    break;
+                }
+                ParserState::Finishing => {
+                    if let Some(m) = END_MARKER.find(&self.buffer) {
+                        self.buffer.drain(m.start()..);
+                    }
+                    if !self.buffer.is_empty() {
+                        if !self.buffer.ends_with('\n') {
+                            self.buffer.push('\n');
+                        }
+                        edit_events.push(CreateFileParserEvent::NewTextChunk {
+                            chunk: self.buffer.drain(..).collect(),
+                        });
+                    }
+                    self.state = ParserState::Finished;
+                    break;
+                }
+                ParserState::Finished => debug_panic!("Can't call parser after finishing"),
+            }
+        }
+        edit_events
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use indoc::indoc;
+    use rand::prelude::*;
+    use std::cmp;
+
+    #[gpui::test(iterations = 100)]
+    fn test_happy_path(mut rng: StdRng) {
+        let mut parser = CreateFileParser::new();
+        assert_eq!(
+            parse_random_chunks("```\nHello world\n```", &mut parser, &mut rng),
+            "Hello world".to_string()
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_cut_prefix(mut rng: StdRng) {
+        let mut parser = CreateFileParser::new();
+        assert_eq!(
+            parse_random_chunks(
+                indoc! {"
+                    Let me write this file for you:
+
+                    ```
+                    Hello world
+                    ```
+
+                "},
+                &mut parser,
+                &mut rng
+            ),
+            "Hello world".to_string()
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_language_name_on_fences(mut rng: StdRng) {
+        let mut parser = CreateFileParser::new();
+        assert_eq!(
+            parse_random_chunks(
+                indoc! {"
+                    ```rust
+                    Hello world
+                    ```
+
+                "},
+                &mut parser,
+                &mut rng
+            ),
+            "Hello world".to_string()
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_leave_suffix(mut rng: StdRng) {
+        let mut parser = CreateFileParser::new();
+        assert_eq!(
+            parse_random_chunks(
+                indoc! {"
+                    Let me write this file for you:
+
+                    ```
+                    Hello world
+                    ```
+
+                    The end
+                "},
+                &mut parser,
+                &mut rng
+            ),
+            // This output is marlformed, so we're doing our best effort
+            "Hello world\n```\n\nThe end\n".to_string()
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_inner_fences(mut rng: StdRng) {
+        let mut parser = CreateFileParser::new();
+        assert_eq!(
+            parse_random_chunks(
+                indoc! {"
+                    Let me write this file for you:
+
+                    ```
+                    ```
+                    Hello world
+                    ```
+                    ```
+                "},
+                &mut parser,
+                &mut rng
+            ),
+            // This output is marlformed, so we're doing our best effort
+            "```\nHello world\n```\n".to_string()
+        );
+    }
+
+    #[gpui::test(iterations = 10)]
+    fn test_empty_file(mut rng: StdRng) {
+        let mut parser = CreateFileParser::new();
+        assert_eq!(
+            parse_random_chunks(
+                indoc! {"
+                    ```
+                    ```
+                "},
+                &mut parser,
+                &mut rng
+            ),
+            "".to_string()
+        );
+    }
+
+    fn parse_random_chunks(input: &str, parser: &mut CreateFileParser, rng: &mut StdRng) -> String {
+        let chunk_count = rng.gen_range(1..=cmp::min(input.len(), 50));
+        let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count);
+        chunk_indices.sort();
+        chunk_indices.push(input.len());
+
+        let chunk_indices = chunk_indices
+            .into_iter()
+            .map(Some)
+            .chain(vec![None])
+            .collect::<Vec<Option<usize>>>();
+
+        let mut edit = String::default();
+        let mut last_ix = 0;
+        for chunk_ix in chunk_indices {
+            let mut chunk = None;
+            if let Some(chunk_ix) = chunk_ix {
+                chunk = Some(&input[last_ix..chunk_ix]);
+                last_ix = chunk_ix;
+            }
+
+            for event in parser.push(chunk) {
+                match event {
+                    CreateFileParserEvent::NewTextChunk { chunk } => {
+                        edit.push_str(&chunk);
+                    }
+                }
+            }
+        }
+        edit
+    }
+}

crates/assistant_tools/src/edit_agent/edit_parser.rs 🔗

@@ -1,18 +1,31 @@
+use anyhow::bail;
 use derive_more::{Add, AddAssign};
+use language_model::LanguageModel;
+use regex::Regex;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use smallvec::SmallVec;
-use std::{cmp, mem, ops::Range};
+use std::{mem, ops::Range, str::FromStr, sync::Arc};
 
 const OLD_TEXT_END_TAG: &str = "</old_text>";
 const NEW_TEXT_END_TAG: &str = "</new_text>";
-const END_TAG_LEN: usize = OLD_TEXT_END_TAG.len();
-const _: () = debug_assert!(OLD_TEXT_END_TAG.len() == NEW_TEXT_END_TAG.len());
+const EDITS_END_TAG: &str = "</edits>";
+const SEARCH_MARKER: &str = "<<<<<<< SEARCH";
+const SEPARATOR_MARKER: &str = "=======";
+const REPLACE_MARKER: &str = ">>>>>>> REPLACE";
+const END_TAGS: [&str; 3] = [OLD_TEXT_END_TAG, NEW_TEXT_END_TAG, EDITS_END_TAG];
 
 #[derive(Debug)]
 pub enum EditParserEvent {
-    OldText(String),
-    NewTextChunk { chunk: String, done: bool },
+    OldTextChunk {
+        chunk: String,
+        done: bool,
+        line_hint: Option<u32>,
+    },
+    NewTextChunk {
+        chunk: String,
+        done: bool,
+    },
 }
 
 #[derive(
@@ -23,53 +36,176 @@ pub struct EditParserMetrics {
     pub mismatched_tags: usize,
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum EditFormat {
+    /// XML-like tags:
+    /// <old_text>...</old_text>
+    /// <new_text>...</new_text>
+    XmlTags,
+    /// Diff-fenced format, in which:
+    /// - Text before the SEARCH marker is ignored
+    /// - Fences are optional
+    /// - Line hint is optional.
+    ///
+    /// Example:
+    ///
+    /// ```diff
+    /// <<<<<<< SEARCH line=42
+    /// ...
+    /// =======
+    /// ...
+    /// >>>>>>> REPLACE
+    /// ```
+    DiffFenced,
+}
+
+impl FromStr for EditFormat {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> anyhow::Result<Self> {
+        match s.to_lowercase().as_str() {
+            "xml_tags" | "xml" => Ok(EditFormat::XmlTags),
+            "diff_fenced" | "diff-fenced" | "diff" => Ok(EditFormat::DiffFenced),
+            _ => bail!("Unknown EditFormat: {}", s),
+        }
+    }
+}
+
+impl EditFormat {
+    /// Return an optimal edit format for the language model
+    pub fn from_model(model: Arc<dyn LanguageModel>) -> anyhow::Result<Self> {
+        if model.provider_id().0 == "google" || model.id().0.to_lowercase().contains("gemini") {
+            Ok(EditFormat::DiffFenced)
+        } else {
+            Ok(EditFormat::XmlTags)
+        }
+    }
+
+    /// Return an optimal edit format for the language model,
+    /// with the ability to override it by setting the
+    /// `ZED_EDIT_FORMAT` environment variable
+    #[allow(dead_code)]
+    pub fn from_env(model: Arc<dyn LanguageModel>) -> anyhow::Result<Self> {
+        let default = EditFormat::from_model(model)?;
+        std::env::var("ZED_EDIT_FORMAT").map_or(Ok(default), |s| EditFormat::from_str(&s))
+    }
+}
+
+pub trait EditFormatParser: Send + std::fmt::Debug {
+    fn push(&mut self, chunk: &str) -> SmallVec<[EditParserEvent; 1]>;
+    fn take_metrics(&mut self) -> EditParserMetrics;
+}
+
 #[derive(Debug)]
-pub struct EditParser {
-    state: EditParserState,
+pub struct XmlEditParser {
+    state: XmlParserState,
     buffer: String,
     metrics: EditParserMetrics,
 }
 
 #[derive(Debug, PartialEq)]
-enum EditParserState {
+enum XmlParserState {
     Pending,
-    WithinOldText,
+    WithinOldText { start: bool, line_hint: Option<u32> },
     AfterOldText,
     WithinNewText { start: bool },
 }
 
-impl EditParser {
+#[derive(Debug)]
+pub struct DiffFencedEditParser {
+    state: DiffParserState,
+    buffer: String,
+    metrics: EditParserMetrics,
+}
+
+#[derive(Debug, PartialEq)]
+enum DiffParserState {
+    Pending,
+    WithinSearch { start: bool, line_hint: Option<u32> },
+    WithinReplace { start: bool },
+}
+
+/// Main parser that delegates to format-specific parsers
+pub struct EditParser {
+    parser: Box<dyn EditFormatParser>,
+}
+
+impl XmlEditParser {
     pub fn new() -> Self {
-        EditParser {
-            state: EditParserState::Pending,
+        XmlEditParser {
+            state: XmlParserState::Pending,
             buffer: String::new(),
             metrics: EditParserMetrics::default(),
         }
     }
 
-    pub fn push(&mut self, chunk: &str) -> SmallVec<[EditParserEvent; 1]> {
+    fn find_end_tag(&self) -> Option<Range<usize>> {
+        let (tag, start_ix) = END_TAGS
+            .iter()
+            .flat_map(|tag| Some((tag, self.buffer.find(tag)?)))
+            .min_by_key(|(_, ix)| *ix)?;
+        Some(start_ix..start_ix + tag.len())
+    }
+
+    fn ends_with_tag_prefix(&self) -> bool {
+        let mut end_prefixes = END_TAGS
+            .iter()
+            .flat_map(|tag| (1..tag.len()).map(move |i| &tag[..i]))
+            .chain(["\n"]);
+        end_prefixes.any(|prefix| self.buffer.ends_with(&prefix))
+    }
+
+    fn parse_line_hint(&self, tag: &str) -> Option<u32> {
+        use std::sync::LazyLock;
+        static LINE_HINT_REGEX: LazyLock<Regex> =
+            LazyLock::new(|| Regex::new(r#"line=(?:"?)(\d+)"#).unwrap());
+
+        LINE_HINT_REGEX
+            .captures(tag)
+            .and_then(|caps| caps.get(1))
+            .and_then(|m| m.as_str().parse::<u32>().ok())
+    }
+}
+
+impl EditFormatParser for XmlEditParser {
+    fn push(&mut self, chunk: &str) -> SmallVec<[EditParserEvent; 1]> {
         self.buffer.push_str(chunk);
 
         let mut edit_events = SmallVec::new();
         loop {
             match &mut self.state {
-                EditParserState::Pending => {
-                    if let Some(start) = self.buffer.find("<old_text>") {
-                        self.buffer.drain(..start + "<old_text>".len());
-                        self.state = EditParserState::WithinOldText;
+                XmlParserState::Pending => {
+                    if let Some(start) = self.buffer.find("<old_text") {
+                        if let Some(tag_end) = self.buffer[start..].find('>') {
+                            let tag_end = start + tag_end + 1;
+                            let tag = &self.buffer[start..tag_end];
+                            let line_hint = self.parse_line_hint(tag);
+                            self.buffer.drain(..tag_end);
+                            self.state = XmlParserState::WithinOldText {
+                                start: true,
+                                line_hint,
+                            };
+                        } else {
+                            break;
+                        }
                     } else {
                         break;
                     }
                 }
-                EditParserState::WithinOldText => {
-                    if let Some(tag_range) = self.find_end_tag() {
-                        let mut start = 0;
-                        if self.buffer.starts_with('\n') {
-                            start = 1;
+                XmlParserState::WithinOldText { start, line_hint } => {
+                    if !self.buffer.is_empty() {
+                        if *start && self.buffer.starts_with('\n') {
+                            self.buffer.remove(0);
                         }
-                        let mut old_text = self.buffer[start..tag_range.start].to_string();
-                        if old_text.ends_with('\n') {
-                            old_text.pop();
+                        *start = false;
+                    }
+
+                    let line_hint = *line_hint;
+                    if let Some(tag_range) = self.find_end_tag() {
+                        let mut chunk = self.buffer[..tag_range.start].to_string();
+                        if chunk.ends_with('\n') {
+                            chunk.pop();
                         }
 
                         self.metrics.tags += 1;
@@ -78,21 +214,32 @@ impl EditParser {
                         }
 
                         self.buffer.drain(..tag_range.end);
-                        self.state = EditParserState::AfterOldText;
-                        edit_events.push(EditParserEvent::OldText(old_text));
+                        self.state = XmlParserState::AfterOldText;
+                        edit_events.push(EditParserEvent::OldTextChunk {
+                            chunk,
+                            done: true,
+                            line_hint,
+                        });
                     } else {
+                        if !self.ends_with_tag_prefix() {
+                            edit_events.push(EditParserEvent::OldTextChunk {
+                                chunk: mem::take(&mut self.buffer),
+                                done: false,
+                                line_hint,
+                            });
+                        }
                         break;
                     }
                 }
-                EditParserState::AfterOldText => {
+                XmlParserState::AfterOldText => {
                     if let Some(start) = self.buffer.find("<new_text>") {
                         self.buffer.drain(..start + "<new_text>".len());
-                        self.state = EditParserState::WithinNewText { start: true };
+                        self.state = XmlParserState::WithinNewText { start: true };
                     } else {
                         break;
                     }
                 }
-                EditParserState::WithinNewText { start } => {
+                XmlParserState::WithinNewText { start } => {
                     if !self.buffer.is_empty() {
                         if *start && self.buffer.starts_with('\n') {
                             self.buffer.remove(0);
@@ -112,13 +259,10 @@ impl EditParser {
                         }
 
                         self.buffer.drain(..tag_range.end);
-                        self.state = EditParserState::Pending;
+                        self.state = XmlParserState::Pending;
                         edit_events.push(EditParserEvent::NewTextChunk { chunk, done: true });
                     } else {
-                        let mut end_prefixes = (1..END_TAG_LEN)
-                            .flat_map(|i| [&NEW_TEXT_END_TAG[..i], &OLD_TEXT_END_TAG[..i]])
-                            .chain(["\n"]);
-                        if end_prefixes.all(|prefix| !self.buffer.ends_with(&prefix)) {
+                        if !self.ends_with_tag_prefix() {
                             edit_events.push(EditParserEvent::NewTextChunk {
                                 chunk: mem::take(&mut self.buffer),
                                 done: false,
@@ -132,21 +276,163 @@ impl EditParser {
         edit_events
     }
 
-    fn find_end_tag(&self) -> Option<Range<usize>> {
-        let old_text_end_tag_ix = self.buffer.find(OLD_TEXT_END_TAG);
-        let new_text_end_tag_ix = self.buffer.find(NEW_TEXT_END_TAG);
-        let start_ix = if let Some((old_text_ix, new_text_ix)) =
-            old_text_end_tag_ix.zip(new_text_end_tag_ix)
-        {
-            cmp::min(old_text_ix, new_text_ix)
-        } else {
-            old_text_end_tag_ix.or(new_text_end_tag_ix)?
+    fn take_metrics(&mut self) -> EditParserMetrics {
+        std::mem::take(&mut self.metrics)
+    }
+}
+
+impl DiffFencedEditParser {
+    pub fn new() -> Self {
+        DiffFencedEditParser {
+            state: DiffParserState::Pending,
+            buffer: String::new(),
+            metrics: EditParserMetrics::default(),
+        }
+    }
+
+    fn ends_with_diff_marker_prefix(&self) -> bool {
+        let diff_markers = [SEPARATOR_MARKER, REPLACE_MARKER];
+        let mut diff_prefixes = diff_markers
+            .iter()
+            .flat_map(|marker| (1..marker.len()).map(move |i| &marker[..i]))
+            .chain(["\n"]);
+        diff_prefixes.any(|prefix| self.buffer.ends_with(&prefix))
+    }
+
+    fn parse_line_hint(&self, search_line: &str) -> Option<u32> {
+        use regex::Regex;
+        use std::sync::LazyLock;
+        static LINE_HINT_REGEX: LazyLock<Regex> =
+            LazyLock::new(|| Regex::new(r#"line=(?:"?)(\d+)"#).unwrap());
+
+        LINE_HINT_REGEX
+            .captures(search_line)
+            .and_then(|caps| caps.get(1))
+            .and_then(|m| m.as_str().parse::<u32>().ok())
+    }
+}
+
+impl EditFormatParser for DiffFencedEditParser {
+    fn push(&mut self, chunk: &str) -> SmallVec<[EditParserEvent; 1]> {
+        self.buffer.push_str(chunk);
+
+        let mut edit_events = SmallVec::new();
+        loop {
+            match &mut self.state {
+                DiffParserState::Pending => {
+                    if let Some(diff) = self.buffer.find(SEARCH_MARKER) {
+                        let search_end = diff + SEARCH_MARKER.len();
+                        if let Some(newline_pos) = self.buffer[search_end..].find('\n') {
+                            let search_line = &self.buffer[diff..search_end + newline_pos];
+                            let line_hint = self.parse_line_hint(search_line);
+                            self.buffer.drain(..search_end + newline_pos + 1);
+                            self.state = DiffParserState::WithinSearch {
+                                start: true,
+                                line_hint,
+                            };
+                        } else {
+                            break;
+                        }
+                    } else {
+                        break;
+                    }
+                }
+                DiffParserState::WithinSearch { start, line_hint } => {
+                    if !self.buffer.is_empty() {
+                        if *start && self.buffer.starts_with('\n') {
+                            self.buffer.remove(0);
+                        }
+                        *start = false;
+                    }
+
+                    let line_hint = *line_hint;
+                    if let Some(separator_pos) = self.buffer.find(SEPARATOR_MARKER) {
+                        let mut chunk = self.buffer[..separator_pos].to_string();
+                        if chunk.ends_with('\n') {
+                            chunk.pop();
+                        }
+
+                        let separator_end = separator_pos + SEPARATOR_MARKER.len();
+                        if let Some(newline_pos) = self.buffer[separator_end..].find('\n') {
+                            self.buffer.drain(..separator_end + newline_pos + 1);
+                            self.state = DiffParserState::WithinReplace { start: true };
+                            edit_events.push(EditParserEvent::OldTextChunk {
+                                chunk,
+                                done: true,
+                                line_hint,
+                            });
+                        } else {
+                            break;
+                        }
+                    } else {
+                        if !self.ends_with_diff_marker_prefix() {
+                            edit_events.push(EditParserEvent::OldTextChunk {
+                                chunk: mem::take(&mut self.buffer),
+                                done: false,
+                                line_hint,
+                            });
+                        }
+                        break;
+                    }
+                }
+                DiffParserState::WithinReplace { start } => {
+                    if !self.buffer.is_empty() {
+                        if *start && self.buffer.starts_with('\n') {
+                            self.buffer.remove(0);
+                        }
+                        *start = false;
+                    }
+
+                    if let Some(replace_pos) = self.buffer.find(REPLACE_MARKER) {
+                        let mut chunk = self.buffer[..replace_pos].to_string();
+                        if chunk.ends_with('\n') {
+                            chunk.pop();
+                        }
+
+                        self.buffer.drain(..replace_pos + REPLACE_MARKER.len());
+                        if let Some(newline_pos) = self.buffer.find('\n') {
+                            self.buffer.drain(..newline_pos + 1);
+                        } else {
+                            self.buffer.clear();
+                        }
+
+                        self.state = DiffParserState::Pending;
+                        edit_events.push(EditParserEvent::NewTextChunk { chunk, done: true });
+                    } else {
+                        if !self.ends_with_diff_marker_prefix() {
+                            edit_events.push(EditParserEvent::NewTextChunk {
+                                chunk: mem::take(&mut self.buffer),
+                                done: false,
+                            });
+                        }
+                        break;
+                    }
+                }
+            }
+        }
+        edit_events
+    }
+
+    fn take_metrics(&mut self) -> EditParserMetrics {
+        std::mem::take(&mut self.metrics)
+    }
+}
+
+impl EditParser {
+    pub fn new(format: EditFormat) -> Self {
+        let parser: Box<dyn EditFormatParser> = match format {
+            EditFormat::XmlTags => Box::new(XmlEditParser::new()),
+            EditFormat::DiffFenced => Box::new(DiffFencedEditParser::new()),
         };
-        Some(start_ix..start_ix + END_TAG_LEN)
+        EditParser { parser }
+    }
+
+    pub fn push(&mut self, chunk: &str) -> SmallVec<[EditParserEvent; 1]> {
+        self.parser.push(chunk)
     }
 
-    pub fn finish(self) -> EditParserMetrics {
-        self.metrics
+    pub fn finish(mut self) -> EditParserMetrics {
+        self.parser.take_metrics()
     }
 }
 
@@ -158,8 +444,8 @@ mod tests {
     use std::cmp;
 
     #[gpui::test(iterations = 1000)]
-    fn test_single_edit(mut rng: StdRng) {
-        let mut parser = EditParser::new();
+    fn test_xml_single_edit(mut rng: StdRng) {
+        let mut parser = EditParser::new(EditFormat::XmlTags);
         assert_eq!(
             parse_random_chunks(
                 "<old_text>original</old_text><new_text>updated</new_text>",
@@ -169,6 +455,7 @@ mod tests {
             vec![Edit {
                 old_text: "original".to_string(),
                 new_text: "updated".to_string(),
+                line_hint: None,
             }]
         );
         assert_eq!(
@@ -181,8 +468,8 @@ mod tests {
     }
 
     #[gpui::test(iterations = 1000)]
-    fn test_multiple_edits(mut rng: StdRng) {
-        let mut parser = EditParser::new();
+    fn test_xml_multiple_edits(mut rng: StdRng) {
+        let mut parser = EditParser::new(EditFormat::XmlTags);
         assert_eq!(
             parse_random_chunks(
                 indoc! {"
@@ -200,10 +487,12 @@ mod tests {
                 Edit {
                     old_text: "first old".to_string(),
                     new_text: "first new".to_string(),
+                    line_hint: None,
                 },
                 Edit {
                     old_text: "second old".to_string(),
                     new_text: "second new".to_string(),
+                    line_hint: None,
                 },
             ]
         );
@@ -217,8 +506,8 @@ mod tests {
     }
 
     #[gpui::test(iterations = 1000)]
-    fn test_edits_with_extra_text(mut rng: StdRng) {
-        let mut parser = EditParser::new();
+    fn test_xml_edits_with_extra_text(mut rng: StdRng) {
+        let mut parser = EditParser::new(EditFormat::XmlTags);
         assert_eq!(
             parse_random_chunks(
                 indoc! {"
@@ -235,14 +524,17 @@ mod tests {
                 Edit {
                     old_text: "content".to_string(),
                     new_text: "updated content".to_string(),
+                    line_hint: None,
                 },
                 Edit {
                     old_text: "second item".to_string(),
                     new_text: "modified second item".to_string(),
+                    line_hint: None,
                 },
                 Edit {
                     old_text: "third case".to_string(),
                     new_text: "improved third case".to_string(),
+                    line_hint: None,
                 },
             ]
         );
@@ -256,8 +548,8 @@ mod tests {
     }
 
     #[gpui::test(iterations = 1000)]
-    fn test_nested_tags(mut rng: StdRng) {
-        let mut parser = EditParser::new();
+    fn test_xml_nested_tags(mut rng: StdRng) {
+        let mut parser = EditParser::new(EditFormat::XmlTags);
         assert_eq!(
             parse_random_chunks(
                 "<old_text>code with <tag>nested</tag> elements</old_text><new_text>new <code>content</code></new_text>",
@@ -267,6 +559,7 @@ mod tests {
             vec![Edit {
                 old_text: "code with <tag>nested</tag> elements".to_string(),
                 new_text: "new <code>content</code>".to_string(),
+                line_hint: None,
             }]
         );
         assert_eq!(
@@ -279,8 +572,8 @@ mod tests {
     }
 
     #[gpui::test(iterations = 1000)]
-    fn test_empty_old_and_new_text(mut rng: StdRng) {
-        let mut parser = EditParser::new();
+    fn test_xml_empty_old_and_new_text(mut rng: StdRng) {
+        let mut parser = EditParser::new(EditFormat::XmlTags);
         assert_eq!(
             parse_random_chunks(
                 "<old_text></old_text><new_text></new_text>",
@@ -290,6 +583,7 @@ mod tests {
             vec![Edit {
                 old_text: "".to_string(),
                 new_text: "".to_string(),
+                line_hint: None,
             }]
         );
         assert_eq!(
@@ -302,8 +596,8 @@ mod tests {
     }
 
     #[gpui::test(iterations = 100)]
-    fn test_multiline_content(mut rng: StdRng) {
-        let mut parser = EditParser::new();
+    fn test_xml_multiline_content(mut rng: StdRng) {
+        let mut parser = EditParser::new(EditFormat::XmlTags);
         assert_eq!(
             parse_random_chunks(
                 "<old_text>line1\nline2\nline3</old_text><new_text>line1\nmodified line2\nline3</new_text>",
@@ -313,6 +607,7 @@ mod tests {
             vec![Edit {
                 old_text: "line1\nline2\nline3".to_string(),
                 new_text: "line1\nmodified line2\nline3".to_string(),
+                line_hint: None,
             }]
         );
         assert_eq!(
@@ -325,8 +620,8 @@ mod tests {
     }
 
     #[gpui::test(iterations = 1000)]
-    fn test_mismatched_tags(mut rng: StdRng) {
-        let mut parser = EditParser::new();
+    fn test_xml_mismatched_tags(mut rng: StdRng) {
+        let mut parser = EditParser::new(EditFormat::XmlTags);
         assert_eq!(
             parse_random_chunks(
                 // Reduced from an actual Sonnet 3.7 output
@@ -359,10 +654,12 @@ mod tests {
                 Edit {
                     old_text: "a\nb\nc".to_string(),
                     new_text: "a\nB\nc".to_string(),
+                    line_hint: None,
                 },
                 Edit {
                     old_text: "d\ne\nf".to_string(),
                     new_text: "D\ne\nF".to_string(),
+                    line_hint: None,
                 }
             ]
         );
@@ -373,12 +670,329 @@ mod tests {
                 mismatched_tags: 4
             }
         );
+
+        let mut parser = EditParser::new(EditFormat::XmlTags);
+        assert_eq!(
+            parse_random_chunks(
+                // Reduced from an actual Opus 4 output
+                indoc! {"
+                    <edits>
+                    <old_text>
+                    Lorem
+                    </old_text>
+                    <new_text>
+                    LOREM
+                    </edits>
+                "},
+                &mut parser,
+                &mut rng
+            ),
+            vec![Edit {
+                old_text: "Lorem".to_string(),
+                new_text: "LOREM".to_string(),
+                line_hint: None,
+            },]
+        );
+        assert_eq!(
+            parser.finish(),
+            EditParserMetrics {
+                tags: 2,
+                mismatched_tags: 1
+            }
+        );
+    }
+
+    #[gpui::test(iterations = 1000)]
+    fn test_diff_fenced_single_edit(mut rng: StdRng) {
+        let mut parser = EditParser::new(EditFormat::DiffFenced);
+        assert_eq!(
+            parse_random_chunks(
+                indoc! {"
+                    <<<<<<< SEARCH
+                    original text
+                    =======
+                    updated text
+                    >>>>>>> REPLACE
+                "},
+                &mut parser,
+                &mut rng
+            ),
+            vec![Edit {
+                old_text: "original text".to_string(),
+                new_text: "updated text".to_string(),
+                line_hint: None,
+            }]
+        );
+        assert_eq!(
+            parser.finish(),
+            EditParserMetrics {
+                tags: 0,
+                mismatched_tags: 0
+            }
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_diff_fenced_with_markdown_fences(mut rng: StdRng) {
+        let mut parser = EditParser::new(EditFormat::DiffFenced);
+        assert_eq!(
+            parse_random_chunks(
+                indoc! {"
+                    ```diff
+                    <<<<<<< SEARCH
+                    from flask import Flask
+                    =======
+                    import math
+                    from flask import Flask
+                    >>>>>>> REPLACE
+                    ```
+                "},
+                &mut parser,
+                &mut rng
+            ),
+            vec![Edit {
+                old_text: "from flask import Flask".to_string(),
+                new_text: "import math\nfrom flask import Flask".to_string(),
+                line_hint: None,
+            }]
+        );
+        assert_eq!(
+            parser.finish(),
+            EditParserMetrics {
+                tags: 0,
+                mismatched_tags: 0
+            }
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_diff_fenced_multiple_edits(mut rng: StdRng) {
+        let mut parser = EditParser::new(EditFormat::DiffFenced);
+        assert_eq!(
+            parse_random_chunks(
+                indoc! {"
+                    <<<<<<< SEARCH
+                    first old
+                    =======
+                    first new
+                    >>>>>>> REPLACE
+
+                    <<<<<<< SEARCH
+                    second old
+                    =======
+                    second new
+                    >>>>>>> REPLACE
+                "},
+                &mut parser,
+                &mut rng
+            ),
+            vec![
+                Edit {
+                    old_text: "first old".to_string(),
+                    new_text: "first new".to_string(),
+                    line_hint: None,
+                },
+                Edit {
+                    old_text: "second old".to_string(),
+                    new_text: "second new".to_string(),
+                    line_hint: None,
+                },
+            ]
+        );
+        assert_eq!(
+            parser.finish(),
+            EditParserMetrics {
+                tags: 0,
+                mismatched_tags: 0
+            }
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_mixed_formats(mut rng: StdRng) {
+        // Test XML format parser only parses XML tags
+        let mut xml_parser = EditParser::new(EditFormat::XmlTags);
+        assert_eq!(
+            parse_random_chunks(
+                indoc! {"
+                    <old_text>xml style old</old_text><new_text>xml style new</new_text>
+
+                    <<<<<<< SEARCH
+                    diff style old
+                    =======
+                    diff style new
+                    >>>>>>> REPLACE
+                "},
+                &mut xml_parser,
+                &mut rng
+            ),
+            vec![Edit {
+                old_text: "xml style old".to_string(),
+                new_text: "xml style new".to_string(),
+                line_hint: None,
+            },]
+        );
+        assert_eq!(
+            xml_parser.finish(),
+            EditParserMetrics {
+                tags: 2,
+                mismatched_tags: 0
+            }
+        );
+
+        // Test diff-fenced format parser only parses diff markers
+        let mut diff_parser = EditParser::new(EditFormat::DiffFenced);
+        assert_eq!(
+            parse_random_chunks(
+                indoc! {"
+                    <old_text>xml style old</old_text><new_text>xml style new</new_text>
+
+                    <<<<<<< SEARCH
+                    diff style old
+                    =======
+                    diff style new
+                    >>>>>>> REPLACE
+                "},
+                &mut diff_parser,
+                &mut rng
+            ),
+            vec![Edit {
+                old_text: "diff style old".to_string(),
+                new_text: "diff style new".to_string(),
+                line_hint: None,
+            },]
+        );
+        assert_eq!(
+            diff_parser.finish(),
+            EditParserMetrics {
+                tags: 0,
+                mismatched_tags: 0
+            }
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_diff_fenced_empty_sections(mut rng: StdRng) {
+        let mut parser = EditParser::new(EditFormat::DiffFenced);
+        assert_eq!(
+            parse_random_chunks(
+                indoc! {"
+                <<<<<<< SEARCH
+                =======
+                >>>>>>> REPLACE
+            "},
+                &mut parser,
+                &mut rng
+            ),
+            vec![Edit {
+                old_text: "".to_string(),
+                new_text: "".to_string(),
+                line_hint: None,
+            }]
+        );
+        assert_eq!(
+            parser.finish(),
+            EditParserMetrics {
+                tags: 0,
+                mismatched_tags: 0
+            }
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_diff_fenced_with_line_hint(mut rng: StdRng) {
+        let mut parser = EditParser::new(EditFormat::DiffFenced);
+        let edits = parse_random_chunks(
+            indoc! {"
+                <<<<<<< SEARCH line=42
+                original text
+                =======
+                updated text
+                >>>>>>> REPLACE
+            "},
+            &mut parser,
+            &mut rng,
+        );
+        assert_eq!(
+            edits,
+            vec![Edit {
+                old_text: "original text".to_string(),
+                line_hint: Some(42),
+                new_text: "updated text".to_string(),
+            }]
+        );
+    }
+    #[gpui::test(iterations = 100)]
+    fn test_xml_line_hints(mut rng: StdRng) {
+        // Line hint is a single quoted line number
+        let mut parser = EditParser::new(EditFormat::XmlTags);
+
+        let edits = parse_random_chunks(
+            r#"
+                    <old_text line="23">original code</old_text>
+                    <new_text>updated code</new_text>"#,
+            &mut parser,
+            &mut rng,
+        );
+
+        assert_eq!(edits.len(), 1);
+        assert_eq!(edits[0].old_text, "original code");
+        assert_eq!(edits[0].line_hint, Some(23));
+        assert_eq!(edits[0].new_text, "updated code");
+
+        // Line hint is a single unquoted line number
+        let mut parser = EditParser::new(EditFormat::XmlTags);
+
+        let edits = parse_random_chunks(
+            r#"
+                    <old_text line=45>original code</old_text>
+                    <new_text>updated code</new_text>"#,
+            &mut parser,
+            &mut rng,
+        );
+
+        assert_eq!(edits.len(), 1);
+        assert_eq!(edits[0].old_text, "original code");
+        assert_eq!(edits[0].line_hint, Some(45));
+        assert_eq!(edits[0].new_text, "updated code");
+
+        // Line hint is a range
+        let mut parser = EditParser::new(EditFormat::XmlTags);
+
+        let edits = parse_random_chunks(
+            r#"
+            <old_text line="23:50">original code</old_text>
+            <new_text>updated code</new_text>"#,
+            &mut parser,
+            &mut rng,
+        );
+
+        assert_eq!(edits.len(), 1);
+        assert_eq!(edits[0].old_text, "original code");
+        assert_eq!(edits[0].line_hint, Some(23));
+        assert_eq!(edits[0].new_text, "updated code");
+
+        // No line hint
+        let mut parser = EditParser::new(EditFormat::XmlTags);
+        let edits = parse_random_chunks(
+            r#"
+            <old_text>old</old_text>
+            <new_text>new</new_text>"#,
+            &mut parser,
+            &mut rng,
+        );
+
+        assert_eq!(edits.len(), 1);
+        assert_eq!(edits[0].old_text, "old");
+        assert_eq!(edits[0].line_hint, None);
+        assert_eq!(edits[0].new_text, "new");
     }
 
     #[derive(Default, Debug, PartialEq, Eq)]
     struct Edit {
         old_text: String,
         new_text: String,
+        line_hint: Option<u32>,
     }
 
     fn parse_random_chunks(input: &str, parser: &mut EditParser, rng: &mut StdRng) -> Vec<Edit> {
@@ -387,26 +1001,40 @@ mod tests {
         chunk_indices.sort();
         chunk_indices.push(input.len());
 
+        let mut old_text = Some(String::new());
+        let mut new_text = None;
         let mut pending_edit = Edit::default();
         let mut edits = Vec::new();
         let mut last_ix = 0;
         for chunk_ix in chunk_indices {
             for event in parser.push(&input[last_ix..chunk_ix]) {
                 match event {
-                    EditParserEvent::OldText(old_text) => {
-                        pending_edit.old_text = old_text;
+                    EditParserEvent::OldTextChunk {
+                        chunk,
+                        done,
+                        line_hint,
+                    } => {
+                        old_text.as_mut().unwrap().push_str(&chunk);
+                        if done {
+                            pending_edit.old_text = old_text.take().unwrap();
+                            pending_edit.line_hint = line_hint;
+                            new_text = Some(String::new());
+                        }
                     }
                     EditParserEvent::NewTextChunk { chunk, done } => {
-                        pending_edit.new_text.push_str(&chunk);
+                        new_text.as_mut().unwrap().push_str(&chunk);
                         if done {
+                            pending_edit.new_text = new_text.take().unwrap();
                             edits.push(pending_edit);
                             pending_edit = Edit::default();
+                            old_text = Some(String::new());
                         }
                     }
                 }
             }
             last_ix = chunk_ix;
         }
+
         edits
     }
 }

crates/assistant_tools/src/edit_agent/evals.rs 🔗

@@ -1,19 +1,24 @@
 use super::*;
-use crate::{ReadFileToolInput, edit_file_tool::EditFileToolInput, grep_tool::GrepToolInput};
+use crate::{
+    ReadFileToolInput,
+    edit_file_tool::{EditFileMode, EditFileToolInput},
+    grep_tool::GrepToolInput,
+    list_directory_tool::ListDirectoryToolInput,
+};
 use Role::*;
-use anyhow::anyhow;
 use assistant_tool::ToolRegistry;
 use client::{Client, UserStore};
 use collections::HashMap;
 use fs::FakeFs;
 use futures::{FutureExt, future::LocalBoxFuture};
-use gpui::{AppContext, TestAppContext};
+use gpui::{AppContext, TestAppContext, Timer};
 use indoc::{formatdoc, indoc};
 use language_model::{
-    LanguageModelRegistry, LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolUse,
-    LanguageModelToolUseId,
+    LanguageModelRegistry, LanguageModelRequestTool, LanguageModelToolResult,
+    LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseId, SelectedModel,
 };
 use project::Project;
+use prompt_store::{ModelContext, ProjectContext, PromptBuilder, WorktreeContext};
 use rand::prelude::*;
 use reqwest_client::ReqwestClient;
 use serde_json::json;
@@ -21,6 +26,8 @@ use std::{
     cmp::Reverse,
     fmt::{self, Display},
     io::Write as _,
+    path::Path,
+    str::FromStr,
     sync::mpsc,
 };
 use util::path;
@@ -28,21 +35,41 @@ use util::path;
 #[test]
 #[cfg_attr(not(feature = "eval"), ignore)]
 fn eval_extract_handle_command_output() {
+    // Test how well agent generates multiple edit hunks.
+    //
+    // Model                       | Pass rate
+    // ----------------------------|----------
+    // claude-3.7-sonnet           |  0.99 (2025-06-14)
+    // claude-sonnet-4             |  0.97 (2025-06-14)
+    // gemini-2.5-pro-06-05        |  0.98 (2025-06-16)
+    // gemini-2.5-flash            |  0.11 (2025-05-22)
+    // gpt-4.1                     |  1.00 (2025-05-22)
+
     let input_file_path = "root/blame.rs";
     let input_file_content = include_str!("evals/fixtures/extract_handle_command_output/before.rs");
-    let output_file_content = include_str!("evals/fixtures/extract_handle_command_output/after.rs");
+    let possible_diffs = vec![
+        include_str!("evals/fixtures/extract_handle_command_output/possible-01.diff"),
+        include_str!("evals/fixtures/extract_handle_command_output/possible-02.diff"),
+        include_str!("evals/fixtures/extract_handle_command_output/possible-03.diff"),
+        include_str!("evals/fixtures/extract_handle_command_output/possible-04.diff"),
+        include_str!("evals/fixtures/extract_handle_command_output/possible-05.diff"),
+        include_str!("evals/fixtures/extract_handle_command_output/possible-06.diff"),
+        include_str!("evals/fixtures/extract_handle_command_output/possible-07.diff"),
+    ];
     let edit_description = "Extract `handle_command_output` method from `run_git_blame`.";
     eval(
         100,
         0.95,
-        EvalInput {
-            conversation: vec![
+        0.05,
+        EvalInput::from_conversation(
+            vec![
                 message(
                     User,
                     [text(formatdoc! {"
                         Read the `{input_file_path}` file and extract a method in
                         the final stanza of `run_git_blame` to deal with command failures,
                         call it `handle_command_output` and take the std::process::Output as the only parameter.
+                        Do not document the method and do not add any comments.
 
                         Add it right next to `run_git_blame` and copy it verbatim from `run_git_blame`.
                     "})],
@@ -71,22 +98,27 @@ fn eval_extract_handle_command_output() {
                         EditFileToolInput {
                             display_description: edit_description.into(),
                             path: input_file_path.into(),
-                            create_or_overwrite: false,
+                            mode: EditFileMode::Edit,
                         },
                     )],
                 ),
             ],
-            input_path: input_file_path.into(),
-            input_content: Some(input_file_content.into()),
-            edit_description: edit_description.into(),
-            assertion: EvalAssertion::assert_eq(output_file_content),
-        },
+            Some(input_file_content.into()),
+            EvalAssertion::assert_diff_any(possible_diffs),
+        ),
     );
 }
 
 #[test]
 #[cfg_attr(not(feature = "eval"), ignore)]
 fn eval_delete_run_git_blame() {
+    // Model                       | Pass rate
+    // ----------------------------|----------
+    // claude-3.7-sonnet           | 1.0  (2025-06-14)
+    // claude-sonnet-4             | 0.96 (2025-06-14)
+    // gemini-2.5-pro-06-05        | 1.0  (2025-06-16)
+    // gemini-2.5-flash            |
+    // gpt-4.1                     |
     let input_file_path = "root/blame.rs";
     let input_file_content = include_str!("evals/fixtures/delete_run_git_blame/before.rs");
     let output_file_content = include_str!("evals/fixtures/delete_run_git_blame/after.rs");
@@ -94,8 +126,9 @@ fn eval_delete_run_git_blame() {
     eval(
         100,
         0.95,
-        EvalInput {
-            conversation: vec![
+        0.05,
+        EvalInput::from_conversation(
+            vec![
                 message(
                     User,
                     [text(formatdoc! {"
@@ -127,30 +160,37 @@ fn eval_delete_run_git_blame() {
                         EditFileToolInput {
                             display_description: edit_description.into(),
                             path: input_file_path.into(),
-                            create_or_overwrite: false,
+                            mode: EditFileMode::Edit,
                         },
                     )],
                 ),
             ],
-            input_path: input_file_path.into(),
-            input_content: Some(input_file_content.into()),
-            edit_description: edit_description.into(),
-            assertion: EvalAssertion::assert_eq(output_file_content),
-        },
+            Some(input_file_content.into()),
+            EvalAssertion::assert_eq(output_file_content),
+        ),
     );
 }
 
 #[test]
 #[cfg_attr(not(feature = "eval"), ignore)]
 fn eval_translate_doc_comments() {
+    //  Model                          | Pass rate
+    // ============================================
+    //
+    //  claude-3.7-sonnet              |  1.0  (2025-06-14)
+    //  claude-sonnet-4                |  1.0  (2025-06-14)
+    //  gemini-2.5-pro-preview-03-25   |  1.0  (2025-05-22)
+    //  gemini-2.5-flash-preview-04-17 |
+    //  gpt-4.1                        |
     let input_file_path = "root/canvas.rs";
     let input_file_content = include_str!("evals/fixtures/translate_doc_comments/before.rs");
     let edit_description = "Translate all doc comments to Italian";
     eval(
         200,
         1.,
-        EvalInput {
-            conversation: vec![
+        0.05,
+        EvalInput::from_conversation(
+            vec![
                 message(
                     User,
                     [text(formatdoc! {"
@@ -182,22 +222,28 @@ fn eval_translate_doc_comments() {
                         EditFileToolInput {
                             display_description: edit_description.into(),
                             path: input_file_path.into(),
-                            create_or_overwrite: false,
+                            mode: EditFileMode::Edit,
                         },
                     )],
                 ),
             ],
-            input_path: input_file_path.into(),
-            input_content: Some(input_file_content.into()),
-            edit_description: edit_description.into(),
-            assertion: EvalAssertion::judge_diff("Doc comments were translated to Italian"),
-        },
+            Some(input_file_content.into()),
+            EvalAssertion::judge_diff("Doc comments were translated to Italian"),
+        ),
     );
 }
 
 #[test]
 #[cfg_attr(not(feature = "eval"), ignore)]
 fn eval_use_wasi_sdk_in_compile_parser_to_wasm() {
+    //  Model                          | Pass rate
+    // ============================================
+    //
+    //  claude-3.7-sonnet              |  0.96 (2025-06-14)
+    //  claude-sonnet-4                |  0.11 (2025-06-14)
+    //  gemini-2.5-pro-preview-latest  |  0.99 (2025-06-16)
+    //  gemini-2.5-flash-preview-04-17 |
+    //  gpt-4.1                        |
     let input_file_path = "root/lib.rs";
     let input_file_content =
         include_str!("evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs");
@@ -205,8 +251,9 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() {
     eval(
         100,
         0.95,
-        EvalInput {
-            conversation: vec![
+        0.05,
+        EvalInput::from_conversation(
+            vec![
                 message(
                     User,
                     [text(formatdoc! {"
@@ -297,33 +344,40 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() {
                         EditFileToolInput {
                             display_description: edit_description.into(),
                             path: input_file_path.into(),
-                            create_or_overwrite: false,
+                            mode: EditFileMode::Edit,
                         },
                     )],
                 ),
             ],
-            input_path: input_file_path.into(),
-            input_content: Some(input_file_content.into()),
-            edit_description: edit_description.into(),
-            assertion: EvalAssertion::judge_diff(indoc! {"
+            Some(input_file_content.into()),
+            EvalAssertion::judge_diff(indoc! {"
                 - The compile_parser_to_wasm method has been changed to use wasi-sdk
                 - ureq is used to download the SDK for current platform and architecture
             "}),
-        },
+        ),
     );
 }
 
 #[test]
 #[cfg_attr(not(feature = "eval"), ignore)]
 fn eval_disable_cursor_blinking() {
+    //  Model                          | Pass rate
+    // ============================================
+    //
+    //  claude-3.7-sonnet              |  0.99 (2025-06-14)
+    //  claude-sonnet-4                |  0.85 (2025-06-14)
+    //  gemini-2.5-pro-preview-latest  |  0.97 (2025-06-16)
+    //  gemini-2.5-flash-preview-04-17 |
+    //  gpt-4.1                        |
     let input_file_path = "root/editor.rs";
     let input_file_content = include_str!("evals/fixtures/disable_cursor_blinking/before.rs");
     let edit_description = "Comment out the call to `BlinkManager::enable`";
     eval(
-        200,
+        100,
         0.95,
-        EvalInput {
-            conversation: vec![
+        0.05,
+        EvalInput::from_conversation(
+            vec![
                 message(User, [text("Let's research how to cursor blinking works.")]),
                 message(
                     Assistant,
@@ -372,34 +426,50 @@ fn eval_disable_cursor_blinking() {
                         EditFileToolInput {
                             display_description: edit_description.into(),
                             path: input_file_path.into(),
-                            create_or_overwrite: false,
+                            mode: EditFileMode::Edit,
                         },
                     )],
                 ),
             ],
-            input_path: input_file_path.into(),
-            input_content: Some(input_file_content.into()),
-            edit_description: edit_description.into(),
-            assertion: EvalAssertion::judge_diff(indoc! {"
+            Some(input_file_content.into()),
+            EvalAssertion::judge_diff(indoc! {"
                 - Calls to BlinkManager in `observe_window_activation` were commented out
                 - The call to `blink_manager.enable` above the call to show_cursor_names was commented out
                 - All the edits have valid indentation
             "}),
-        },
+        ),
     );
 }
 
 #[test]
 #[cfg_attr(not(feature = "eval"), ignore)]
 fn eval_from_pixels_constructor() {
+    // Results for 2025-06-13
+    //
+    // The outcome of this evaluation depends heavily on the LINE_HINT_TOLERANCE
+    // value. Higher values improve the pass rate but may sometimes cause
+    // edits to be misapplied. In the context of this eval, this means
+    // the agent might add from_pixels tests in incorrect locations
+    // (e.g., at the beginning of the file), yet the evaluation may still
+    // rate it highly.
+    //
+    //  Model                          | Date        | Pass rate
+    // =========================================================
+    //  claude-4.0-sonnet              | 2025-06-14  | 0.99
+    //  claude-3.7-sonnet              | 2025-06-14  | 0.88
+    //  gemini-2.5-pro-preview-06-05   | 2025-06-16  | 0.98
+    //  gpt-4.1                        |
     let input_file_path = "root/canvas.rs";
     let input_file_content = include_str!("evals/fixtures/from_pixels_constructor/before.rs");
     let edit_description = "Implement from_pixels constructor and add tests.";
     eval(
         100,
         0.95,
-        EvalInput {
-            conversation: vec![
+        // For whatever reason, this eval produces more mismatched tags.
+        // Increasing for now, let's see if we can bring this down.
+        0.25,
+        EvalInput::from_conversation(
+            vec![
                 message(
                     User,
                     [text(indoc! {"
@@ -566,32 +636,40 @@ fn eval_from_pixels_constructor() {
                         EditFileToolInput {
                             display_description: edit_description.into(),
                             path: input_file_path.into(),
-                            create_or_overwrite: false,
+                            mode: EditFileMode::Edit,
                         },
                     )],
                 ),
             ],
-            input_path: input_file_path.into(),
-            input_content: Some(input_file_content.into()),
-            edit_description: edit_description.into(),
-            assertion: EvalAssertion::judge_diff(indoc! {"
-                - The diff contains a new `from_pixels` constructor
-                - The diff contains new tests for the `from_pixels` constructor
-            "}),
-        },
+            Some(input_file_content.into()),
+            EvalAssertion::judge_diff(indoc! {"
+                    - The diff contains a new `from_pixels` constructor
+                    - The diff contains new tests for the `from_pixels` constructor
+                "}),
+        ),
     );
 }
 
 #[test]
 #[cfg_attr(not(feature = "eval"), ignore)]
 fn eval_zode() {
+    //  Model                          | Pass rate
+    // ============================================
+    //
+    //  claude-3.7-sonnet              |  1.0 (2025-06-14)
+    //  claude-sonnet-4                |  1.0 (2025-06-14)
+    //  gemini-2.5-pro-preview-03-25   |  1.0 (2025-05-22)
+    //  gemini-2.5-flash-preview-04-17 |  1.0 (2025-05-22)
+    //  gpt-4.1                        |  1.0 (2025-05-22)
     let input_file_path = "root/zode.py";
+    let input_content = None;
     let edit_description = "Create the main Zode CLI script";
     eval(
-        200,
+        50,
         1.,
-        EvalInput {
-            conversation: vec![
+        0.05,
+        EvalInput::from_conversation(
+            vec![
                 message(User, [text(include_str!("evals/fixtures/zode/prompt.md"))]),
                 message(
                     Assistant,
@@ -643,20 +721,18 @@ fn eval_zode() {
                             EditFileToolInput {
                                 display_description: edit_description.into(),
                                 path: input_file_path.into(),
-                                create_or_overwrite: true,
+                                mode: EditFileMode::Create,
                             },
                         ),
                     ],
                 ),
             ],
-            input_path: input_file_path.into(),
-            input_content: None,
-            edit_description: edit_description.into(),
-            assertion: EvalAssertion::new(async move |sample, _, _cx| {
+            input_content,
+            EvalAssertion::new(async move |sample, _, _cx| {
                 let invalid_starts = [' ', '`', '\n'];
                 let mut message = String::new();
                 for start in invalid_starts {
-                    if sample.text.starts_with(start) {
+                    if sample.text_after.starts_with(start) {
                         message.push_str(&format!("The sample starts with a {:?}\n", start));
                         break;
                     }
@@ -676,21 +752,30 @@ fn eval_zode() {
                     })
                 }
             }),
-        },
+        ),
     );
 }
 
 #[test]
 #[cfg_attr(not(feature = "eval"), ignore)]
 fn eval_add_overwrite_test() {
+    //  Model                          | Pass rate
+    // ============================================
+    //
+    //  claude-3.7-sonnet              |  0.65 (2025-06-14)
+    //  claude-sonnet-4                |  0.07 (2025-06-14)
+    //  gemini-2.5-pro-preview-03-25   |  0.35 (2025-05-22)
+    //  gemini-2.5-flash-preview-04-17 |
+    //  gpt-4.1                        |
     let input_file_path = "root/action_log.rs";
     let input_file_content = include_str!("evals/fixtures/add_overwrite_test/before.rs");
     let edit_description = "Add a new test for overwriting a file in action_log.rs";
     eval(
         200,
         0.5, // TODO: make this eval better
-        EvalInput {
-            conversation: vec![
+        0.05,
+        EvalInput::from_conversation(
+            vec![
                 message(
                     User,
                     [text(indoc! {"
@@ -888,19 +973,96 @@ fn eval_add_overwrite_test() {
                             EditFileToolInput {
                                 display_description: edit_description.into(),
                                 path: input_file_path.into(),
-                                create_or_overwrite: false,
+                                mode: EditFileMode::Edit,
                             },
                         ),
                     ],
                 ),
             ],
-            input_path: input_file_path.into(),
-            input_content: Some(input_file_content.into()),
-            edit_description: edit_description.into(),
-            assertion: EvalAssertion::judge_diff(
+            Some(input_file_content.into()),
+            EvalAssertion::judge_diff(
                 "A new test for overwritten files was created, without changing any previous test",
             ),
-        },
+        ),
+    );
+}
+
+#[test]
+#[cfg_attr(not(feature = "eval"), ignore)]
+fn eval_create_empty_file() {
+    // Check that Edit Agent can create a file without writing its
+    // thoughts into it. This issue is not specific to empty files, but
+    // it's easier to reproduce with them.
+    //
+    //  Model                          | Pass rate
+    // ============================================
+    //
+    //  claude-3.7-sonnet              |  1.00 (2025-06-14)
+    //  claude-sonnet-4                |  1.00 (2025-06-14)
+    //  gemini-2.5-pro-preview-03-25   |  1.00 (2025-05-21)
+    //  gemini-2.5-flash-preview-04-17 |  1.00 (2025-05-21)
+    //  gpt-4.1                        |  1.00 (2025-05-21)
+    //
+    //
+    // TODO: gpt-4.1-mini errored 38 times:
+    // "data did not match any variant of untagged enum ResponseStreamResult"
+    //
+    let input_file_content = None;
+    let expected_output_content = String::new();
+    eval(
+        100,
+        0.99,
+        0.05,
+        EvalInput::from_conversation(
+            vec![
+                message(User, [text("Create a second empty todo file ")]),
+                message(
+                    Assistant,
+                    [
+                        text(formatdoc! {"
+                        I'll help you create a second empty todo file.
+                        First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one.
+                        "}),
+                        tool_use(
+                            "toolu_01GAF8TtsgpjKxCr8fgQLDgR",
+                            "list_directory",
+                            ListDirectoryToolInput {
+                                path: "root".to_string(),
+                            },
+                        ),
+                    ],
+                ),
+                message(
+                    User,
+                    [tool_result(
+                        "toolu_01GAF8TtsgpjKxCr8fgQLDgR",
+                        "list_directory",
+                        "root/TODO\nroot/TODO2\nroot/new.txt\n",
+                    )],
+                ),
+                message(
+                    Assistant,
+                    [
+                        text(formatdoc! {"
+                        I can see there's already a `TODO` file in the `root` directory. Let me create a second empty todo file called `TODO3` in the same directory:
+                    "}),
+                        tool_use(
+                            "toolu_01Tb3iQ9griqSYMmVuykQPWU",
+                            "edit_file",
+                            EditFileToolInput {
+                                display_description: "Create empty TODO3 file".to_string(),
+                                mode: EditFileMode::Create,
+                                path: "root/TODO3".into(),
+                            },
+                        ),
+                    ],
+                ),
+            ],
+            input_file_content,
+            // Bad behavior is to write something like
+            // "I'll create an empty TODO3 file as requested."
+            EvalAssertion::assert_eq(expected_output_content),
+        ),
     );
 }
 
@@ -951,7 +1113,7 @@ fn tool_result(
         tool_use_id: LanguageModelToolUseId::from(id.into()),
         tool_name: name.into(),
         is_error: false,
-        content: result.into(),
+        content: LanguageModelToolResultContent::Text(result.into()),
         output: None,
     })
 }
@@ -959,15 +1121,50 @@ fn tool_result(
 #[derive(Clone)]
 struct EvalInput {
     conversation: Vec<LanguageModelRequestMessage>,
-    input_path: PathBuf,
+    edit_file_input: EditFileToolInput,
     input_content: Option<String>,
-    edit_description: String,
     assertion: EvalAssertion,
 }
 
+impl EvalInput {
+    fn from_conversation(
+        conversation: Vec<LanguageModelRequestMessage>,
+        input_content: Option<String>,
+        assertion: EvalAssertion,
+    ) -> Self {
+        let msg = conversation.last().expect("Conversation must not be empty");
+        if msg.role != Role::Assistant {
+            panic!("Conversation must end with an assistant message");
+        }
+        let tool_use = msg
+            .content
+            .iter()
+            .flat_map(|content| match content {
+                MessageContent::ToolUse(tool_use) if tool_use.name == "edit_file".into() => {
+                    Some(tool_use)
+                }
+                _ => None,
+            })
+            .next()
+            .expect("Conversation must end with an edit_file tool use")
+            .clone();
+
+        let edit_file_input: EditFileToolInput =
+            serde_json::from_value(tool_use.input.clone()).unwrap();
+
+        EvalInput {
+            conversation,
+            edit_file_input,
+            input_content,
+            assertion,
+        }
+    }
+}
+
 #[derive(Clone)]
 struct EvalSample {
-    text: String,
+    text_before: String,
+    text_after: String,
     edit_output: EditAgentOutput,
     diff: String,
 }
@@ -1024,7 +1221,7 @@ impl EvalAssertion {
         let expected = expected.into();
         Self::new(async move |sample, _judge, _cx| {
             Ok(EvalAssertionOutcome {
-                score: if strip_empty_lines(&sample.text) == strip_empty_lines(&expected) {
+                score: if strip_empty_lines(&sample.text_after) == strip_empty_lines(&expected) {
                     100
                 } else {
                     0
@@ -1034,6 +1231,22 @@ impl EvalAssertion {
         })
     }
 
+    fn assert_diff_any(expected_diffs: Vec<impl Into<String>>) -> Self {
+        let expected_diffs: Vec<String> = expected_diffs.into_iter().map(Into::into).collect();
+        Self::new(async move |sample, _judge, _cx| {
+            let matches = expected_diffs.iter().any(|possible_diff| {
+                let expected =
+                    language::apply_diff_patch(&sample.text_before, possible_diff).unwrap();
+                strip_empty_lines(&expected) == strip_empty_lines(&sample.text_after)
+            });
+
+            Ok(EvalAssertionOutcome {
+                score: if matches { 100 } else { 0 },
+                message: None,
+            })
+        })
+    }
+
     fn judge_diff(assertions: &'static str) -> Self {
         Self::new(async move |sample, judge, cx| {
             let prompt = DiffJudgeTemplate {
@@ -1051,9 +1264,12 @@ impl EvalAssertion {
                 }],
                 ..Default::default()
             };
-            let mut response = judge
-                .stream_completion_text(request, &cx.to_async())
-                .await?;
+            let mut response = retry_on_rate_limit(async || {
+                Ok(judge
+                    .stream_completion_text(request.clone(), &cx.to_async())
+                    .await?)
+            })
+            .await?;
             let mut output = String::new();
             while let Some(chunk) = response.stream.next().await {
                 let chunk = chunk?;
@@ -1072,10 +1288,7 @@ impl EvalAssertion {
                 }
             }
 
-            Err(anyhow!(
-                "No score found in response. Raw output: {}",
-                output
-            ))
+            anyhow::bail!("No score found in response. Raw output: {output}");
         })
     }
 
@@ -1089,7 +1302,12 @@ impl EvalAssertion {
     }
 }
 
-fn eval(iterations: usize, expected_pass_ratio: f32, mut eval: EvalInput) {
+fn eval(
+    iterations: usize,
+    expected_pass_ratio: f32,
+    mismatched_tag_threshold: f32,
+    mut eval: EvalInput,
+) {
     let mut evaluated_count = 0;
     let mut failed_count = 0;
     report_progress(evaluated_count, failed_count, iterations);
@@ -1102,10 +1320,17 @@ fn eval(iterations: usize, expected_pass_ratio: f32, mut eval: EvalInput) {
     run_eval(eval.clone(), tx.clone());
 
     let executor = gpui::background_executor();
+    let semaphore = Arc::new(smol::lock::Semaphore::new(32));
     for _ in 1..iterations {
         let eval = eval.clone();
         let tx = tx.clone();
-        executor.spawn(async move { run_eval(eval, tx) }).detach();
+        let semaphore = semaphore.clone();
+        executor
+            .spawn(async move {
+                let _guard = semaphore.acquire().await;
+                run_eval(eval, tx)
+            })
+            .detach();
     }
     drop(tx);
 
@@ -1121,7 +1346,7 @@ fn eval(iterations: usize, expected_pass_ratio: f32, mut eval: EvalInput) {
                 if output.assertion.score < 80 {
                     failed_count += 1;
                     failed_evals
-                        .entry(output.sample.text.clone())
+                        .entry(output.sample.text_after.clone())
                         .or_insert(Vec::new())
                         .push(output);
                 }
@@ -1161,7 +1386,7 @@ fn eval(iterations: usize, expected_pass_ratio: f32, mut eval: EvalInput) {
 
     let mismatched_tag_ratio =
         cumulative_parser_metrics.mismatched_tags as f32 / cumulative_parser_metrics.tags as f32;
-    if mismatched_tag_ratio > 0.05 {
+    if mismatched_tag_ratio > mismatched_tag_threshold {
         for eval_output in eval_outputs {
             println!("{}", eval_output);
         }
@@ -1212,7 +1437,7 @@ fn report_progress(evaluated_count: usize, failed_count: usize, iterations: usiz
         passed_count as f64 / evaluated_count as f64
     };
     print!(
-        "\r\x1b[KEvaluated {}/{} ({:.2}%)",
+        "\r\x1b[KEvaluated {}/{} ({:.2}% passed)",
         evaluated_count,
         iterations,
         passed_ratio * 100.0
@@ -1245,43 +1470,61 @@ impl EditAgentTest {
             Project::init_settings(cx);
             language::init(cx);
             language_model::init(client.clone(), cx);
-            language_models::init(user_store.clone(), client.clone(), fs.clone(), cx);
+            language_models::init(user_store.clone(), client.clone(), cx);
             crate::init(client.http_client(), cx);
         });
 
         fs.insert_tree("/root", json!({})).await;
         let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+        let agent_model = SelectedModel::from_str(
+            &std::env::var("ZED_AGENT_MODEL")
+                .unwrap_or("anthropic/claude-3-7-sonnet-latest".into()),
+        )
+        .unwrap();
+        let judge_model = SelectedModel::from_str(
+            &std::env::var("ZED_JUDGE_MODEL")
+                .unwrap_or("anthropic/claude-3-7-sonnet-latest".into()),
+        )
+        .unwrap();
         let (agent_model, judge_model) = cx
             .update(|cx| {
                 cx.spawn(async move |cx| {
-                    let agent_model =
-                        Self::load_model("anthropic", "claude-3-7-sonnet-latest", cx).await;
-                    let judge_model =
-                        Self::load_model("anthropic", "claude-3-7-sonnet-latest", cx).await;
+                    let agent_model = Self::load_model(&agent_model, cx).await;
+                    let judge_model = Self::load_model(&judge_model, cx).await;
                     (agent_model.unwrap(), judge_model.unwrap())
                 })
             })
             .await;
         let action_log = cx.new(|_| ActionLog::new(project.clone()));
 
+        let edit_format = EditFormat::from_env(agent_model.clone()).unwrap();
+
         Self {
-            agent: EditAgent::new(agent_model, project.clone(), action_log, Templates::new()),
+            agent: EditAgent::new(
+                agent_model,
+                project.clone(),
+                action_log,
+                Templates::new(),
+                edit_format,
+            ),
             project,
             judge_model,
         }
     }
 
     async fn load_model(
-        provider: &str,
-        id: &str,
+        selected_model: &SelectedModel,
         cx: &mut AsyncApp,
     ) -> Result<Arc<dyn LanguageModel>> {
         let (provider, model) = cx.update(|cx| {
             let models = LanguageModelRegistry::read_global(cx);
             let model = models
                 .available_models(cx)
-                .find(|model| model.provider_id().0 == provider && model.id().0 == id)
-                .unwrap();
+                .find(|model| {
+                    model.provider_id() == selected_model.provider
+                        && model.id() == selected_model.model
+                })
+                .expect("Model not found");
             let provider = models.provider(&model.provider_id()).unwrap();
             (provider, model)
         })?;
@@ -1293,7 +1536,7 @@ impl EditAgentTest {
         let path = self
             .project
             .read_with(cx, |project, cx| {
-                project.find_project_path(eval.input_path, cx)
+                project.find_project_path(eval.edit_file_input.path, cx)
             })
             .unwrap();
         let buffer = self
@@ -1301,43 +1544,92 @@ impl EditAgentTest {
             .update(cx, |project, cx| project.open_buffer(path, cx))
             .await
             .unwrap();
-        let conversation = LanguageModelRequest {
-            messages: eval.conversation,
-            tools: cx.update(|cx| {
-                ToolRegistry::default_global(cx)
-                    .tools()
-                    .into_iter()
-                    .filter_map(|tool| {
-                        let input_schema = tool
-                            .input_schema(self.agent.model.tool_input_format())
-                            .ok()?;
-                        Some(LanguageModelRequestTool {
-                            name: tool.name(),
-                            description: tool.description(),
-                            input_schema,
-                        })
+        let tools = cx.update(|cx| {
+            ToolRegistry::default_global(cx)
+                .tools()
+                .into_iter()
+                .filter_map(|tool| {
+                    let input_schema = tool
+                        .input_schema(self.agent.model.tool_input_format())
+                        .ok()?;
+                    Some(LanguageModelRequestTool {
+                        name: tool.name(),
+                        description: tool.description(),
+                        input_schema,
                     })
-                    .collect()
-            }),
+                })
+                .collect::<Vec<_>>()
+        });
+        let tool_names = tools
+            .iter()
+            .map(|tool| tool.name.clone())
+            .collect::<Vec<_>>();
+        let worktrees = vec![WorktreeContext {
+            root_name: "root".to_string(),
+            abs_path: Path::new("/path/to/root").into(),
+            rules_file: None,
+        }];
+        let prompt_builder = PromptBuilder::new(None)?;
+        let project_context = ProjectContext::new(worktrees, Vec::default());
+        let system_prompt = prompt_builder.generate_assistant_system_prompt(
+            &project_context,
+            &ModelContext {
+                available_tools: tool_names,
+            },
+        )?;
+
+        let has_system_prompt = eval
+            .conversation
+            .first()
+            .map_or(false, |msg| msg.role == Role::System);
+        let messages = if has_system_prompt {
+            eval.conversation
+        } else {
+            [LanguageModelRequestMessage {
+                role: Role::System,
+                content: vec![MessageContent::Text(system_prompt)],
+                cache: true,
+            }]
+            .into_iter()
+            .chain(eval.conversation)
+            .collect::<Vec<_>>()
+        };
+
+        let conversation = LanguageModelRequest {
+            messages,
+            tools,
             ..Default::default()
         };
-        let edit_output = if let Some(input_content) = eval.input_content.as_deref() {
-            buffer.update(cx, |buffer, cx| buffer.set_text(input_content, cx));
-            let (edit_output, _) = self.agent.edit(
-                buffer.clone(),
-                eval.edit_description,
-                &conversation,
-                &mut cx.to_async(),
-            );
-            edit_output.await?
+
+        let edit_output = if matches!(eval.edit_file_input.mode, EditFileMode::Edit) {
+            if let Some(input_content) = eval.input_content.as_deref() {
+                buffer.update(cx, |buffer, cx| buffer.set_text(input_content, cx));
+            }
+            retry_on_rate_limit(async || {
+                self.agent
+                    .edit(
+                        buffer.clone(),
+                        eval.edit_file_input.display_description.clone(),
+                        &conversation,
+                        &mut cx.to_async(),
+                    )
+                    .0
+                    .await
+            })
+            .await?
         } else {
-            let (edit_output, _) = self.agent.overwrite(
-                buffer.clone(),
-                eval.edit_description,
-                &conversation,
-                &mut cx.to_async(),
-            );
-            edit_output.await?
+            retry_on_rate_limit(async || {
+                self.agent
+                    .overwrite(
+                        buffer.clone(),
+                        eval.edit_file_input.display_description.clone(),
+                        &conversation,
+                        &mut cx.to_async(),
+                    )
+                    .0
+                    .await
+            })
+            .await?
         };
 
         let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text());
@@ -1347,7 +1639,8 @@ impl EditAgentTest {
                 eval.input_content.as_deref().unwrap_or_default(),
                 &buffer_text,
             ),
-            text: buffer_text,
+            text_before: eval.input_content.unwrap_or_default(),
+            text_after: buffer_text,
         };
         let assertion = eval
             .assertion
@@ -1358,6 +1651,31 @@ impl EditAgentTest {
     }
 }
 
+async fn retry_on_rate_limit<R>(mut request: impl AsyncFnMut() -> Result<R>) -> Result<R> {
+    let mut attempt = 0;
+    loop {
+        attempt += 1;
+        match request().await {
+            Ok(result) => return Ok(result),
+            Err(err) => match err.downcast::<LanguageModelCompletionError>() {
+                Ok(err) => match err {
+                    LanguageModelCompletionError::RateLimitExceeded { retry_after } => {
+                        // Wait for the duration supplied, with some jitter to avoid all requests being made at the same time.
+                        let jitter = retry_after.mul_f64(rand::thread_rng().gen_range(0.0..1.0));
+                        eprintln!(
+                            "Attempt #{attempt}: Rate limit exceeded. Retry after {retry_after:?} + jitter of {jitter:?}"
+                        );
+                        Timer::after(retry_after + jitter).await;
+                        continue;
+                    }
+                    _ => return Err(err.into()),
+                },
+                Err(err) => return Err(err),
+            },
+        }
+    }
+}
+
 #[derive(Clone, Debug, Eq, PartialEq, Hash)]
 struct EvalAssertionOutcome {
     score: usize,

crates/assistant_tools/src/edit_agent/evals/fixtures/delete_run_git_blame/after.rs 🔗

@@ -98,21 +98,21 @@ impl BlameEntry {
         let sha = parts
             .next()
             .and_then(|line| line.parse::<Oid>().ok())
-            .ok_or_else(|| anyhow!("failed to parse sha"))?;
+            .with_context(|| format!("parsing sha from {line}"))?;
 
         let original_line_number = parts
             .next()
             .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse original line number"))?;
+            .with_context(|| format!("parsing original line number from {line}"))?;
         let final_line_number = parts
             .next()
             .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse final line number"))?;
+            .with_context(|| format!("parsing final line number from {line}"))?;
 
         let line_count = parts
             .next()
             .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse final line number"))?;
+            .with_context(|| format!("parsing line count from {line}"))?;
 
         let start_line = final_line_number.saturating_sub(1);
         let end_line = start_line + line_count;

crates/assistant_tools/src/edit_agent/evals/fixtures/delete_run_git_blame/before.rs 🔗

@@ -80,7 +80,7 @@ async fn run_git_blame(
         .stdout(Stdio::piped())
         .stderr(Stdio::piped())
         .spawn()
-        .map_err(|e| anyhow!("Failed to start git blame process: {}", e))?;
+        .context("starting git blame process")?;
 
     let stdin = child
         .stdin
@@ -92,10 +92,7 @@ async fn run_git_blame(
     }
     stdin.flush().await?;
 
-    let output = child
-        .output()
-        .await
-        .map_err(|e| anyhow!("Failed to read git blame output: {}", e))?;
+    let output = child.output().await.context("reading git blame output")?;
 
     if !output.status.success() {
         let stderr = String::from_utf8_lossy(&output.stderr);
@@ -103,7 +100,7 @@ async fn run_git_blame(
         if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
             return Ok(String::new());
         }
-        return Err(anyhow!("git blame process failed: {}", stderr));
+        anyhow::bail!("git blame process failed: {stderr}");
     }
 
     Ok(String::from_utf8(output.stdout)?)
@@ -144,21 +141,21 @@ impl BlameEntry {
         let sha = parts
             .next()
             .and_then(|line| line.parse::<Oid>().ok())
-            .ok_or_else(|| anyhow!("failed to parse sha"))?;
+            .with_context(|| format!("parsing sha from {line}"))?;
 
         let original_line_number = parts
             .next()
             .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse original line number"))?;
+            .with_context(|| format!("parsing original line number from {line}"))?;
         let final_line_number = parts
             .next()
             .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse final line number"))?;
+            .with_context(|| format!("parsing final line number from {line}"))?;
 
         let line_count = parts
             .next()
             .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse final line number"))?;
+            .with_context(|| format!("parsing line count from {line}"))?;
 
         let start_line = final_line_number.saturating_sub(1);
         let end_line = start_line + line_count;

crates/assistant_tools/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs 🔗

@@ -1249,7 +1249,7 @@ pub struct ActiveDiagnosticGroup {
 }
 
 #[derive(Debug, PartialEq, Eq)]
-#[allow(clippy::large_enum_variant)]
+
 pub(crate) enum ActiveDiagnostic {
     None,
     All,
@@ -5272,7 +5272,7 @@ impl Editor {
                 task.await?;
             }
 
-            Ok::<_, anyhow::Error>(())
+            anyhow::Ok(())
         })
         .detach_and_log_err(cx);
     }
@@ -7698,7 +7698,7 @@ impl Editor {
                     .gap_1()
                     // Workaround: For some reason, there's a gap if we don't do this
                     .ml(-BORDER_WIDTH)
-                    .shadow(smallvec![gpui::BoxShadow {
+                    .shadow(vec![gpui::BoxShadow {
                         color: gpui::black().opacity(0.05),
                         offset: point(px(1.), px(1.)),
                         blur_radius: px(2.),
@@ -9132,7 +9132,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.manipulate_lines(window, cx, |lines| lines.sort())
+        self.manipulate_immutable_lines(window, cx, |lines| lines.sort())
     }
 
     pub fn sort_lines_case_insensitive(
@@ -9141,7 +9141,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.manipulate_lines(window, cx, |lines| {
+        self.manipulate_immutable_lines(window, cx, |lines| {
             lines.sort_by_key(|line| line.to_lowercase())
         })
     }
@@ -9152,7 +9152,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.manipulate_lines(window, cx, |lines| {
+        self.manipulate_immutable_lines(window, cx, |lines| {
             let mut seen = HashSet::default();
             lines.retain(|line| seen.insert(line.to_lowercase()));
         })
@@ -9164,7 +9164,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.manipulate_lines(window, cx, |lines| {
+        self.manipulate_immutable_lines(window, cx, |lines| {
             let mut seen = HashSet::default();
             lines.retain(|line| seen.insert(*line));
         })
@@ -9606,20 +9606,20 @@ impl Editor {
     }
 
     pub fn reverse_lines(&mut self, _: &ReverseLines, window: &mut Window, cx: &mut Context<Self>) {
-        self.manipulate_lines(window, cx, |lines| lines.reverse())
+        self.manipulate_immutable_lines(window, cx, |lines| lines.reverse())
     }
 
     pub fn shuffle_lines(&mut self, _: &ShuffleLines, window: &mut Window, cx: &mut Context<Self>) {
-        self.manipulate_lines(window, cx, |lines| lines.shuffle(&mut thread_rng()))
+        self.manipulate_immutable_lines(window, cx, |lines| lines.shuffle(&mut thread_rng()))
     }
 
-    fn manipulate_lines<Fn>(
+    fn manipulate_lines<M>(
         &mut self,
         window: &mut Window,
         cx: &mut Context<Self>,
-        mut callback: Fn,
+        mut manipulate: M,
     ) where
-        Fn: FnMut(&mut Vec<&str>),
+        M: FnMut(&str) -> LineManipulationResult,
     {
         self.hide_mouse_cursor(&HideMouseCursorOrigin::TypingAction);
 
@@ -9652,18 +9652,14 @@ impl Editor {
                 .text_for_range(start_point..end_point)
                 .collect::<String>();
 
-            let mut lines = text.split('\n').collect_vec();
-
-            let lines_before = lines.len();
-            callback(&mut lines);
-            let lines_after = lines.len();
+            let LineManipulationResult { new_text, line_count_before, line_count_after} = manipulate(&text);
 
-            edits.push((start_point..end_point, lines.join("\n")));
+            edits.push((start_point..end_point, new_text));
 
             // Selections must change based on added and removed line count
             let start_row =
                 MultiBufferRow(start_point.row + added_lines as u32 - removed_lines as u32);
-            let end_row = MultiBufferRow(start_row.0 + lines_after.saturating_sub(1) as u32);
+            let end_row = MultiBufferRow(start_row.0 + line_count_after.saturating_sub(1) as u32);
             new_selections.push(Selection {
                 id: selection.id,
                 start: start_row,
@@ -9672,10 +9668,10 @@ impl Editor {
                 reversed: selection.reversed,
             });
 
-            if lines_after > lines_before {
-                added_lines += lines_after - lines_before;
-            } else if lines_before > lines_after {
-                removed_lines += lines_before - lines_after;
+            if line_count_after > line_count_before {
+                added_lines += line_count_after - line_count_before;
+            } else if line_count_before > line_count_after {
+                removed_lines += line_count_before - line_count_after;
             }
         }
 
@@ -9720,6 +9716,171 @@ impl Editor {
         })
     }
 
+    fn manipulate_immutable_lines<Fn>(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+        mut callback: Fn,
+    ) where
+        Fn: FnMut(&mut Vec<&str>),
+    {
+        self.manipulate_lines(window, cx, |text| {
+            let mut lines: Vec<&str> = text.split('\n').collect();
+            let line_count_before = lines.len();
+
+            callback(&mut lines);
+
+            LineManipulationResult {
+                new_text: lines.join("\n"),
+                line_count_before,
+                line_count_after: lines.len(),
+            }
+        });
+    }
+
+    fn manipulate_mutable_lines<Fn>(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+        mut callback: Fn,
+    ) where
+        Fn: FnMut(&mut Vec<Cow<'_, str>>),
+    {
+        self.manipulate_lines(window, cx, |text| {
+            let mut lines: Vec<Cow<str>> = text.split('\n').map(Cow::from).collect();
+            let line_count_before = lines.len();
+
+            callback(&mut lines);
+
+            LineManipulationResult {
+                new_text: lines.join("\n"),
+                line_count_before,
+                line_count_after: lines.len(),
+            }
+        });
+    }
+
+    pub fn convert_indentation_to_spaces(
+        &mut self,
+        _: &ConvertIndentationToSpaces,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let settings = self.buffer.read(cx).language_settings(cx);
+        let tab_size = settings.tab_size.get() as usize;
+
+        self.manipulate_mutable_lines(window, cx, |lines| {
+            // Allocates a reasonably sized scratch buffer once for the whole loop
+            let mut reindented_line = String::with_capacity(MAX_LINE_LEN);
+            // Avoids recomputing spaces that could be inserted many times
+            let space_cache: Vec<Vec<char>> = (1..=tab_size)
+                .map(|n| IndentSize::spaces(n as u32).chars().collect())
+                .collect();
+
+            for line in lines.iter_mut().filter(|line| !line.is_empty()) {
+                let mut chars = line.as_ref().chars();
+                let mut col = 0;
+                let mut changed = false;
+
+                while let Some(ch) = chars.next() {
+                    match ch {
+                        ' ' => {
+                            reindented_line.push(' ');
+                            col += 1;
+                        }
+                        '\t' => {
+                            // \t are converted to spaces depending on the current column
+                            let spaces_len = tab_size - (col % tab_size);
+                            reindented_line.extend(&space_cache[spaces_len - 1]);
+                            col += spaces_len;
+                            changed = true;
+                        }
+                        _ => {
+                            // If we dont append before break, the character is consumed
+                            reindented_line.push(ch);
+                            break;
+                        }
+                    }
+                }
+
+                if !changed {
+                    reindented_line.clear();
+                    continue;
+                }
+                // Append the rest of the line and replace old reference with new one
+                reindented_line.extend(chars);
+                *line = Cow::Owned(reindented_line.clone());
+                reindented_line.clear();
+            }
+        });
+    }
+
+    pub fn convert_indentation_to_tabs(
+        &mut self,
+        _: &ConvertIndentationToTabs,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let settings = self.buffer.read(cx).language_settings(cx);
+        let tab_size = settings.tab_size.get() as usize;
+
+        self.manipulate_mutable_lines(window, cx, |lines| {
+            // Allocates a reasonably sized buffer once for the whole loop
+            let mut reindented_line = String::with_capacity(MAX_LINE_LEN);
+            // Avoids recomputing spaces that could be inserted many times
+            let space_cache: Vec<Vec<char>> = (1..=tab_size)
+                .map(|n| IndentSize::spaces(n as u32).chars().collect())
+                .collect();
+
+            for line in lines.iter_mut().filter(|line| !line.is_empty()) {
+                let mut chars = line.chars();
+                let mut spaces_count = 0;
+                let mut first_non_indent_char = None;
+                let mut changed = false;
+
+                while let Some(ch) = chars.next() {
+                    match ch {
+                        ' ' => {
+                            // Keep track of spaces. Append \t when we reach tab_size
+                            spaces_count += 1;
+                            changed = true;
+                            if spaces_count == tab_size {
+                                reindented_line.push('\t');
+                                spaces_count = 0;
+                            }
+                        }
+                        '\t' => {
+                            reindented_line.push('\t');
+                            spaces_count = 0;
+                        }
+                        _ => {
+                            // Dont append it yet, we might have remaining spaces
+                            first_non_indent_char = Some(ch);
+                            break;
+                        }
+                    }
+                }
+
+                if !changed {
+                    reindented_line.clear();
+                    continue;
+                }
+                // Remaining spaces that didn't make a full tab stop
+                if spaces_count > 0 {
+                    reindented_line.extend(&space_cache[spaces_count - 1]);
+                }
+                // If we consume an extra character that was not indentation, add it back
+                if let Some(extra_char) = first_non_indent_char {
+                    reindented_line.push(extra_char);
+                }
+                // Append the rest of the line and replace old reference with new one
+                reindented_line.extend(chars);
+                *line = Cow::Owned(reindented_line.clone());
+                reindented_line.clear();
+            }
+        });
+    }
+
     pub fn convert_to_upper_case(
         &mut self,
         _: &ConvertToUpperCase,
@@ -10369,8 +10530,8 @@ impl Editor {
                 .map(|line| {
                     line.strip_prefix(&line_prefix)
                         .or_else(|| line.trim_start().strip_prefix(&line_prefix.trim_start()))
-                        .ok_or_else(|| {
-                            anyhow!("line did not start with prefix {line_prefix:?}: {line:?}")
+                        .with_context(|| {
+                            format!("line did not start with prefix {line_prefix:?}: {line:?}")
                         })
                 })
                 .collect::<Result<Vec<_>, _>>()
@@ -16944,7 +17105,7 @@ impl Editor {
             Err(err) => {
                 let message = format!("Failed to copy permalink: {err}");
 
-                Err::<(), anyhow::Error>(err).log_err();
+                anyhow::Result::<()>::Err(err).log_err();
 
                 if let Some(workspace) = workspace {
                     workspace
@@ -16999,7 +17160,7 @@ impl Editor {
             Err(err) => {
                 let message = format!("Failed to open permalink: {err}");
 
-                Err::<(), anyhow::Error>(err).log_err();
+                anyhow::Result::<()>::Err(err).log_err();
 
                 if let Some(workspace) = workspace {
                     workspace
@@ -21157,6 +21318,13 @@ pub struct LineHighlight {
     pub type_id: Option<TypeId>,
 }
 
+struct LineManipulationResult {
+    pub new_text: String,
+    pub line_count_before: usize,
+    pub line_count_after: usize,
+}
+
+
 fn render_diff_hunk_controls(
     row: u32,
     status: &DiffHunkStatus,

crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/after.rs 🔗

@@ -1,378 +0,0 @@
-use crate::commit::get_messages;
-use crate::{GitRemote, Oid};
-use anyhow::{Context as _, Result, anyhow};
-use collections::{HashMap, HashSet};
-use futures::AsyncWriteExt;
-use gpui::SharedString;
-use serde::{Deserialize, Serialize};
-use std::process::Stdio;
-use std::{ops::Range, path::Path};
-use text::Rope;
-use time::OffsetDateTime;
-use time::UtcOffset;
-use time::macros::format_description;
-
-pub use git2 as libgit;
-
-#[derive(Debug, Clone, Default)]
-pub struct Blame {
-    pub entries: Vec<BlameEntry>,
-    pub messages: HashMap<Oid, String>,
-    pub remote_url: Option<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,
-        working_directory: &Path,
-        path: &Path,
-        content: &Rope,
-        remote_url: Option<String>,
-    ) -> Result<Self> {
-        let output = run_git_blame(git_binary, working_directory, path, content).await?;
-        let mut entries = parse_git_blame(&output)?;
-        entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start));
-
-        let mut unique_shas = HashSet::default();
-
-        for entry in entries.iter_mut() {
-            unique_shas.insert(entry.sha);
-        }
-
-        let shas = unique_shas.into_iter().collect::<Vec<_>>();
-        let messages = get_messages(working_directory, &shas)
-            .await
-            .context("failed to get commit messages")?;
-
-        Ok(Self {
-            entries,
-            messages,
-            remote_url,
-        })
-    }
-}
-
-const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD";
-const GIT_BLAME_NO_PATH: &str = "fatal: no such path";
-
-async fn run_git_blame(
-    git_binary: &Path,
-    working_directory: &Path,
-    path: &Path,
-    contents: &Rope,
-) -> Result<String> {
-    let mut child = util::command::new_smol_command(git_binary)
-        .current_dir(working_directory)
-        .arg("blame")
-        .arg("--incremental")
-        .arg("--contents")
-        .arg("-")
-        .arg(path.as_os_str())
-        .stdin(Stdio::piped())
-        .stdout(Stdio::piped())
-        .stderr(Stdio::piped())
-        .spawn()
-        .map_err(|e| anyhow!("Failed to start git blame process: {}", e))?;
-
-    let stdin = child
-        .stdin
-        .as_mut()
-        .context("failed to get pipe to stdin of git blame command")?;
-
-    for chunk in contents.chunks() {
-        stdin.write_all(chunk.as_bytes()).await?;
-    }
-    stdin.flush().await?;
-
-    let output = child
-        .output()
-        .await
-        .map_err(|e| anyhow!("Failed to read git blame output: {}", e))?;
-
-    handle_command_output(output)
-}
-
-fn handle_command_output(output: std::process::Output) -> Result<String> {
-    if !output.status.success() {
-        let stderr = String::from_utf8_lossy(&output.stderr);
-        let trimmed = stderr.trim();
-        if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
-            return Ok(String::new());
-        }
-        return Err(anyhow!("git blame process failed: {}", stderr));
-    }
-
-    Ok(String::from_utf8(output.stdout)?)
-}
-
-#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
-pub struct BlameEntry {
-    pub sha: Oid,
-
-    pub range: Range<u32>,
-
-    pub original_line_number: u32,
-
-    pub author: Option<String>,
-    pub author_mail: Option<String>,
-    pub author_time: Option<i64>,
-    pub author_tz: Option<String>,
-
-    pub committer_name: Option<String>,
-    pub committer_email: Option<String>,
-    pub committer_time: Option<i64>,
-    pub committer_tz: Option<String>,
-
-    pub summary: Option<String>,
-
-    pub previous: Option<String>,
-    pub filename: String,
-}
-
-impl BlameEntry {
-    // Returns a BlameEntry by parsing the first line of a `git blame --incremental`
-    // entry. The line MUST have this format:
-    //
-    //     <40-byte-hex-sha1> <sourceline> <resultline> <num-lines>
-    fn new_from_blame_line(line: &str) -> Result<BlameEntry> {
-        let mut parts = line.split_whitespace();
-
-        let sha = parts
-            .next()
-            .and_then(|line| line.parse::<Oid>().ok())
-            .ok_or_else(|| anyhow!("failed to parse sha"))?;
-
-        let original_line_number = parts
-            .next()
-            .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse original line number"))?;
-        let final_line_number = parts
-            .next()
-            .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse final line number"))?;
-
-        let line_count = parts
-            .next()
-            .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse final line number"))?;
-
-        let start_line = final_line_number.saturating_sub(1);
-        let end_line = start_line + line_count;
-        let range = start_line..end_line;
-
-        Ok(Self {
-            sha,
-            range,
-            original_line_number,
-            ..Default::default()
-        })
-    }
-
-    pub fn author_offset_date_time(&self) -> Result<time::OffsetDateTime> {
-        if let (Some(author_time), Some(author_tz)) = (self.author_time, &self.author_tz) {
-            let format = format_description!("[offset_hour][offset_minute]");
-            let offset = UtcOffset::parse(author_tz, &format)?;
-            let date_time_utc = OffsetDateTime::from_unix_timestamp(author_time)?;
-
-            Ok(date_time_utc.to_offset(offset))
-        } else {
-            // Directly return current time in UTC if there's no committer time or timezone
-            Ok(time::OffsetDateTime::now_utc())
-        }
-    }
-}
-
-// parse_git_blame parses the output of `git blame --incremental`, which returns
-// all the blame-entries for a given path incrementally, as it finds them.
-//
-// Each entry *always* starts with:
-//
-//     <40-byte-hex-sha1> <sourceline> <resultline> <num-lines>
-//
-// Each entry *always* ends with:
-//
-//     filename <whitespace-quoted-filename-goes-here>
-//
-// Line numbers are 1-indexed.
-//
-// A `git blame --incremental` entry looks like this:
-//
-//    6ad46b5257ba16d12c5ca9f0d4900320959df7f4 2 2 1
-//    author Joe Schmoe
-//    author-mail <joe.schmoe@example.com>
-//    author-time 1709741400
-//    author-tz +0100
-//    committer Joe Schmoe
-//    committer-mail <joe.schmoe@example.com>
-//    committer-time 1709741400
-//    committer-tz +0100
-//    summary Joe's cool commit
-//    previous 486c2409237a2c627230589e567024a96751d475 index.js
-//    filename index.js
-//
-// If the entry has the same SHA as an entry that was already printed then no
-// signature information is printed:
-//
-//    6ad46b5257ba16d12c5ca9f0d4900320959df7f4 3 4 1
-//    previous 486c2409237a2c627230589e567024a96751d475 index.js
-//    filename index.js
-//
-// More about `--incremental` output: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-blame.html
-fn parse_git_blame(output: &str) -> Result<Vec<BlameEntry>> {
-    let mut entries: Vec<BlameEntry> = Vec::new();
-    let mut index: HashMap<Oid, usize> = HashMap::default();
-
-    let mut current_entry: Option<BlameEntry> = None;
-
-    for line in output.lines() {
-        let mut done = false;
-
-        match &mut current_entry {
-            None => {
-                let mut new_entry = BlameEntry::new_from_blame_line(line)?;
-
-                if let Some(existing_entry) = index
-                    .get(&new_entry.sha)
-                    .and_then(|slot| entries.get(*slot))
-                {
-                    new_entry.author.clone_from(&existing_entry.author);
-                    new_entry
-                        .author_mail
-                        .clone_from(&existing_entry.author_mail);
-                    new_entry.author_time = existing_entry.author_time;
-                    new_entry.author_tz.clone_from(&existing_entry.author_tz);
-                    new_entry
-                        .committer_name
-                        .clone_from(&existing_entry.committer_name);
-                    new_entry
-                        .committer_email
-                        .clone_from(&existing_entry.committer_email);
-                    new_entry.committer_time = existing_entry.committer_time;
-                    new_entry
-                        .committer_tz
-                        .clone_from(&existing_entry.committer_tz);
-                    new_entry.summary.clone_from(&existing_entry.summary);
-                }
-
-                current_entry.replace(new_entry);
-            }
-            Some(entry) => {
-                let Some((key, value)) = line.split_once(' ') else {
-                    continue;
-                };
-                let is_committed = !entry.sha.is_zero();
-                match key {
-                    "filename" => {
-                        entry.filename = value.into();
-                        done = true;
-                    }
-                    "previous" => entry.previous = Some(value.into()),
-
-                    "summary" if is_committed => entry.summary = Some(value.into()),
-                    "author" if is_committed => entry.author = Some(value.into()),
-                    "author-mail" if is_committed => entry.author_mail = Some(value.into()),
-                    "author-time" if is_committed => {
-                        entry.author_time = Some(value.parse::<i64>()?)
-                    }
-                    "author-tz" if is_committed => entry.author_tz = Some(value.into()),
-
-                    "committer" if is_committed => entry.committer_name = Some(value.into()),
-                    "committer-mail" if is_committed => entry.committer_email = Some(value.into()),
-                    "committer-time" if is_committed => {
-                        entry.committer_time = Some(value.parse::<i64>()?)
-                    }
-                    "committer-tz" if is_committed => entry.committer_tz = Some(value.into()),
-                    _ => {}
-                }
-            }
-        };
-
-        if done {
-            if let Some(entry) = current_entry.take() {
-                index.insert(entry.sha, entries.len());
-
-                // We only want annotations that have a commit.
-                if !entry.sha.is_zero() {
-                    entries.push(entry);
-                }
-            }
-        }
-    }
-
-    Ok(entries)
-}
-
-#[cfg(test)]
-mod tests {
-    use std::path::PathBuf;
-
-    use super::BlameEntry;
-    use super::parse_git_blame;
-
-    fn read_test_data(filename: &str) -> String {
-        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
-        path.push("test_data");
-        path.push(filename);
-
-        std::fs::read_to_string(&path)
-            .unwrap_or_else(|_| panic!("Could not read test data at {:?}. Is it generated?", path))
-    }
-
-    fn assert_eq_golden(entries: &Vec<BlameEntry>, golden_filename: &str) {
-        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
-        path.push("test_data");
-        path.push("golden");
-        path.push(format!("{}.json", golden_filename));
-
-        let mut have_json =
-            serde_json::to_string_pretty(&entries).expect("could not serialize entries to JSON");
-        // We always want to save with a trailing newline.
-        have_json.push('\n');
-
-        let update = std::env::var("UPDATE_GOLDEN")
-            .map(|val| val.eq_ignore_ascii_case("true"))
-            .unwrap_or(false);
-
-        if update {
-            std::fs::create_dir_all(path.parent().unwrap())
-                .expect("could not create golden test data directory");
-            std::fs::write(&path, have_json).expect("could not write out golden data");
-        } else {
-            let want_json =
-                std::fs::read_to_string(&path).unwrap_or_else(|_| {
-                    panic!("could not read golden test data file at {:?}. Did you run the test with UPDATE_GOLDEN=true before?", path);
-                }).replace("\r\n", "\n");
-
-            pretty_assertions::assert_eq!(have_json, want_json, "wrong blame entries");
-        }
-    }
-
-    #[test]
-    fn test_parse_git_blame_not_committed() {
-        let output = read_test_data("blame_incremental_not_committed");
-        let entries = parse_git_blame(&output).unwrap();
-        assert_eq_golden(&entries, "blame_incremental_not_committed");
-    }
-
-    #[test]
-    fn test_parse_git_blame_simple() {
-        let output = read_test_data("blame_incremental_simple");
-        let entries = parse_git_blame(&output).unwrap();
-        assert_eq_golden(&entries, "blame_incremental_simple");
-    }
-
-    #[test]
-    fn test_parse_git_blame_complex() {
-        let output = read_test_data("blame_incremental_complex");
-        let entries = parse_git_blame(&output).unwrap();
-        assert_eq_golden(&entries, "blame_incremental_complex");
-    }
-}

crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/before.rs 🔗

@@ -80,7 +80,7 @@ async fn run_git_blame(
         .stdout(Stdio::piped())
         .stderr(Stdio::piped())
         .spawn()
-        .map_err(|e| anyhow!("Failed to start git blame process: {}", e))?;
+        .context("starting git blame process")?;
 
     let stdin = child
         .stdin
@@ -92,10 +92,7 @@ async fn run_git_blame(
     }
     stdin.flush().await?;
 
-    let output = child
-        .output()
-        .await
-        .map_err(|e| anyhow!("Failed to read git blame output: {}", e))?;
+    let output = child.output().await.context("reading git blame output")?;
 
     if !output.status.success() {
         let stderr = String::from_utf8_lossy(&output.stderr);
@@ -103,7 +100,7 @@ async fn run_git_blame(
         if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
             return Ok(String::new());
         }
-        return Err(anyhow!("git blame process failed: {}", stderr));
+        anyhow::bail!("git blame process failed: {stderr}");
     }
 
     Ok(String::from_utf8(output.stdout)?)
@@ -144,21 +141,21 @@ impl BlameEntry {
         let sha = parts
             .next()
             .and_then(|line| line.parse::<Oid>().ok())
-            .ok_or_else(|| anyhow!("failed to parse sha"))?;
+            .with_context(|| format!("parsing sha from {line}"))?;
 
         let original_line_number = parts
             .next()
             .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse original line number"))?;
+            .with_context(|| format!("parsing original line number from {line}"))?;
         let final_line_number = parts
             .next()
             .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse final line number"))?;
+            .with_context(|| format!("parsing final line number from {line}"))?;
 
         let line_count = parts
             .next()
             .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse final line number"))?;
+            .with_context(|| format!("parsing line count from {line}"))?;
 
         let start_line = final_line_number.saturating_sub(1);
         let end_line = start_line + line_count;

crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-02.diff 🔗

@@ -0,0 +1,26 @@
+@@ -95,15 +95,19 @@
+     let output = child.output().await.context("reading git blame output")?;
+
+     if !output.status.success() {
+-        let stderr = String::from_utf8_lossy(&output.stderr);
+-        let trimmed = stderr.trim();
+-        if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
+-            return Ok(String::new());
+-        }
+-        anyhow::bail!("git blame process failed: {stderr}");
++        return handle_command_output(output);
+     }
+
+     Ok(String::from_utf8(output.stdout)?)
++}
++
++fn handle_command_output(output: std::process::Output) -> Result<String> {
++    let stderr = String::from_utf8_lossy(&output.stderr);
++    let trimmed = stderr.trim();
++    if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
++        return Ok(String::new());
++    }
++    anyhow::bail!("git blame process failed: {stderr}");
+ }
+
+ #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]

crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-04.diff 🔗

@@ -0,0 +1,24 @@
+@@ -93,17 +93,20 @@
+     stdin.flush().await?;
+
+     let output = child.output().await.context("reading git blame output")?;
++    handle_command_output(&output)?;
++    Ok(String::from_utf8(output.stdout)?)
++}
+
++fn handle_command_output(output: &std::process::Output) -> Result<()> {
+     if !output.status.success() {
+         let stderr = String::from_utf8_lossy(&output.stderr);
+         let trimmed = stderr.trim();
+         if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
+-            return Ok(String::new());
++            return Ok(());
+         }
+         anyhow::bail!("git blame process failed: {stderr}");
+     }
+-
+-    Ok(String::from_utf8(output.stdout)?)
++    Ok(())
+ }
+
+ #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]

crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-05.diff 🔗

@@ -0,0 +1,26 @@
+@@ -95,15 +95,19 @@
+     let output = child.output().await.context("reading git blame output")?;
+
+     if !output.status.success() {
+-        let stderr = String::from_utf8_lossy(&output.stderr);
+-        let trimmed = stderr.trim();
+-        if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
+-            return Ok(String::new());
+-        }
+-        anyhow::bail!("git blame process failed: {stderr}");
++        return handle_command_output(&output);
+     }
+
+     Ok(String::from_utf8(output.stdout)?)
++}
++
++fn handle_command_output(output: &std::process::Output) -> Result<String> {
++    let stderr = String::from_utf8_lossy(&output.stderr);
++    let trimmed = stderr.trim();
++    if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
++        return Ok(String::new());
++    }
++    anyhow::bail!("git blame process failed: {stderr}");
+ }
+
+ #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]

crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-06.diff 🔗

@@ -0,0 +1,23 @@
+@@ -93,7 +93,12 @@
+     stdin.flush().await?;
+
+     let output = child.output().await.context("reading git blame output")?;
++    handle_command_output(&output)?;
+
++    Ok(String::from_utf8(output.stdout)?)
++}
++
++fn handle_command_output(output: &std::process::Output) -> Result<String> {
+     if !output.status.success() {
+         let stderr = String::from_utf8_lossy(&output.stderr);
+         let trimmed = stderr.trim();
+@@ -102,8 +107,7 @@
+         }
+         anyhow::bail!("git blame process failed: {stderr}");
+     }
+-
+-    Ok(String::from_utf8(output.stdout)?)
++    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
+ }
+
+ #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]

crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-07.diff 🔗

@@ -0,0 +1,26 @@
+@@ -95,15 +95,19 @@
+     let output = child.output().await.context("reading git blame output")?;
+
+     if !output.status.success() {
+-        let stderr = String::from_utf8_lossy(&output.stderr);
+-        let trimmed = stderr.trim();
+-        if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
+-            return Ok(String::new());
+-        }
+-        anyhow::bail!("git blame process failed: {stderr}");
++            return handle_command_output(output);
+     }
+
+     Ok(String::from_utf8(output.stdout)?)
++}
++
++fn handle_command_output(output: std::process::Output) -> Result<String> {
++    let stderr = String::from_utf8_lossy(&output.stderr);
++    let trimmed = stderr.trim();
++    if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
++        return Ok(String::new());
++    }
++    anyhow::bail!("git blame process failed: {stderr}");
+ }
+
+ #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]

crates/assistant_tools/src/edit_agent/evals/fixtures/extract_handle_command_output/possible-08.diff 🔗

@@ -0,0 +1,26 @@
+@@ -95,15 +95,19 @@
+     let output = child.output().await.context("reading git blame output")?;
+
+     if !output.status.success() {
+-        let stderr = String::from_utf8_lossy(&output.stderr);
+-        let trimmed = stderr.trim();
+-        if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
+-            return Ok(String::new());
+-        }
+-        anyhow::bail!("git blame process failed: {stderr}");
++        return handle_command_output(output);
+     }
+
+     Ok(String::from_utf8(output.stdout)?)
++}
++
++fn handle_command_output(output: std::process::Output) -> Result<String> {
++    let stderr = String::from_utf8_lossy(&output.stderr);
++    let trimmed = stderr.trim();
++    if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
++        return Ok(String::new());
++    }
++    anyhow::bail!("git blame process failed: {stderr}")
+ }
+
+ #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]

crates/assistant_tools/src/edit_agent/evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs 🔗

@@ -20,7 +20,7 @@ use std::{
 
 #[cfg(any(feature = "tree-sitter-highlight", feature = "tree-sitter-tags"))]
 use anyhow::Error;
-use anyhow::{Context, Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use etcetera::BaseStrategy as _;
 use fs4::fs_std::FileExt;
 use indoc::indoc;
@@ -875,16 +875,13 @@ impl Loader {
 
         FileExt::unlock(lock_file)?;
         fs::remove_file(lock_path)?;
-
-        if output.status.success() {
-            Ok(())
-        } else {
-            Err(anyhow!(
-                "Parser compilation failed.\nStdout: {}\nStderr: {}",
-                String::from_utf8_lossy(&output.stdout),
-                String::from_utf8_lossy(&output.stderr)
-            ))
-        }
+        anyhow::ensure!(
+            output.status.success(),
+            "Parser compilation failed.\nStdout: {}\nStderr: {}",
+            String::from_utf8_lossy(&output.stdout),
+            String::from_utf8_lossy(&output.stderr)
+        );
+        Ok(())
     }
 
     #[cfg(unix)]
@@ -941,17 +938,13 @@ impl Loader {
                         .map(|f| format!("  `{f}`"))
                         .collect::<Vec<_>>()
                         .join("\n");
+                    anyhow::bail!(format!(indoc! {"
+                        Missing required functions in the external scanner, parsing won't work without these!
 
-                    return Err(anyhow!(format!(
-                        indoc! {"
-                            Missing required functions in the external scanner, parsing won't work without these!
-
-                            {}
+                        {missing}
 
-                            You can read more about this at https://tree-sitter.github.io/tree-sitter/creating-parsers/4-external-scanners
-                        "},
-                        missing,
-                    )));
+                        You can read more about this at https://tree-sitter.github.io/tree-sitter/creating-parsers/4-external-scanners
+                    "}));
                 }
             }
         }
@@ -1008,9 +1001,9 @@ impl Loader {
         {
             EmccSource::Podman
         } else {
-            return Err(anyhow!(
+            anyhow::bail!(
                 "You must have either emcc, docker, or podman on your PATH to run this command"
-            ));
+            );
         };
 
         let mut command = match source {
@@ -1103,12 +1096,11 @@ impl Loader {
             .spawn()
             .with_context(|| "Failed to run emcc command")?
             .wait()?;
-        if !status.success() {
-            return Err(anyhow!("emcc command failed"));
-        }
-
-        fs::rename(src_path.join(output_name), output_path)
-            .context("failed to rename wasm output file")?;
+        anyhow::ensure!(status.success(), "emcc command failed");
+        let source_path = src_path.join(output_name);
+        fs::rename(&source_path, &output_path).with_context(|| {
+            format!("failed to rename wasm output file from {source_path:?} to {output_path:?}")
+        })?;
 
         Ok(())
     }
@@ -1185,11 +1177,8 @@ impl Loader {
                                     .map(|path| {
                                        let path = parser_path.join(path);
                                         // prevent p being above/outside of parser_path
-                                        if path.starts_with(parser_path) {
-                                            Ok(path)
-                                        } else {
-                                            Err(anyhow!("External file path {path:?} is outside of parser directory {parser_path:?}"))
-                                        }
+                                        anyhow::ensure!(path.starts_with(parser_path), "External file path {path:?} is outside of parser directory {parser_path:?}");
+                                        Ok(path)
                                     })
                                     .collect::<Result<Vec<_>>>()
                             }).transpose()?,
@@ -1324,11 +1313,8 @@ impl Loader {
         let name = GRAMMAR_NAME_REGEX
             .captures(&first_three_lines)
             .and_then(|c| c.get(1))
-            .ok_or_else(|| {
-                anyhow!(
-                    "Failed to parse the language name from grammar.json at {}",
-                    grammar_path.display()
-                )
+            .with_context(|| {
+                format!("Failed to parse the language name from grammar.json at {grammar_path:?}")
             })?;
 
         Ok(name.as_str().to_string())
@@ -1347,7 +1333,7 @@ impl Loader {
             {
                 Ok(config.0)
             } else {
-                Err(anyhow!("Unknown scope '{scope}'"))
+                anyhow::bail!("Unknown scope '{scope}'")
             }
         } else if let Some((lang, _)) = self
             .language_configuration_for_file_name(path)
@@ -1371,7 +1357,7 @@ impl Loader {
         } else if let Some(lang) = self.language_configuration_for_first_line_regex(path)? {
             Ok(lang.0)
         } else {
-            Err(anyhow!("No language found"))
+            anyhow::bail!("No language found");
         }
     }
 

crates/assistant_tools/src/edit_agent/evals/fixtures/zode/prompt.md 🔗

@@ -498,7 +498,7 @@ client.with_options(max_retries=5).messages.create(
 ### Timeouts
 
 By default requests time out after 10 minutes. You can configure this with a `timeout` option,
-which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/#fine-tuning-the-configuration) object:
+which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object:
 
 ```python
 from anthropic import Anthropic

crates/assistant_tools/src/edit_agent/streaming_fuzzy_matcher.rs 🔗

@@ -0,0 +1,803 @@
+use language::{Point, TextBufferSnapshot};
+use std::{cmp, ops::Range};
+
+const REPLACEMENT_COST: u32 = 1;
+const INSERTION_COST: u32 = 3;
+const DELETION_COST: u32 = 10;
+
+/// A streaming fuzzy matcher that can process text chunks incrementally
+/// and return the best match found so far at each step.
+pub struct StreamingFuzzyMatcher {
+    snapshot: TextBufferSnapshot,
+    query_lines: Vec<String>,
+    line_hint: Option<u32>,
+    incomplete_line: String,
+    matches: Vec<Range<usize>>,
+    matrix: SearchMatrix,
+}
+
+impl StreamingFuzzyMatcher {
+    pub fn new(snapshot: TextBufferSnapshot) -> Self {
+        let buffer_line_count = snapshot.max_point().row as usize + 1;
+        Self {
+            snapshot,
+            query_lines: Vec::new(),
+            line_hint: None,
+            incomplete_line: String::new(),
+            matches: Vec::new(),
+            matrix: SearchMatrix::new(buffer_line_count + 1),
+        }
+    }
+
+    /// Returns the query lines.
+    pub fn query_lines(&self) -> &[String] {
+        &self.query_lines
+    }
+
+    /// Push a new chunk of text and get the best match found so far.
+    ///
+    /// This method accumulates text chunks and processes complete lines.
+    /// Partial lines are buffered internally until a newline is received.
+    ///
+    /// # Returns
+    ///
+    /// Returns `Some(range)` if a match has been found with the accumulated
+    /// query so far, or `None` if no suitable match exists yet.
+    pub fn push(&mut self, chunk: &str, line_hint: Option<u32>) -> Option<Range<usize>> {
+        if line_hint.is_some() {
+            self.line_hint = line_hint;
+        }
+
+        // Add the chunk to our incomplete line buffer
+        self.incomplete_line.push_str(chunk);
+        self.line_hint = line_hint;
+
+        if let Some((last_pos, _)) = self.incomplete_line.match_indices('\n').next_back() {
+            let complete_part = &self.incomplete_line[..=last_pos];
+
+            // Split into lines and add to query_lines
+            for line in complete_part.lines() {
+                self.query_lines.push(line.to_string());
+            }
+
+            self.incomplete_line.replace_range(..last_pos + 1, "");
+
+            self.matches = self.resolve_location_fuzzy();
+        }
+
+        let best_match = self.select_best_match();
+        best_match.or_else(|| self.matches.first().cloned())
+    }
+
+    /// Finish processing and return the final best match(es).
+    ///
+    /// This processes any remaining incomplete line before returning the final
+    /// match result.
+    pub fn finish(&mut self) -> Vec<Range<usize>> {
+        // Process any remaining incomplete line
+        if !self.incomplete_line.is_empty() {
+            self.query_lines.push(self.incomplete_line.clone());
+            self.incomplete_line.clear();
+            self.matches = self.resolve_location_fuzzy();
+        }
+        self.matches.clone()
+    }
+
+    fn resolve_location_fuzzy(&mut self) -> Vec<Range<usize>> {
+        let new_query_line_count = self.query_lines.len();
+        let old_query_line_count = self.matrix.rows.saturating_sub(1);
+        if new_query_line_count == old_query_line_count {
+            return Vec::new();
+        }
+
+        self.matrix.resize_rows(new_query_line_count + 1);
+
+        // Process only the new query lines
+        for row in old_query_line_count..new_query_line_count {
+            let query_line = self.query_lines[row].trim();
+            let leading_deletion_cost = (row + 1) as u32 * DELETION_COST;
+
+            self.matrix.set(
+                row + 1,
+                0,
+                SearchState::new(leading_deletion_cost, SearchDirection::Up),
+            );
+
+            let mut buffer_lines = self.snapshot.as_rope().chunks().lines();
+            let mut col = 0;
+            while let Some(buffer_line) = buffer_lines.next() {
+                let buffer_line = buffer_line.trim();
+                let up = SearchState::new(
+                    self.matrix
+                        .get(row, col + 1)
+                        .cost
+                        .saturating_add(DELETION_COST),
+                    SearchDirection::Up,
+                );
+                let left = SearchState::new(
+                    self.matrix
+                        .get(row + 1, col)
+                        .cost
+                        .saturating_add(INSERTION_COST),
+                    SearchDirection::Left,
+                );
+                let diagonal = SearchState::new(
+                    if query_line == buffer_line {
+                        self.matrix.get(row, col).cost
+                    } else if fuzzy_eq(query_line, buffer_line) {
+                        self.matrix.get(row, col).cost + REPLACEMENT_COST
+                    } else {
+                        self.matrix
+                            .get(row, col)
+                            .cost
+                            .saturating_add(DELETION_COST + INSERTION_COST)
+                    },
+                    SearchDirection::Diagonal,
+                );
+                self.matrix
+                    .set(row + 1, col + 1, up.min(left).min(diagonal));
+                col += 1;
+            }
+        }
+
+        // Find all matches with the best cost
+        let buffer_line_count = self.snapshot.max_point().row as usize + 1;
+        let mut best_cost = u32::MAX;
+        let mut matches_with_best_cost = Vec::new();
+
+        for col in 1..=buffer_line_count {
+            let cost = self.matrix.get(new_query_line_count, col).cost;
+            if cost < best_cost {
+                best_cost = cost;
+                matches_with_best_cost.clear();
+                matches_with_best_cost.push(col as u32);
+            } else if cost == best_cost {
+                matches_with_best_cost.push(col as u32);
+            }
+        }
+
+        // Find ranges for the matches
+        let mut valid_matches = Vec::new();
+        for &buffer_row_end in &matches_with_best_cost {
+            let mut matched_lines = 0;
+            let mut query_row = new_query_line_count;
+            let mut buffer_row_start = buffer_row_end;
+            while query_row > 0 && buffer_row_start > 0 {
+                let current = self.matrix.get(query_row, buffer_row_start as usize);
+                match current.direction {
+                    SearchDirection::Diagonal => {
+                        query_row -= 1;
+                        buffer_row_start -= 1;
+                        matched_lines += 1;
+                    }
+                    SearchDirection::Up => {
+                        query_row -= 1;
+                    }
+                    SearchDirection::Left => {
+                        buffer_row_start -= 1;
+                    }
+                }
+            }
+
+            let matched_buffer_row_count = buffer_row_end - buffer_row_start;
+            let matched_ratio = matched_lines as f32
+                / (matched_buffer_row_count as f32).max(new_query_line_count as f32);
+            if matched_ratio >= 0.8 {
+                let buffer_start_ix = self
+                    .snapshot
+                    .point_to_offset(Point::new(buffer_row_start, 0));
+                let buffer_end_ix = self.snapshot.point_to_offset(Point::new(
+                    buffer_row_end - 1,
+                    self.snapshot.line_len(buffer_row_end - 1),
+                ));
+                valid_matches.push((buffer_row_start, buffer_start_ix..buffer_end_ix));
+            }
+        }
+
+        valid_matches.into_iter().map(|(_, range)| range).collect()
+    }
+
+    /// Return the best match with starting position close enough to line_hint.
+    pub fn select_best_match(&self) -> Option<Range<usize>> {
+        // Allow line hint to be off by that many lines.
+        // Higher values increase probability of applying edits to a wrong place,
+        // Lower values increase edits failures and overall conversation length.
+        const LINE_HINT_TOLERANCE: u32 = 200;
+
+        if self.matches.is_empty() {
+            return None;
+        }
+
+        if self.matches.len() == 1 {
+            return self.matches.first().cloned();
+        }
+
+        let Some(line_hint) = self.line_hint else {
+            // Multiple ambiguous matches
+            return None;
+        };
+
+        let mut best_match = None;
+        let mut best_distance = u32::MAX;
+
+        for range in &self.matches {
+            let start_point = self.snapshot.offset_to_point(range.start);
+            let start_line = start_point.row;
+            let distance = start_line.abs_diff(line_hint);
+
+            if distance <= LINE_HINT_TOLERANCE && distance < best_distance {
+                best_distance = distance;
+                best_match = Some(range.clone());
+            }
+        }
+
+        best_match
+    }
+}
+
+fn fuzzy_eq(left: &str, right: &str) -> bool {
+    const THRESHOLD: f64 = 0.8;
+
+    let min_levenshtein = left.len().abs_diff(right.len());
+    let min_normalized_levenshtein =
+        1. - (min_levenshtein as f64 / cmp::max(left.len(), right.len()) as f64);
+    if min_normalized_levenshtein < THRESHOLD {
+        return false;
+    }
+
+    strsim::normalized_levenshtein(left, right) >= THRESHOLD
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
+enum SearchDirection {
+    Up,
+    Left,
+    Diagonal,
+}
+
+#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
+struct SearchState {
+    cost: u32,
+    direction: SearchDirection,
+}
+
+impl SearchState {
+    fn new(cost: u32, direction: SearchDirection) -> Self {
+        Self { cost, direction }
+    }
+}
+
+struct SearchMatrix {
+    cols: usize,
+    rows: usize,
+    data: Vec<SearchState>,
+}
+
+impl SearchMatrix {
+    fn new(cols: usize) -> Self {
+        SearchMatrix {
+            cols,
+            rows: 0,
+            data: Vec::new(),
+        }
+    }
+
+    fn resize_rows(&mut self, needed_rows: usize) {
+        debug_assert!(needed_rows > self.rows);
+        self.rows = needed_rows;
+        self.data.resize(
+            self.rows * self.cols,
+            SearchState::new(0, SearchDirection::Diagonal),
+        );
+    }
+
+    fn get(&self, row: usize, col: usize) -> SearchState {
+        debug_assert!(row < self.rows && col < self.cols);
+        self.data[row * self.cols + col]
+    }
+
+    fn set(&mut self, row: usize, col: usize, state: SearchState) {
+        debug_assert!(row < self.rows && col < self.cols);
+        self.data[row * self.cols + col] = state;
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use indoc::indoc;
+    use language::{BufferId, TextBuffer};
+    use rand::prelude::*;
+    use util::test::{generate_marked_text, marked_text_ranges};
+
+    #[test]
+    fn test_empty_query() {
+        let buffer = TextBuffer::new(
+            0,
+            BufferId::new(1).unwrap(),
+            "Hello world\nThis is a test\nFoo bar baz",
+        );
+        let snapshot = buffer.snapshot();
+
+        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
+        assert_eq!(push(&mut finder, ""), None);
+        assert_eq!(finish(finder), None);
+    }
+
+    #[test]
+    fn test_streaming_exact_match() {
+        let buffer = TextBuffer::new(
+            0,
+            BufferId::new(1).unwrap(),
+            "Hello world\nThis is a test\nFoo bar baz",
+        );
+        let snapshot = buffer.snapshot();
+
+        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
+
+        // Push partial query
+        assert_eq!(push(&mut finder, "This"), None);
+
+        // Complete the line
+        assert_eq!(
+            push(&mut finder, " is a test\n"),
+            Some("This is a test".to_string())
+        );
+
+        // Finish should return the same result
+        assert_eq!(finish(finder), Some("This is a test".to_string()));
+    }
+
+    #[test]
+    fn test_streaming_fuzzy_match() {
+        let buffer = TextBuffer::new(
+            0,
+            BufferId::new(1).unwrap(),
+            indoc! {"
+                function foo(a, b) {
+                    return a + b;
+                }
+
+                function bar(x, y) {
+                    return x * y;
+                }
+            "},
+        );
+        let snapshot = buffer.snapshot();
+
+        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
+
+        // Push a fuzzy query that should match the first function
+        assert_eq!(
+            push(&mut finder, "function foo(a, c) {\n").as_deref(),
+            Some("function foo(a, b) {")
+        );
+        assert_eq!(
+            push(&mut finder, "    return a + c;\n}\n").as_deref(),
+            Some(concat!(
+                "function foo(a, b) {\n",
+                "    return a + b;\n",
+                "}"
+            ))
+        );
+    }
+
+    #[test]
+    fn test_incremental_improvement() {
+        let buffer = TextBuffer::new(
+            0,
+            BufferId::new(1).unwrap(),
+            "Line 1\nLine 2\nLine 3\nLine 4\nLine 5",
+        );
+        let snapshot = buffer.snapshot();
+
+        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
+
+        // No match initially
+        assert_eq!(push(&mut finder, "Lin"), None);
+
+        // Get a match when we complete a line
+        assert_eq!(push(&mut finder, "e 3\n"), Some("Line 3".to_string()));
+
+        // The match might change if we add more specific content
+        assert_eq!(
+            push(&mut finder, "Line 4\n"),
+            Some("Line 3\nLine 4".to_string())
+        );
+        assert_eq!(finish(finder), Some("Line 3\nLine 4".to_string()));
+    }
+
+    #[test]
+    fn test_incomplete_lines_buffering() {
+        let buffer = TextBuffer::new(
+            0,
+            BufferId::new(1).unwrap(),
+            indoc! {"
+                The quick brown fox
+                jumps over the lazy dog
+                Pack my box with five dozen liquor jugs
+            "},
+        );
+        let snapshot = buffer.snapshot();
+
+        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
+
+        // Push text in small chunks across line boundaries
+        assert_eq!(push(&mut finder, "jumps "), None); // No newline yet
+        assert_eq!(push(&mut finder, "over the"), None); // Still no newline
+        assert_eq!(push(&mut finder, " lazy"), None); // Still incomplete
+
+        // Complete the line
+        assert_eq!(
+            push(&mut finder, " dog\n"),
+            Some("jumps over the lazy dog".to_string())
+        );
+    }
+
+    #[test]
+    fn test_multiline_fuzzy_match() {
+        let buffer = TextBuffer::new(
+            0,
+            BufferId::new(1).unwrap(),
+            indoc! {r#"
+                impl Display for User {
+                    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+                        write!(f, "User: {} ({})", self.name, self.email)
+                    }
+                }
+
+                impl Debug for User {
+                    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+                        f.debug_struct("User")
+                            .field("name", &self.name)
+                            .field("email", &self.email)
+                            .finish()
+                    }
+                }
+            "#},
+        );
+        let snapshot = buffer.snapshot();
+
+        let mut finder = StreamingFuzzyMatcher::new(snapshot.clone());
+
+        assert_eq!(
+            push(&mut finder, "impl Debug for User {\n"),
+            Some("impl Debug for User {".to_string())
+        );
+        assert_eq!(
+            push(
+                &mut finder,
+                "    fn fmt(&self, f: &mut Formatter) -> Result {\n"
+            )
+            .as_deref(),
+            Some(concat!(
+                "impl Debug for User {\n",
+                "    fn fmt(&self, f: &mut Formatter) -> fmt::Result {"
+            ))
+        );
+        assert_eq!(
+            push(&mut finder, "        f.debug_struct(\"User\")\n").as_deref(),
+            Some(concat!(
+                "impl Debug for User {\n",
+                "    fn fmt(&self, f: &mut Formatter) -> fmt::Result {\n",
+                "        f.debug_struct(\"User\")"
+            ))
+        );
+        assert_eq!(
+            push(
+                &mut finder,
+                "            .field(\"name\", &self.username)\n"
+            )
+            .as_deref(),
+            Some(concat!(
+                "impl Debug for User {\n",
+                "    fn fmt(&self, f: &mut Formatter) -> fmt::Result {\n",
+                "        f.debug_struct(\"User\")\n",
+                "            .field(\"name\", &self.name)"
+            ))
+        );
+        assert_eq!(
+            finish(finder).as_deref(),
+            Some(concat!(
+                "impl Debug for User {\n",
+                "    fn fmt(&self, f: &mut Formatter) -> fmt::Result {\n",
+                "        f.debug_struct(\"User\")\n",
+                "            .field(\"name\", &self.name)"
+            ))
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_resolve_location_single_line(mut rng: StdRng) {
+        assert_location_resolution(
+            concat!(
+                "    Lorem\n",
+                "«    ipsum»\n",
+                "    dolor sit amet\n",
+                "    consecteur",
+            ),
+            "ipsum",
+            &mut rng,
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_resolve_location_multiline(mut rng: StdRng) {
+        assert_location_resolution(
+            concat!(
+                "    Lorem\n",
+                "«    ipsum\n",
+                "    dolor sit amet»\n",
+                "    consecteur",
+            ),
+            "ipsum\ndolor sit amet",
+            &mut rng,
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_resolve_location_function_with_typo(mut rng: StdRng) {
+        assert_location_resolution(
+            indoc! {"
+                «fn foo1(a: usize) -> usize {
+                    40
+                }»
+
+                fn foo2(b: usize) -> usize {
+                    42
+                }
+            "},
+            "fn foo1(a: usize) -> u32 {\n40\n}",
+            &mut rng,
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_resolve_location_class_methods(mut rng: StdRng) {
+        assert_location_resolution(
+            indoc! {"
+                class Something {
+                    one() { return 1; }
+                «    two() { return 2222; }
+                    three() { return 333; }
+                    four() { return 4444; }
+                    five() { return 5555; }
+                    six() { return 6666; }»
+                    seven() { return 7; }
+                    eight() { return 8; }
+                }
+            "},
+            indoc! {"
+                two() { return 2222; }
+                four() { return 4444; }
+                five() { return 5555; }
+                six() { return 6666; }
+            "},
+            &mut rng,
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_resolve_location_imports_no_match(mut rng: StdRng) {
+        assert_location_resolution(
+            indoc! {"
+                use std::ops::Range;
+                use std::sync::Mutex;
+                use std::{
+                    collections::HashMap,
+                    env,
+                    ffi::{OsStr, OsString},
+                    fs,
+                    io::{BufRead, BufReader},
+                    mem,
+                    path::{Path, PathBuf},
+                    process::Command,
+                    sync::LazyLock,
+                    time::SystemTime,
+                };
+            "},
+            indoc! {"
+                use std::collections::{HashMap, HashSet};
+                use std::ffi::{OsStr, OsString};
+                use std::fmt::Write as _;
+                use std::fs;
+                use std::io::{BufReader, Read, Write};
+                use std::mem;
+                use std::path::{Path, PathBuf};
+                use std::process::Command;
+                use std::sync::Arc;
+            "},
+            &mut rng,
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_resolve_location_nested_closure(mut rng: StdRng) {
+        assert_location_resolution(
+            indoc! {"
+                impl Foo {
+                    fn new() -> Self {
+                        Self {
+                            subscriptions: vec![
+                                cx.observe_window_activation(window, |editor, window, cx| {
+                                    let active = window.is_window_active();
+                                    editor.blink_manager.update(cx, |blink_manager, cx| {
+                                        if active {
+                                            blink_manager.enable(cx);
+                                        } else {
+                                            blink_manager.disable(cx);
+                                        }
+                                    });
+                                }),
+                            ];
+                        }
+                    }
+                }
+            "},
+            concat!(
+                "                    editor.blink_manager.update(cx, |blink_manager, cx| {\n",
+                "                        blink_manager.enable(cx);\n",
+                "                    });",
+            ),
+            &mut rng,
+        );
+    }
+
+    #[gpui::test(iterations = 100)]
+    fn test_resolve_location_tool_invocation(mut rng: StdRng) {
+        assert_location_resolution(
+            indoc! {r#"
+                let tool = cx
+                    .update(|cx| working_set.tool(&tool_name, cx))
+                    .map_err(|err| {
+                        anyhow!("Failed to look up tool '{}': {}", tool_name, err)
+                    })?;
+
+                let Some(tool) = tool else {
+                    return Err(anyhow!("Tool '{}' not found", tool_name));
+                };
+
+                let project = project.clone();
+                let action_log = action_log.clone();
+                let messages = messages.clone();
+                let tool_result = cx
+                    .update(|cx| tool.run(invocation.input, &messages, project, action_log, cx))
+                    .map_err(|err| anyhow!("Failed to start tool '{}': {}", tool_name, err))?;
+
+                tasks.push(tool_result.output);
+            "#},
+            concat!(
+                "let tool_result = cx\n",
+                "    .update(|cx| tool.run(invocation.input, &messages, project, action_log, cx))\n",
+                "    .output;",
+            ),
+            &mut rng,
+        );
+    }
+
+    #[gpui::test]
+    fn test_line_hint_selection() {
+        let text = indoc! {r#"
+            fn first_function() {
+                return 42;
+            }
+
+            fn second_function() {
+                return 42;
+            }
+
+            fn third_function() {
+                return 42;
+            }
+        "#};
+
+        let buffer = TextBuffer::new(0, BufferId::new(1).unwrap(), text.to_string());
+        let snapshot = buffer.snapshot();
+        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
+
+        // Given a query that matches all three functions
+        let query = "return 42;\n";
+
+        // Test with line hint pointing to second function (around line 5)
+        let best_match = matcher.push(query, Some(5)).expect("Failed to match query");
+
+        let matched_text = snapshot
+            .text_for_range(best_match.clone())
+            .collect::<String>();
+        assert!(matched_text.contains("return 42;"));
+        assert_eq!(
+            best_match,
+            63..77,
+            "Expected to match `second_function` based on the line hint"
+        );
+
+        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
+        matcher.push(query, None);
+        matcher.finish();
+        let best_match = matcher.select_best_match();
+        assert!(
+            best_match.is_none(),
+            "Best match should be None when query cannot be uniquely resolved"
+        );
+    }
+
+    #[track_caller]
+    fn assert_location_resolution(text_with_expected_range: &str, query: &str, rng: &mut StdRng) {
+        let (text, expected_ranges) = marked_text_ranges(text_with_expected_range, false);
+        let buffer = TextBuffer::new(0, BufferId::new(1).unwrap(), text.clone());
+        let snapshot = buffer.snapshot();
+
+        let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone());
+
+        // Split query into random chunks
+        let chunks = to_random_chunks(rng, query);
+
+        // Push chunks incrementally
+        for chunk in &chunks {
+            matcher.push(chunk, None);
+        }
+
+        let actual_ranges = matcher.finish();
+
+        // If no expected ranges, we expect no match
+        if expected_ranges.is_empty() {
+            assert!(
+                actual_ranges.is_empty(),
+                "Expected no match for query: {:?}, but found: {:?}",
+                query,
+                actual_ranges
+            );
+        } else {
+            let text_with_actual_range = generate_marked_text(&text, &actual_ranges, false);
+            pretty_assertions::assert_eq!(
+                text_with_actual_range,
+                text_with_expected_range,
+                indoc! {"
+                    Query: {:?}
+                    Chunks: {:?}
+                    Expected marked text: {}
+                    Actual marked text: {}
+                    Expected ranges: {:?}
+                    Actual ranges: {:?}"
+                },
+                query,
+                chunks,
+                text_with_expected_range,
+                text_with_actual_range,
+                expected_ranges,
+                actual_ranges
+            );
+        }
+    }
+
+    fn to_random_chunks(rng: &mut StdRng, input: &str) -> Vec<String> {
+        let chunk_count = rng.gen_range(1..=cmp::min(input.len(), 50));
+        let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count);
+        chunk_indices.sort();
+        chunk_indices.push(input.len());
+
+        let mut chunks = Vec::new();
+        let mut last_ix = 0;
+        for chunk_ix in chunk_indices {
+            chunks.push(input[last_ix..chunk_ix].to_string());
+            last_ix = chunk_ix;
+        }
+        chunks
+    }
+
+    fn push(finder: &mut StreamingFuzzyMatcher, chunk: &str) -> Option<String> {
+        finder
+            .push(chunk, None)
+            .map(|range| finder.snapshot.text_for_range(range).collect::<String>())
+    }
+
+    fn finish(mut finder: StreamingFuzzyMatcher) -> Option<String> {
+        let snapshot = finder.snapshot.clone();
+        let matches = finder.finish();
+        if let Some(range) = matches.first() {
+            Some(snapshot.text_for_range(range.clone()).collect::<String>())
+        } else {
+            None
+        }
+    }
+}

crates/assistant_tools/src/edit_file_tool.rs 🔗

@@ -1,31 +1,40 @@
 use crate::{
     Templates,
-    edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent},
+    edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat},
     schema::json_schema_for,
+    ui::{COLLAPSED_LINES, ToolOutputPreview},
 };
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use assistant_tool::{
-    ActionLog, AnyToolCard, Tool, ToolCard, ToolResult, ToolResultOutput, ToolUseStatus,
+    ActionLog, AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput,
+    ToolUseStatus,
 };
 use buffer_diff::{BufferDiff, BufferDiffSnapshot};
-use editor::{Editor, EditorMode, MultiBuffer, PathKey};
+use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey};
 use futures::StreamExt;
 use gpui::{
-    Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, EntityId, Task,
-    TextStyleRefinement, WeakEntity, pulsating_between,
+    Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task,
+    TextStyleRefinement, WeakEntity, pulsating_between, px,
 };
 use indoc::formatdoc;
 use language::{
-    Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Rope, TextBuffer,
-    language_settings::SoftWrap,
+    Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Point, Rope,
+    TextBuffer,
+    language_settings::{self, FormatOnSave, SoftWrap},
 };
 use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
-use project::Project;
+use project::{
+    Project, ProjectPath,
+    lsp_store::{FormatTrigger, LspFormatTarget},
+};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::Settings;
 use std::{
+    cmp::Reverse,
+    collections::HashSet,
+    ops::Range,
     path::{Path, PathBuf},
     sync::Arc,
     time::Duration,
@@ -37,7 +46,7 @@ use workspace::Workspace;
 
 pub struct EditFileTool;
 
-#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
 pub struct EditFileToolInput {
     /// A one-line, user-friendly markdown description of the edit. This will be
     /// shown in the UI and also passed to another model to perform the edit.
@@ -60,13 +69,13 @@ pub struct EditFileToolInput {
     /// start each path with one of the project's root directories.
     ///
     /// The following examples assume we have two root directories in the project:
-    /// - backend
-    /// - frontend
+    /// - /a/b/backend
+    /// - /c/d/frontend
     ///
     /// <example>
     /// `backend/src/main.rs`
     ///
-    /// Notice how the file path starts with root-1. Without that, the path
+    /// Notice how the file path starts with `backend`. Without that, the path
     /// would be ambiguous and the call would fail!
     /// </example>
     ///
@@ -75,19 +84,29 @@ pub struct EditFileToolInput {
     /// </example>
     pub path: PathBuf,
 
-    /// If true, this tool will recreate the file from scratch.
-    /// If false, this tool will produce granular edits to an existing file.
+    /// The mode of operation on the file. Possible values:
+    /// - 'edit': Make granular edits to an existing file.
+    /// - 'create': Create a new file if it doesn't exist.
+    /// - 'overwrite': Replace the entire contents of an existing file.
     ///
-    /// When a file already exists or you just created it, always prefer editing
+    /// When a file already exists or you just created it, prefer editing
     /// it as opposed to recreating it from scratch.
-    pub create_or_overwrite: bool,
+    pub mode: EditFileMode,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum EditFileMode {
+    Edit,
+    Create,
+    Overwrite,
 }
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct EditFileToolOutput {
     pub original_path: PathBuf,
     pub new_text: String,
-    pub old_text: String,
+    pub old_text: Arc<String>,
     pub raw_output: Option<EditAgentOutput>,
 }
 
@@ -110,6 +129,10 @@ impl Tool for EditFileTool {
         false
     }
 
+    fn may_perform_edits(&self) -> bool {
+        true
+    }
+
     fn description(&self) -> String {
         include_str!("edit_file_tool/description.md").to_string()
     }
@@ -160,12 +183,9 @@ impl Tool for EditFileTool {
             Err(err) => return Task::ready(Err(anyhow!(err))).into(),
         };
 
-        let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else {
-            return Task::ready(Err(anyhow!(
-                "Path {} not found in project",
-                input.path.display()
-            )))
-            .into();
+        let project_path = match resolve_path(&input, project.clone(), cx) {
+            Ok(path) => path,
+            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
         };
 
         let card = window.and_then(|window| {
@@ -179,8 +199,16 @@ impl Tool for EditFileTool {
         });
 
         let card_clone = card.clone();
+        let action_log_clone = action_log.clone();
         let task = cx.spawn(async move |cx: &mut AsyncApp| {
-            let edit_agent = EditAgent::new(model, project.clone(), action_log, Templates::new());
+            let edit_format = EditFormat::from_model(model.clone())?;
+            let edit_agent = EditAgent::new(
+                model,
+                project.clone(),
+                action_log_clone,
+                Templates::new(),
+                edit_format,
+            );
 
             let buffer = project
                 .update(cx, |project, cx| {
@@ -188,33 +216,27 @@ impl Tool for EditFileTool {
                 })?
                 .await?;
 
-            let exists = buffer.read_with(cx, |buffer, _| {
-                buffer
-                    .file()
-                    .as_ref()
-                    .map_or(false, |file| file.disk_state().exists())
-            })?;
-            if !input.create_or_overwrite && !exists {
-                return Err(anyhow!("{} not found", input.path.display()));
-            }
-
             let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
             let old_text = cx
                 .background_spawn({
                     let old_snapshot = old_snapshot.clone();
-                    async move { old_snapshot.text() }
+                    async move { Arc::new(old_snapshot.text()) }
                 })
                 .await;
 
-            let (output, mut events) = if input.create_or_overwrite {
-                edit_agent.overwrite(
+            if let Some(card) = card_clone.as_ref() {
+                card.update(cx, |card, cx| card.initialize(buffer.clone(), cx))?;
+            }
+
+            let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
+                edit_agent.edit(
                     buffer.clone(),
                     input.display_description.clone(),
                     &request,
                     cx,
                 )
             } else {
-                edit_agent.edit(
+                edit_agent.overwrite(
                     buffer.clone(),
                     input.display_description.clone(),
                     &request,
@@ -223,76 +245,123 @@ impl Tool for EditFileTool {
             };
 
             let mut hallucinated_old_text = false;
+            let mut ambiguous_ranges = Vec::new();
             while let Some(event) = events.next().await {
                 match event {
                     EditAgentOutputEvent::Edited => {
                         if let Some(card) = card_clone.as_ref() {
-                            let new_snapshot =
-                                buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
-                            let new_text = cx
-                                .background_spawn({
-                                    let new_snapshot = new_snapshot.clone();
-                                    async move { new_snapshot.text() }
-                                })
-                                .await;
-                            card.update(cx, |card, cx| {
-                                card.set_diff(
-                                    project_path.path.clone(),
-                                    old_text.clone(),
-                                    new_text,
-                                    cx,
-                                );
-                            })
-                            .log_err();
+                            card.update(cx, |card, cx| card.update_diff(cx))?;
+                        }
+                    }
+                    EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
+                    EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
+                    EditAgentOutputEvent::ResolvingEditRange(range) => {
+                        if let Some(card) = card_clone.as_ref() {
+                            card.update(cx, |card, cx| card.reveal_range(range, cx))?;
                         }
                     }
-                    EditAgentOutputEvent::OldTextNotFound(_) => hallucinated_old_text = true,
                 }
             }
             let agent_output = output.await?;
 
+            // If format_on_save is enabled, format the buffer
+            let format_on_save_enabled = buffer
+                .read_with(cx, |buffer, cx| {
+                    let settings = language_settings::language_settings(
+                        buffer.language().map(|l| l.name()),
+                        buffer.file(),
+                        cx,
+                    );
+                    !matches!(settings.format_on_save, FormatOnSave::Off)
+                })
+                .unwrap_or(false);
+
+            if format_on_save_enabled {
+                let format_task = project.update(cx, |project, cx| {
+                    project.format(
+                        HashSet::from_iter([buffer.clone()]),
+                        LspFormatTarget::Buffers,
+                        false, // Don't push to history since the tool did it.
+                        FormatTrigger::Save,
+                        cx,
+                    )
+                })?;
+                format_task.await.log_err();
+            }
+
             project
                 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
                 .await?;
 
+            // Notify the action log that we've edited the buffer (*after* formatting has completed).
+            action_log.update(cx, |log, cx| {
+                log.buffer_edited(buffer.clone(), cx);
+            })?;
+
             let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
-            let new_text = cx.background_spawn({
-                let new_snapshot = new_snapshot.clone();
-                async move { new_snapshot.text() }
-            });
-            let diff = cx.background_spawn(async move {
-                language::unified_diff(&old_snapshot.text(), &new_snapshot.text())
-            });
-            let (new_text, diff) = futures::join!(new_text, diff);
+            let (new_text, diff) = cx
+                .background_spawn({
+                    let new_snapshot = new_snapshot.clone();
+                    let old_text = old_text.clone();
+                    async move {
+                        let new_text = new_snapshot.text();
+                        let diff = language::unified_diff(&old_text, &new_text);
+
+                        (new_text, diff)
+                    }
+                })
+                .await;
 
             let output = EditFileToolOutput {
                 original_path: project_path.path.to_path_buf(),
                 new_text: new_text.clone(),
-                old_text: old_text.clone(),
+                old_text,
                 raw_output: Some(agent_output),
             };
 
             if let Some(card) = card_clone {
                 card.update(cx, |card, cx| {
-                    card.set_diff(project_path.path.clone(), old_text, new_text, cx);
+                    card.update_diff(cx);
+                    card.finalize(cx)
                 })
                 .log_err();
             }
 
             let input_path = input.path.display();
             if diff.is_empty() {
-                if hallucinated_old_text {
-                    Err(anyhow!(formatdoc! {"
+                anyhow::ensure!(
+                    !hallucinated_old_text,
+                    formatdoc! {"
                         Some edits were produced but none of them could be applied.
                         Read the relevant sections of {input_path} again so that
                         I can perform the requested edits.
-                    "}))
-                } else {
-                    Ok("No edits were made.".to_string().into())
-                }
+                    "}
+                );
+                anyhow::ensure!(
+                    ambiguous_ranges.is_empty(),
+                    {
+                        let line_numbers = ambiguous_ranges
+                            .iter()
+                            .map(|range| range.start.to_string())
+                            .collect::<Vec<_>>()
+                            .join(", ");
+                        formatdoc! {"
+                            <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
+                            relevant sections of {input_path} again and extend <old_text> so
+                            that I can perform the requested edits.
+                        "}
+                    }
+                );
+                Ok(ToolResultOutput {
+                    content: ToolResultContent::Text("No edits were made.".into()),
+                    output: serde_json::to_value(output).ok(),
+                })
             } else {
                 Ok(ToolResultOutput {
-                    content: format!("Edited {}:\n\n```diff\n{}\n```", input_path, diff),
+                    content: ToolResultContent::Text(format!(
+                        "Edited {}:\n\n```diff\n{}\n```",
+                        input_path, diff
+                    )),
                     output: serde_json::to_value(output).ok(),
                 })
             }
@@ -317,31 +386,131 @@ impl Tool for EditFileTool {
         };
 
         let card = cx.new(|cx| {
-            let mut card = EditFileToolCard::new(output.original_path.clone(), project, window, cx);
-            card.set_diff(
-                output.original_path.into(),
-                output.old_text,
-                output.new_text,
-                cx,
-            );
-            card
+            EditFileToolCard::new(output.original_path.clone(), project.clone(), window, cx)
         });
 
+        cx.spawn({
+            let path: Arc<Path> = output.original_path.into();
+            let language_registry = project.read(cx).languages().clone();
+            let card = card.clone();
+            async move |cx| {
+                let buffer =
+                    build_buffer(output.new_text, path.clone(), &language_registry, cx).await?;
+                let buffer_diff =
+                    build_buffer_diff(output.old_text.clone(), &buffer, &language_registry, cx)
+                        .await?;
+                card.update(cx, |card, cx| {
+                    card.multibuffer.update(cx, |multibuffer, cx| {
+                        let snapshot = buffer.read(cx).snapshot();
+                        let diff = buffer_diff.read(cx);
+                        let diff_hunk_ranges = diff
+                            .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
+                            .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
+                            .collect::<Vec<_>>();
+
+                        multibuffer.set_excerpts_for_path(
+                            PathKey::for_buffer(&buffer, cx),
+                            buffer,
+                            diff_hunk_ranges,
+                            editor::DEFAULT_MULTIBUFFER_CONTEXT,
+                            cx,
+                        );
+                        multibuffer.add_diff(buffer_diff, cx);
+                        let end = multibuffer.len(cx);
+                        card.total_lines =
+                            Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1);
+                    });
+
+                    cx.notify();
+                })?;
+                anyhow::Ok(())
+            }
+        })
+        .detach_and_log_err(cx);
+
         Some(card.into())
     }
 }
 
+/// Validate that the file path is valid, meaning:
+///
+/// - For `edit` and `overwrite`, the path must point to an existing file.
+/// - For `create`, the file must not already exist, but it's parent dir must exist.
+fn resolve_path(
+    input: &EditFileToolInput,
+    project: Entity<Project>,
+    cx: &mut App,
+) -> Result<ProjectPath> {
+    let project = project.read(cx);
+
+    match input.mode {
+        EditFileMode::Edit | EditFileMode::Overwrite => {
+            let path = project
+                .find_project_path(&input.path, cx)
+                .context("Can't edit file: path not found")?;
+
+            let entry = project
+                .entry_for_path(&path, cx)
+                .context("Can't edit file: path not found")?;
+
+            anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
+            Ok(path)
+        }
+
+        EditFileMode::Create => {
+            if let Some(path) = project.find_project_path(&input.path, cx) {
+                anyhow::ensure!(
+                    project.entry_for_path(&path, cx).is_none(),
+                    "Can't create file: file already exists"
+                );
+            }
+
+            let parent_path = input
+                .path
+                .parent()
+                .context("Can't create file: incorrect path")?;
+
+            let parent_project_path = project.find_project_path(&parent_path, cx);
+
+            let parent_entry = parent_project_path
+                .as_ref()
+                .and_then(|path| project.entry_for_path(&path, cx))
+                .context("Can't create file: parent directory doesn't exist")?;
+
+            anyhow::ensure!(
+                parent_entry.is_dir(),
+                "Can't create file: parent is not a directory"
+            );
+
+            let file_name = input
+                .path
+                .file_name()
+                .context("Can't create file: invalid filename")?;
+
+            let new_file_path = parent_project_path.map(|parent| ProjectPath {
+                path: Arc::from(parent.path.join(file_name)),
+                ..parent
+            });
+
+            new_file_path.context("Can't create file")
+        }
+    }
+}
+
 pub struct EditFileToolCard {
     path: PathBuf,
     editor: Entity<Editor>,
     multibuffer: Entity<MultiBuffer>,
     project: Entity<Project>,
+    buffer: Option<Entity<Buffer>>,
+    base_text: Option<Arc<String>>,
+    buffer_diff: Option<Entity<BufferDiff>>,
+    revealed_ranges: Vec<Range<Anchor>>,
     diff_task: Option<Task<Result<()>>>,
     preview_expanded: bool,
     error_expanded: Option<Entity<Markdown>>,
     full_height_expanded: bool,
     total_lines: Option<u32>,
-    editor_unique_id: EntityId,
 }
 
 impl EditFileToolCard {
@@ -362,7 +531,9 @@ impl EditFileToolCard {
             editor.set_show_gutter(false, cx);
             editor.disable_inline_diagnostics();
             editor.disable_expand_excerpt_buttons(cx);
-            editor.disable_scrollbars_and_minimap(window, cx);
+            // Keep horizontal scrollbar so user can scroll horizontally if needed
+            editor.set_show_vertical_scrollbar(false, cx);
+            editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
             editor.set_soft_wrap_mode(SoftWrap::None, cx);
             editor.scroll_manager.set_forbid_vertical_scroll(true);
             editor.set_show_indent_guides(false, cx);
@@ -374,59 +545,200 @@ impl EditFileToolCard {
             editor
         });
         Self {
-            editor_unique_id: editor.entity_id(),
             path,
             project,
             editor,
             multibuffer,
+            buffer: None,
+            base_text: None,
+            buffer_diff: None,
+            revealed_ranges: Vec::new(),
             diff_task: None,
             preview_expanded: true,
             error_expanded: None,
-            full_height_expanded: false,
+            full_height_expanded: true,
             total_lines: None,
         }
     }
 
-    pub fn has_diff(&self) -> bool {
-        self.total_lines.is_some()
+    pub fn initialize(&mut self, buffer: Entity<Buffer>, cx: &mut App) {
+        let buffer_snapshot = buffer.read(cx).snapshot();
+        let base_text = buffer_snapshot.text();
+        let language_registry = buffer.read(cx).language_registry();
+        let text_snapshot = buffer.read(cx).text_snapshot();
+
+        // Create a buffer diff with the current text as the base
+        let buffer_diff = cx.new(|cx| {
+            let mut diff = BufferDiff::new(&text_snapshot, cx);
+            let _ = diff.set_base_text(
+                buffer_snapshot.clone(),
+                language_registry,
+                text_snapshot,
+                cx,
+            );
+            diff
+        });
+
+        self.buffer = Some(buffer.clone());
+        self.base_text = Some(base_text.into());
+        self.buffer_diff = Some(buffer_diff.clone());
+
+        // Add the diff to the multibuffer
+        self.multibuffer
+            .update(cx, |multibuffer, cx| multibuffer.add_diff(buffer_diff, cx));
+    }
+
+    pub fn is_loading(&self) -> bool {
+        self.total_lines.is_none()
     }
 
-    pub fn set_diff(
-        &mut self,
-        path: Arc<Path>,
-        old_text: String,
-        new_text: String,
-        cx: &mut Context<Self>,
-    ) {
-        let language_registry = self.project.read(cx).languages().clone();
+    pub fn update_diff(&mut self, cx: &mut Context<Self>) {
+        let Some(buffer) = self.buffer.as_ref() else {
+            return;
+        };
+        let Some(buffer_diff) = self.buffer_diff.as_ref() else {
+            return;
+        };
+
+        let buffer = buffer.clone();
+        let buffer_diff = buffer_diff.clone();
+        let base_text = self.base_text.clone();
         self.diff_task = Some(cx.spawn(async move |this, cx| {
-            let buffer = build_buffer(new_text, path.clone(), &language_registry, cx).await?;
-            let buffer_diff = build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
+            let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot())?;
+            let diff_snapshot = BufferDiff::update_diff(
+                buffer_diff.clone(),
+                text_snapshot.clone(),
+                base_text,
+                false,
+                false,
+                None,
+                None,
+                cx,
+            )
+            .await?;
+            buffer_diff.update(cx, |diff, cx| {
+                diff.set_snapshot(diff_snapshot, &text_snapshot, cx)
+            })?;
+            this.update(cx, |this, cx| this.update_visible_ranges(cx))
+        }));
+    }
+
+    pub fn reveal_range(&mut self, range: Range<Anchor>, cx: &mut Context<Self>) {
+        self.revealed_ranges.push(range);
+        self.update_visible_ranges(cx);
+    }
+
+    fn update_visible_ranges(&mut self, cx: &mut Context<Self>) {
+        let Some(buffer) = self.buffer.as_ref() else {
+            return;
+        };
+
+        let ranges = self.excerpt_ranges(cx);
+        self.total_lines = self.multibuffer.update(cx, |multibuffer, cx| {
+            multibuffer.set_excerpts_for_path(
+                PathKey::for_buffer(buffer, cx),
+                buffer.clone(),
+                ranges,
+                editor::DEFAULT_MULTIBUFFER_CONTEXT,
+                cx,
+            );
+            let end = multibuffer.len(cx);
+            Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
+        });
+        cx.notify();
+    }
+
+    fn excerpt_ranges(&self, cx: &App) -> Vec<Range<Point>> {
+        let Some(buffer) = self.buffer.as_ref() else {
+            return Vec::new();
+        };
+        let Some(diff) = self.buffer_diff.as_ref() else {
+            return Vec::new();
+        };
+
+        let buffer = buffer.read(cx);
+        let diff = diff.read(cx);
+        let mut ranges = diff
+            .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx)
+            .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer))
+            .collect::<Vec<_>>();
+        ranges.extend(
+            self.revealed_ranges
+                .iter()
+                .map(|range| range.to_point(&buffer)),
+        );
+        ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end)));
+
+        // Merge adjacent ranges
+        let mut ranges = ranges.into_iter().peekable();
+        let mut merged_ranges = Vec::new();
+        while let Some(mut range) = ranges.next() {
+            while let Some(next_range) = ranges.peek() {
+                if range.end >= next_range.start {
+                    range.end = range.end.max(next_range.end);
+                    ranges.next();
+                } else {
+                    break;
+                }
+            }
+
+            merged_ranges.push(range);
+        }
+        merged_ranges
+    }
+
+    pub fn finalize(&mut self, cx: &mut Context<Self>) -> Result<()> {
+        let ranges = self.excerpt_ranges(cx);
+        let buffer = self.buffer.take().context("card was already finalized")?;
+        let base_text = self
+            .base_text
+            .take()
+            .context("card was already finalized")?;
+        let language_registry = self.project.read(cx).languages().clone();
+
+        // Replace the buffer in the multibuffer with the snapshot
+        let buffer = cx.new(|cx| {
+            let language = buffer.read(cx).language().cloned();
+            let buffer = TextBuffer::new_normalized(
+                0,
+                cx.entity_id().as_non_zero_u64().into(),
+                buffer.read(cx).line_ending(),
+                buffer.read(cx).as_rope().clone(),
+            );
+            let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
+            buffer.set_language(language, cx);
+            buffer
+        });
 
+        let buffer_diff = cx.spawn({
+            let buffer = buffer.clone();
+            let language_registry = language_registry.clone();
+            async move |_this, cx| {
+                build_buffer_diff(base_text, &buffer, &language_registry, cx).await
+            }
+        });
+
+        cx.spawn(async move |this, cx| {
+            let buffer_diff = buffer_diff.await?;
             this.update(cx, |this, cx| {
-                this.total_lines = this.multibuffer.update(cx, |multibuffer, cx| {
-                    let snapshot = buffer.read(cx).snapshot();
-                    let diff = buffer_diff.read(cx);
-                    let diff_hunk_ranges = diff
-                        .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
-                        .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
-                        .collect::<Vec<_>>();
+                this.multibuffer.update(cx, |multibuffer, cx| {
+                    let path_key = PathKey::for_buffer(&buffer, cx);
                     multibuffer.clear(cx);
                     multibuffer.set_excerpts_for_path(
-                        PathKey::for_buffer(&buffer, cx),
+                        path_key,
                         buffer,
-                        diff_hunk_ranges,
+                        ranges,
                         editor::DEFAULT_MULTIBUFFER_CONTEXT,
                         cx,
                     );
-                    multibuffer.add_diff(buffer_diff, cx);
-                    let end = multibuffer.len(cx);
-                    Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
+                    multibuffer.add_diff(buffer_diff.clone(), cx);
                 });
 
                 cx.notify();
             })
-        }));
+        })
+        .detach_and_log_err(cx);
+        Ok(())
     }
 }
 
@@ -444,7 +756,7 @@ impl ToolCard for EditFileToolCard {
         };
 
         let path_label_button = h_flex()
-            .id(("edit-tool-path-label-button", self.editor_unique_id))
+            .id(("edit-tool-path-label-button", self.editor.entity_id()))
             .w_full()
             .max_w_full()
             .px_1()
@@ -498,11 +810,30 @@ impl ToolCard for EditFileToolCard {
                                         if let Some(active_editor) = item.downcast::<Editor>() {
                                             active_editor
                                                 .update_in(cx, |editor, window, cx| {
-                                                    editor.go_to_singleton_buffer_point(
-                                                        language::Point::new(0, 0),
-                                                        window,
-                                                        cx,
-                                                    );
+                                                    let snapshot =
+                                                        editor.buffer().read(cx).snapshot(cx);
+                                                    let first_hunk = editor
+                                                        .diff_hunks_in_ranges(
+                                                            &[editor::Anchor::min()
+                                                                ..editor::Anchor::max()],
+                                                            &snapshot,
+                                                        )
+                                                        .next();
+                                                    if let Some(first_hunk) = first_hunk {
+                                                        let first_hunk_start =
+                                                            first_hunk.multi_buffer_range().start;
+                                                        editor.change_selections(
+                                                            Default::default(),
+                                                            window,
+                                                            cx,
+                                                            |selections| {
+                                                                selections.select_anchor_ranges([
+                                                                    first_hunk_start
+                                                                        ..first_hunk_start,
+                                                                ]);
+                                                            },
+                                                        )
+                                                    }
                                                 })
                                                 .log_err();
                                         }
@@ -543,7 +874,7 @@ impl ToolCard for EditFileToolCard {
                         )
                         .child(
                             Disclosure::new(
-                                ("edit-file-error-disclosure", self.editor_unique_id),
+                                ("edit-file-error-disclosure", self.editor.entity_id()),
                                 self.error_expanded.is_some(),
                             )
                             .opened_icon(IconName::ChevronUp)
@@ -565,10 +896,10 @@ impl ToolCard for EditFileToolCard {
                         ),
                 )
             })
-            .when(error_message.is_none() && self.has_diff(), |header| {
+            .when(error_message.is_none() && !self.is_loading(), |header| {
                 header.child(
                     Disclosure::new(
-                        ("edit-file-disclosure", self.editor_unique_id),
+                        ("edit-file-disclosure", self.editor.entity_id()),
                         self.preview_expanded,
                     )
                     .opened_icon(IconName::ChevronUp)
@@ -600,30 +931,8 @@ impl ToolCard for EditFileToolCard {
             (element.into_any_element(), line_height)
         });
 
-        let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
-            (IconName::ChevronUp, "Collapse Code Block")
-        } else {
-            (IconName::ChevronDown, "Expand Code Block")
-        };
-
-        let gradient_overlay =
-            div()
-                .absolute()
-                .bottom_0()
-                .left_0()
-                .w_full()
-                .h_2_5()
-                .bg(gpui::linear_gradient(
-                    0.,
-                    gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
-                    gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
-                ));
-
         let border_color = cx.theme().colors().border.opacity(0.6);
 
-        const DEFAULT_COLLAPSED_LINES: u32 = 10;
-        let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
-
         let waiting_for_diff = {
             let styles = [
                 ("w_4_5", (0.1, 0.85), 2000),
@@ -704,52 +1013,38 @@ impl ToolCard for EditFileToolCard {
                         ),
                 )
             })
-            .when(!self.has_diff() && error_message.is_none(), |card| {
+            .when(self.is_loading() && error_message.is_none(), |card| {
                 card.child(waiting_for_diff)
             })
-            .when(self.preview_expanded && self.has_diff(), |card| {
+            .when(self.preview_expanded && !self.is_loading(), |card| {
+                let editor_view = v_flex()
+                    .relative()
+                    .h_full()
+                    .when(!self.full_height_expanded, |editor_container| {
+                        editor_container.max_h(px(COLLAPSED_LINES as f32 * editor_line_height.0))
+                    })
+                    .overflow_hidden()
+                    .border_t_1()
+                    .border_color(border_color)
+                    .bg(cx.theme().colors().editor_background)
+                    .child(editor);
+
                 card.child(
-                    v_flex()
-                        .relative()
-                        .h_full()
-                        .when(!self.full_height_expanded, |editor_container| {
-                            editor_container
-                                .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
-                        })
-                        .overflow_hidden()
-                        .border_t_1()
-                        .border_color(border_color)
-                        .bg(cx.theme().colors().editor_background)
-                        .child(editor)
-                        .when(
-                            !self.full_height_expanded && is_collapsible,
-                            |editor_container| editor_container.child(gradient_overlay),
-                        ),
+                    ToolOutputPreview::new(editor_view.into_any_element(), self.editor.entity_id())
+                        .with_total_lines(self.total_lines.unwrap_or(0) as usize)
+                        .toggle_state(self.full_height_expanded)
+                        .with_collapsed_fade()
+                        .on_toggle({
+                            let this = cx.entity().downgrade();
+                            move |is_expanded, _window, cx| {
+                                if let Some(this) = this.upgrade() {
+                                    this.update(cx, |this, _cx| {
+                                        this.full_height_expanded = is_expanded;
+                                    });
+                                }
+                            }
+                        }),
                 )
-                .when(is_collapsible, |card| {
-                    card.child(
-                        h_flex()
-                            .id(("expand-button", self.editor_unique_id))
-                            .flex_none()
-                            .cursor_pointer()
-                            .h_5()
-                            .justify_center()
-                            .border_t_1()
-                            .rounded_b_md()
-                            .border_color(border_color)
-                            .bg(cx.theme().colors().editor_background)
-                            .hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
-                            .child(
-                                Icon::new(full_height_icon)
-                                    .size(IconSize::Small)
-                                    .color(Color::Muted),
-                            )
-                            .tooltip(Tooltip::text(full_height_tooltip_label))
-                            .on_click(cx.listener(move |this, _event, _window, _cx| {
-                                this.full_height_expanded = !this.full_height_expanded;
-                            })),
-                    )
-                })
             })
     }
 }
@@ -770,7 +1065,7 @@ fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
 
     MarkdownStyle {
         base_text_style: text_style.clone(),
-        selection_background_color: cx.theme().players().local().selection,
+        selection_background_color: cx.theme().colors().element_selection_background,
         ..Default::default()
     }
 }
@@ -803,19 +1098,23 @@ async fn build_buffer(
 }
 
 async fn build_buffer_diff(
-    mut old_text: String,
+    old_text: Arc<String>,
     buffer: &Entity<Buffer>,
     language_registry: &Arc<LanguageRegistry>,
     cx: &mut AsyncApp,
 ) -> Result<Entity<BufferDiff>> {
-    LineEnding::normalize(&mut old_text);
-
     let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
 
+    let old_text_rope = cx
+        .background_spawn({
+            let old_text = old_text.clone();
+            async move { Rope::from(old_text.as_str()) }
+        })
+        .await;
     let base_buffer = cx
         .update(|cx| {
             Buffer::build_snapshot(
-                old_text.clone().into(),
+                old_text_rope,
                 buffer.language().cloned(),
                 Some(language_registry.clone()),
                 cx,
@@ -827,7 +1126,7 @@ async fn build_buffer_diff(
         .update(|cx| {
             BufferDiffSnapshot::new_with_base_buffer(
                 buffer.text.clone(),
-                Some(old_text.into()),
+                Some(old_text),
                 base_buffer,
                 cx,
             )
@@ -851,8 +1150,9 @@ async fn build_buffer_diff(
 #[cfg(test)]
 mod tests {
     use super::*;
-    use fs::FakeFs;
-    use gpui::TestAppContext;
+    use client::TelemetrySettings;
+    use fs::{FakeFs, Fs};
+    use gpui::{TestAppContext, UpdateGlobal};
     use language_model::fake_provider::FakeLanguageModel;
     use serde_json::json;
     use settings::SettingsStore;
@@ -872,7 +1172,7 @@ mod tests {
                 let input = serde_json::to_value(EditFileToolInput {
                     display_description: "Some edit".into(),
                     path: "root/nonexistent_file.txt".into(),
-                    create_or_overwrite: false,
+                    mode: EditFileMode::Edit,
                 })
                 .unwrap();
                 Arc::new(EditFileTool)
@@ -890,8 +1190,100 @@ mod tests {
             .await;
         assert_eq!(
             result.unwrap_err().to_string(),
-            "root/nonexistent_file.txt not found"
+            "Can't edit file: path not found"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
+        let mode = &EditFileMode::Create;
+
+        let result = test_resolve_path(mode, "root/new.txt", cx);
+        assert_resolved_path_eq(result.await, "new.txt");
+
+        let result = test_resolve_path(mode, "new.txt", cx);
+        assert_resolved_path_eq(result.await, "new.txt");
+
+        let result = test_resolve_path(mode, "dir/new.txt", cx);
+        assert_resolved_path_eq(result.await, "dir/new.txt");
+
+        let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
+        assert_eq!(
+            result.await.unwrap_err().to_string(),
+            "Can't create file: file already exists"
+        );
+
+        let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
+        assert_eq!(
+            result.await.unwrap_err().to_string(),
+            "Can't create file: parent directory doesn't exist"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
+        let mode = &EditFileMode::Edit;
+
+        let path_with_root = "root/dir/subdir/existing.txt";
+        let path_without_root = "dir/subdir/existing.txt";
+        let result = test_resolve_path(mode, path_with_root, cx);
+        assert_resolved_path_eq(result.await, path_without_root);
+
+        let result = test_resolve_path(mode, path_without_root, cx);
+        assert_resolved_path_eq(result.await, path_without_root);
+
+        let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
+        assert_eq!(
+            result.await.unwrap_err().to_string(),
+            "Can't edit file: path not found"
         );
+
+        let result = test_resolve_path(mode, "root/dir", cx);
+        assert_eq!(
+            result.await.unwrap_err().to_string(),
+            "Can't edit file: path is a directory"
+        );
+    }
+
+    async fn test_resolve_path(
+        mode: &EditFileMode,
+        path: &str,
+        cx: &mut TestAppContext,
+    ) -> anyhow::Result<ProjectPath> {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/root",
+            json!({
+                "dir": {
+                    "subdir": {
+                        "existing.txt": "hello"
+                    }
+                }
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+
+        let input = EditFileToolInput {
+            display_description: "Some edit".into(),
+            path: path.into(),
+            mode: mode.clone(),
+        };
+
+        let result = cx.update(|cx| resolve_path(&input, project, cx));
+        result
+    }
+
+    fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
+        let actual = path
+            .expect("Should return valid path")
+            .path
+            .to_str()
+            .unwrap()
+            .replace("\\", "/"); // Naive Windows paths normalization
+        assert_eq!(actual, expected);
     }
 
     #[test]

crates/assistant_tools/src/fetch_tool.rs 🔗

@@ -1,6 +1,6 @@
-use std::cell::RefCell;
 use std::rc::Rc;
 use std::sync::Arc;
+use std::{borrow::Cow, cell::RefCell};
 
 use crate::schema::json_schema_for;
 use anyhow::{Context as _, Result, anyhow, bail};
@@ -39,10 +39,11 @@ impl FetchTool {
     }
 
     async fn build_message(http_client: Arc<HttpClientWithUrl>, url: &str) -> Result<String> {
-        let mut url = url.to_owned();
-        if !url.starts_with("https://") && !url.starts_with("http://") {
-            url = format!("https://{url}");
-        }
+        let url = if !url.starts_with("https://") && !url.starts_with("http://") {
+            Cow::Owned(format!("https://{url}"))
+        } else {
+            Cow::Borrowed(url)
+        };
 
         let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
 
@@ -117,7 +118,11 @@ impl Tool for FetchTool {
     }
 
     fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
-        true
+        false
+    }
+
+    fn may_perform_edits(&self) -> bool {
+        false
     }
 
     fn description(&self) -> String {
@@ -156,8 +161,7 @@ impl Tool for FetchTool {
 
         let text = cx.background_spawn({
             let http_client = self.http_client.clone();
-            let url = input.url.clone();
-            async move { Self::build_message(http_client, &url).await }
+            async move { Self::build_message(http_client, &input.url).await }
         });
 
         cx.foreground_executor()

crates/assistant_tools/src/find_path_tool.rs 🔗

@@ -1,6 +1,8 @@
 use crate::{schema::json_schema_for, ui::ToolCallCardHeader};
 use anyhow::{Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
+use assistant_tool::{
+    ActionLog, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus,
+};
 use editor::Editor;
 use futures::channel::oneshot::{self, Receiver};
 use gpui::{
@@ -38,6 +40,12 @@ pub struct FindPathToolInput {
     pub offset: usize,
 }
 
+#[derive(Debug, Serialize, Deserialize)]
+struct FindPathToolOutput {
+    glob: String,
+    paths: Vec<PathBuf>,
+}
+
 const RESULTS_PER_PAGE: usize = 50;
 
 pub struct FindPathTool;
@@ -51,6 +59,10 @@ impl Tool for FindPathTool {
         false
     }
 
+    fn may_perform_edits(&self) -> bool {
+        false
+    }
+
     fn description(&self) -> String {
         include_str!("./find_path_tool/description.md").into()
     }
@@ -111,10 +123,20 @@ impl Tool for FindPathTool {
                     )
                     .unwrap();
                 }
-                for mat in matches.into_iter().skip(offset).take(RESULTS_PER_PAGE) {
+
+                for mat in matches.iter().skip(offset).take(RESULTS_PER_PAGE) {
                     write!(&mut message, "\n{}", mat.display()).unwrap();
                 }
-                Ok(message.into())
+
+                let output = FindPathToolOutput {
+                    glob,
+                    paths: matches,
+                };
+
+                Ok(ToolResultOutput {
+                    content: ToolResultContent::Text(message),
+                    output: Some(serde_json::to_value(output)?),
+                })
             }
         });
 
@@ -123,6 +145,18 @@ impl Tool for FindPathTool {
             card: Some(card.into()),
         }
     }
+
+    fn deserialize_card(
+        self: Arc<Self>,
+        output: serde_json::Value,
+        _project: Entity<Project>,
+        _window: &mut Window,
+        cx: &mut App,
+    ) -> Option<assistant_tool::AnyToolCard> {
+        let output = serde_json::from_value::<FindPathToolOutput>(output).ok()?;
+        let card = cx.new(|_| FindPathToolCard::from_output(output));
+        Some(card.into())
+    }
 }
 
 fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Result<Vec<PathBuf>>> {
@@ -180,6 +214,15 @@ impl FindPathToolCard {
             _receiver_task: Some(_receiver_task),
         }
     }
+
+    fn from_output(output: FindPathToolOutput) -> Self {
+        Self {
+            glob: output.glob,
+            paths: output.paths,
+            expanded: false,
+            _receiver_task: None,
+        }
+    }
 }
 
 impl ToolCard for FindPathToolCard {
@@ -198,8 +241,6 @@ impl ToolCard for FindPathToolCard {
             format!("{} matches", self.paths.len()).into()
         };
 
-        let glob_label = self.glob.to_string();
-
         let content = if !self.paths.is_empty() && self.expanded {
             Some(
                 v_flex()
@@ -273,7 +314,7 @@ impl ToolCard for FindPathToolCard {
             .gap_1()
             .child(
                 ToolCallCardHeader::new(IconName::SearchCode, matches_label)
-                    .with_code_path(glob_label)
+                    .with_code_path(&self.glob)
                     .disclosure_slot(
                         Disclosure::new("path-search-disclosure", self.expanded)
                             .opened_icon(IconName::ChevronUp)

crates/assistant_tools/src/grep_tool.rs 🔗

@@ -6,11 +6,12 @@ use gpui::{AnyWindowHandle, App, Entity, Task};
 use language::{OffsetRangeExt, ParseStatus, Point};
 use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
 use project::{
-    Project,
+    Project, WorktreeSettings,
     search::{SearchQuery, SearchResult},
 };
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
+use settings::Settings;
 use std::{cmp, fmt::Write, sync::Arc};
 use ui::IconName;
 use util::RangeExt;
@@ -60,6 +61,10 @@ impl Tool for GrepTool {
         false
     }
 
+    fn may_perform_edits(&self) -> bool {
+        false
+    }
+
     fn description(&self) -> String {
         include_str!("./grep_tool/description.md").into()
     }
@@ -109,7 +114,7 @@ impl Tool for GrepTool {
         let input = match serde_json::from_value::<GrepToolInput>(input) {
             Ok(input) => input,
             Err(error) => {
-                return Task::ready(Err(anyhow!("Failed to parse input: {}", error))).into();
+                return Task::ready(Err(anyhow!("Failed to parse input: {error}"))).into();
             }
         };
 
@@ -122,7 +127,24 @@ impl Tool for GrepTool {
         ) {
             Ok(matcher) => matcher,
             Err(error) => {
-                return Task::ready(Err(anyhow!("invalid include glob pattern: {}", error))).into();
+                return Task::ready(Err(anyhow!("invalid include glob pattern: {error}"))).into();
+            }
+        };
+
+        // Exclude global file_scan_exclusions and private_files settings
+        let exclude_matcher = {
+            let global_settings = WorktreeSettings::get_global(cx);
+            let exclude_patterns = global_settings
+                .file_scan_exclusions
+                .sources()
+                .iter()
+                .chain(global_settings.private_files.sources().iter());
+
+            match PathMatcher::new(exclude_patterns) {
+                Ok(matcher) => matcher,
+                Err(error) => {
+                    return Task::ready(Err(anyhow!("invalid exclude pattern: {error}"))).into();
+                }
             }
         };
 
@@ -133,7 +155,7 @@ impl Tool for GrepTool {
             false,
             false,
             include_matcher,
-            PathMatcher::default(), // For now, keep it simple and don't enable an exclude pattern.
+            exclude_matcher,
             true, // Always match file include pattern against *full project paths* that start with a project root.
             None,
         ) {
@@ -156,12 +178,24 @@ impl Tool for GrepTool {
                     continue;
                 }
 
-                let (Some(path), mut parse_status) = buffer.read_with(cx, |buffer, cx| {
+                let Ok((Some(path), mut parse_status)) = buffer.read_with(cx, |buffer, cx| {
                     (buffer.file().map(|file| file.full_path(cx)), buffer.parse_status())
-                })? else {
+                }) else {
                     continue;
                 };
 
+                // Check if this file should be excluded based on its worktree settings
+                if let Ok(Some(project_path)) = project.read_with(cx, |project, cx| {
+                    project.find_project_path(&path, cx)
+                }) {
+                    if cx.update(|cx| {
+                        let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
+                        worktree_settings.is_path_excluded(&project_path.path)
+                            || worktree_settings.is_path_private(&project_path.path)
+                    }).unwrap_or(false) {
+                        continue;
+                    }
+                }
 
                 while *parse_status.borrow() != ParseStatus::Idle {
                     parse_status.changed().await?;
@@ -280,10 +314,11 @@ impl Tool for GrepTool {
 mod tests {
     use super::*;
     use assistant_tool::Tool;
-    use gpui::{AppContext, TestAppContext};
+    use gpui::{AppContext, TestAppContext, UpdateGlobal};
     use language::{Language, LanguageConfig, LanguageMatcher};
     use language_model::fake_provider::FakeLanguageModel;
-    use project::{FakeFs, Project};
+    use project::{FakeFs, Project, WorktreeSettings};
+    use serde_json::json;
     use settings::SettingsStore;
     use unindent::Unindent;
     use util::path;
@@ -295,7 +330,7 @@ mod tests {
 
         let fs = FakeFs::new(cx.executor().clone());
         fs.insert_tree(
-            "/root",
+            path!("/root"),
             serde_json::json!({
                 "src": {
                     "main.rs": "fn main() {\n    println!(\"Hello, world!\");\n}",
@@ -383,7 +418,7 @@ mod tests {
 
         let fs = FakeFs::new(cx.executor().clone());
         fs.insert_tree(
-            "/root",
+            path!("/root"),
             serde_json::json!({
                 "case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true",
             }),
@@ -464,7 +499,7 @@ mod tests {
 
         // Create test file with syntax structures
         fs.insert_tree(
-            "/root",
+            path!("/root"),
             serde_json::json!({
                 "test_syntax.rs": r#"
                     fn top_level_function() {
@@ -752,9 +787,9 @@ mod tests {
         match task.output.await {
             Ok(result) => {
                 if cfg!(windows) {
-                    result.content.replace("root\\", "root/")
+                    result.content.as_str().unwrap().replace("root\\", "root/")
                 } else {
-                    result.content
+                    result.content.as_str().unwrap().to_string()
                 }
             }
             Err(e) => panic!("Failed to run grep tool: {}", e),
@@ -785,4 +820,488 @@ mod tests {
         .with_outline_query(include_str!("../../languages/src/rust/outline.scm"))
         .unwrap()
     }
+
+    #[gpui::test]
+    async fn test_grep_security_boundaries(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+
+        fs.insert_tree(
+            path!("/"),
+            json!({
+                "project_root": {
+                    "allowed_file.rs": "fn main() { println!(\"This file is in the project\"); }",
+                    ".mysecrets": "SECRET_KEY=abc123\nfn secret() { /* private */ }",
+                    ".secretdir": {
+                        "config": "fn special_configuration() { /* excluded */ }"
+                    },
+                    ".mymetadata": "fn custom_metadata() { /* excluded */ }",
+                    "subdir": {
+                        "normal_file.rs": "fn normal_file_content() { /* Normal */ }",
+                        "special.privatekey": "fn private_key_content() { /* private */ }",
+                        "data.mysensitive": "fn sensitive_data() { /* private */ }"
+                    }
+                },
+                "outside_project": {
+                    "sensitive_file.rs": "fn outside_function() { /* This file is outside the project */ }"
+                }
+            }),
+        )
+        .await;
+
+        cx.update(|cx| {
+            use gpui::UpdateGlobal;
+            use project::WorktreeSettings;
+            use settings::SettingsStore;
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
+                    settings.file_scan_exclusions = Some(vec![
+                        "**/.secretdir".to_string(),
+                        "**/.mymetadata".to_string(),
+                    ]);
+                    settings.private_files = Some(vec![
+                        "**/.mysecrets".to_string(),
+                        "**/*.privatekey".to_string(),
+                        "**/*.mysensitive".to_string(),
+                    ]);
+                });
+            });
+        });
+
+        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let model = Arc::new(FakeLanguageModel::default());
+
+        // Searching for files outside the project worktree should return no results
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "regex": "outside_function"
+                });
+                Arc::new(GrepTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        let results = result.unwrap();
+        let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+        assert!(
+            paths.is_empty(),
+            "grep_tool should not find files outside the project worktree"
+        );
+
+        // Searching within the project should succeed
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "regex": "main"
+                });
+                Arc::new(GrepTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        let results = result.unwrap();
+        let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+        assert!(
+            paths.iter().any(|p| p.contains("allowed_file.rs")),
+            "grep_tool should be able to search files inside worktrees"
+        );
+
+        // Searching files that match file_scan_exclusions should return no results
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "regex": "special_configuration"
+                });
+                Arc::new(GrepTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        let results = result.unwrap();
+        let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+        assert!(
+            paths.is_empty(),
+            "grep_tool should not search files in .secretdir (file_scan_exclusions)"
+        );
+
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "regex": "custom_metadata"
+                });
+                Arc::new(GrepTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        let results = result.unwrap();
+        let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+        assert!(
+            paths.is_empty(),
+            "grep_tool should not search .mymetadata files (file_scan_exclusions)"
+        );
+
+        // Searching private files should return no results
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "regex": "SECRET_KEY"
+                });
+                Arc::new(GrepTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        let results = result.unwrap();
+        let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+        assert!(
+            paths.is_empty(),
+            "grep_tool should not search .mysecrets (private_files)"
+        );
+
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "regex": "private_key_content"
+                });
+                Arc::new(GrepTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        let results = result.unwrap();
+        let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+        assert!(
+            paths.is_empty(),
+            "grep_tool should not search .privatekey files (private_files)"
+        );
+
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "regex": "sensitive_data"
+                });
+                Arc::new(GrepTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        let results = result.unwrap();
+        let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+        assert!(
+            paths.is_empty(),
+            "grep_tool should not search .mysensitive files (private_files)"
+        );
+
+        // Searching a normal file should still work, even with private_files configured
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "regex": "normal_file_content"
+                });
+                Arc::new(GrepTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        let results = result.unwrap();
+        let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+        assert!(
+            paths.iter().any(|p| p.contains("normal_file.rs")),
+            "Should be able to search normal files"
+        );
+
+        // Path traversal attempts with .. in include_pattern should not escape project
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "regex": "outside_function",
+                    "include_pattern": "../outside_project/**/*.rs"
+                });
+                Arc::new(GrepTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        let results = result.unwrap();
+        let paths = extract_paths_from_results(&results.content.as_str().unwrap());
+        assert!(
+            paths.is_empty(),
+            "grep_tool should not allow escaping project boundaries with relative paths"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_grep_with_multiple_worktree_settings(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+
+        // Create first worktree with its own private files
+        fs.insert_tree(
+            path!("/worktree1"),
+            json!({
+                ".zed": {
+                    "settings.json": r#"{
+                        "file_scan_exclusions": ["**/fixture.*"],
+                        "private_files": ["**/secret.rs"]
+                    }"#
+                },
+                "src": {
+                    "main.rs": "fn main() { let secret_key = \"hidden\"; }",
+                    "secret.rs": "const API_KEY: &str = \"secret_value\";",
+                    "utils.rs": "pub fn get_config() -> String { \"config\".to_string() }"
+                },
+                "tests": {
+                    "test.rs": "fn test_secret() { assert!(true); }",
+                    "fixture.sql": "SELECT * FROM secret_table;"
+                }
+            }),
+        )
+        .await;
+
+        // Create second worktree with different private files
+        fs.insert_tree(
+            path!("/worktree2"),
+            json!({
+                ".zed": {
+                    "settings.json": r#"{
+                        "file_scan_exclusions": ["**/internal.*"],
+                        "private_files": ["**/private.js", "**/data.json"]
+                    }"#
+                },
+                "lib": {
+                    "public.js": "export function getSecret() { return 'public'; }",
+                    "private.js": "const SECRET_KEY = \"private_value\";",
+                    "data.json": "{\"secret_data\": \"hidden\"}"
+                },
+                "docs": {
+                    "README.md": "# Documentation with secret info",
+                    "internal.md": "Internal secret documentation"
+                }
+            }),
+        )
+        .await;
+
+        // Set global settings
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
+                    settings.file_scan_exclusions =
+                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
+                    settings.private_files = Some(vec!["**/.env".to_string()]);
+                });
+            });
+        });
+
+        let project = Project::test(
+            fs.clone(),
+            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
+            cx,
+        )
+        .await;
+
+        // Wait for worktrees to be fully scanned
+        cx.executor().run_until_parked();
+
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let model = Arc::new(FakeLanguageModel::default());
+
+        // Search for "secret" - should exclude files based on worktree-specific settings
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "regex": "secret",
+                    "case_sensitive": false
+                });
+                Arc::new(GrepTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await
+            .unwrap();
+
+        let content = result.content.as_str().unwrap();
+        let paths = extract_paths_from_results(&content);
+
+        // Should find matches in non-private files
+        assert!(
+            paths.iter().any(|p| p.contains("main.rs")),
+            "Should find 'secret' in worktree1/src/main.rs"
+        );
+        assert!(
+            paths.iter().any(|p| p.contains("test.rs")),
+            "Should find 'secret' in worktree1/tests/test.rs"
+        );
+        assert!(
+            paths.iter().any(|p| p.contains("public.js")),
+            "Should find 'secret' in worktree2/lib/public.js"
+        );
+        assert!(
+            paths.iter().any(|p| p.contains("README.md")),
+            "Should find 'secret' in worktree2/docs/README.md"
+        );
+
+        // Should NOT find matches in private/excluded files based on worktree settings
+        assert!(
+            !paths.iter().any(|p| p.contains("secret.rs")),
+            "Should not search in worktree1/src/secret.rs (local private_files)"
+        );
+        assert!(
+            !paths.iter().any(|p| p.contains("fixture.sql")),
+            "Should not search in worktree1/tests/fixture.sql (local file_scan_exclusions)"
+        );
+        assert!(
+            !paths.iter().any(|p| p.contains("private.js")),
+            "Should not search in worktree2/lib/private.js (local private_files)"
+        );
+        assert!(
+            !paths.iter().any(|p| p.contains("data.json")),
+            "Should not search in worktree2/lib/data.json (local private_files)"
+        );
+        assert!(
+            !paths.iter().any(|p| p.contains("internal.md")),
+            "Should not search in worktree2/docs/internal.md (local file_scan_exclusions)"
+        );
+
+        // Test with `include_pattern` specific to one worktree
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "regex": "secret",
+                    "include_pattern": "worktree1/**/*.rs"
+                });
+                Arc::new(GrepTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await
+            .unwrap();
+
+        let content = result.content.as_str().unwrap();
+        let paths = extract_paths_from_results(&content);
+
+        // Should only find matches in worktree1 *.rs files (excluding private ones)
+        assert!(
+            paths.iter().any(|p| p.contains("main.rs")),
+            "Should find match in worktree1/src/main.rs"
+        );
+        assert!(
+            paths.iter().any(|p| p.contains("test.rs")),
+            "Should find match in worktree1/tests/test.rs"
+        );
+        assert!(
+            !paths.iter().any(|p| p.contains("secret.rs")),
+            "Should not find match in excluded worktree1/src/secret.rs"
+        );
+        assert!(
+            paths.iter().all(|p| !p.contains("worktree2")),
+            "Should not find any matches in worktree2"
+        );
+    }
+
+    // Helper function to extract file paths from grep results
+    fn extract_paths_from_results(results: &str) -> Vec<String> {
+        results
+            .lines()
+            .filter(|line| line.starts_with("## Matches in "))
+            .map(|line| {
+                line.strip_prefix("## Matches in ")
+                    .unwrap()
+                    .trim()
+                    .to_string()
+            })
+            .collect()
+    }
 }

crates/assistant_tools/src/grep_tool/description.md 🔗

@@ -6,3 +6,4 @@ Searches the contents of files in the project with a regular expression
 - Never use this tool to search for paths. Only search file contents with this tool.
 - Use this tool when you need to find files containing specific patterns
 - Results are paginated with 20 matches per page. Use the optional 'offset' parameter to request subsequent pages.
+- DO NOT use HTML entities solely to escape characters in the tool parameters.

crates/assistant_tools/src/list_directory_tool.rs 🔗

@@ -3,9 +3,10 @@ use anyhow::{Result, anyhow};
 use assistant_tool::{ActionLog, Tool, ToolResult};
 use gpui::{AnyWindowHandle, App, Entity, Task};
 use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
-use project::Project;
+use project::{Project, WorktreeSettings};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
+use settings::Settings;
 use std::{fmt::Write, path::Path, sync::Arc};
 use ui::IconName;
 use util::markdown::MarkdownInlineCode;
@@ -48,6 +49,10 @@ impl Tool for ListDirectoryTool {
         false
     }
 
+    fn may_perform_edits(&self) -> bool {
+        false
+    }
+
     fn description(&self) -> String {
         include_str!("./list_directory_tool/description.md").into()
     }
@@ -115,28 +120,746 @@ impl Tool for ListDirectoryTool {
         else {
             return Task::ready(Err(anyhow!("Worktree not found"))).into();
         };
-        let worktree = worktree.read(cx);
 
-        let Some(entry) = worktree.entry_for_path(&project_path.path) else {
+        // Check if the directory whose contents we're listing is itself excluded or private
+        let global_settings = WorktreeSettings::get_global(cx);
+        if global_settings.is_path_excluded(&project_path.path) {
+            return Task::ready(Err(anyhow!(
+                "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
+                &input.path
+            )))
+            .into();
+        }
+
+        if global_settings.is_path_private(&project_path.path) {
+            return Task::ready(Err(anyhow!(
+                "Cannot list directory because its path matches the user's global `private_files` setting: {}",
+                &input.path
+            )))
+            .into();
+        }
+
+        let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
+        if worktree_settings.is_path_excluded(&project_path.path) {
+            return Task::ready(Err(anyhow!(
+                "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}",
+                &input.path
+            )))
+            .into();
+        }
+
+        if worktree_settings.is_path_private(&project_path.path) {
+            return Task::ready(Err(anyhow!(
+                "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
+                &input.path
+            )))
+            .into();
+        }
+
+        let worktree_snapshot = worktree.read(cx).snapshot();
+        let worktree_root_name = worktree.read(cx).root_name().to_string();
+
+        let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
             return Task::ready(Err(anyhow!("Path not found: {}", input.path))).into();
         };
 
         if !entry.is_dir() {
             return Task::ready(Err(anyhow!("{} is not a directory.", input.path))).into();
         }
+        let worktree_snapshot = worktree.read(cx).snapshot();
+
+        let mut folders = Vec::new();
+        let mut files = Vec::new();
+
+        for entry in worktree_snapshot.child_entries(&project_path.path) {
+            // Skip private and excluded files and directories
+            if global_settings.is_path_private(&entry.path)
+                || global_settings.is_path_excluded(&entry.path)
+            {
+                continue;
+            }
+
+            if project
+                .read(cx)
+                .find_project_path(&entry.path, cx)
+                .map(|project_path| {
+                    let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
+
+                    worktree_settings.is_path_excluded(&project_path.path)
+                        || worktree_settings.is_path_private(&project_path.path)
+                })
+                .unwrap_or(false)
+            {
+                continue;
+            }
+
+            let full_path = Path::new(&worktree_root_name)
+                .join(&entry.path)
+                .display()
+                .to_string();
+            if entry.is_dir() {
+                folders.push(full_path);
+            } else {
+                files.push(full_path);
+            }
+        }
 
         let mut output = String::new();
-        for entry in worktree.child_entries(&project_path.path) {
-            writeln!(
-                output,
-                "{}",
-                Path::new(worktree.root_name()).join(&entry.path).display(),
-            )
-            .unwrap();
+
+        if !folders.is_empty() {
+            writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();
+        }
+
+        if !files.is_empty() {
+            writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();
         }
+
         if output.is_empty() {
-            return Task::ready(Ok(format!("{} is empty.", input.path).into())).into();
+            writeln!(output, "{} is empty.", input.path).unwrap();
         }
+
         Task::ready(Ok(output.into())).into()
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use assistant_tool::Tool;
+    use gpui::{AppContext, TestAppContext, UpdateGlobal};
+    use indoc::indoc;
+    use language_model::fake_provider::FakeLanguageModel;
+    use project::{FakeFs, Project, WorktreeSettings};
+    use serde_json::json;
+    use settings::SettingsStore;
+    use util::path;
+
+    fn platform_paths(path_str: &str) -> String {
+        if cfg!(target_os = "windows") {
+            path_str.replace("/", "\\")
+        } else {
+            path_str.to_string()
+        }
+    }
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            language::init(cx);
+            Project::init_settings(cx);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                "src": {
+                    "main.rs": "fn main() {}",
+                    "lib.rs": "pub fn hello() {}",
+                    "models": {
+                        "user.rs": "struct User {}",
+                        "post.rs": "struct Post {}"
+                    },
+                    "utils": {
+                        "helper.rs": "pub fn help() {}"
+                    }
+                },
+                "tests": {
+                    "integration_test.rs": "#[test] fn test() {}"
+                },
+                "README.md": "# Project",
+                "Cargo.toml": "[package]"
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let model = Arc::new(FakeLanguageModel::default());
+        let tool = Arc::new(ListDirectoryTool);
+
+        // Test listing root directory
+        let input = json!({
+            "path": "project"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await
+            .unwrap();
+
+        let content = result.content.as_str().unwrap();
+        assert_eq!(
+            content,
+            platform_paths(indoc! {"
+                # Folders:
+                project/src
+                project/tests
+
+                # Files:
+                project/Cargo.toml
+                project/README.md
+            "})
+        );
+
+        // Test listing src directory
+        let input = json!({
+            "path": "project/src"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await
+            .unwrap();
+
+        let content = result.content.as_str().unwrap();
+        assert_eq!(
+            content,
+            platform_paths(indoc! {"
+                # Folders:
+                project/src/models
+                project/src/utils
+
+                # Files:
+                project/src/lib.rs
+                project/src/main.rs
+            "})
+        );
+
+        // Test listing directory with only files
+        let input = json!({
+            "path": "project/tests"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await
+            .unwrap();
+
+        let content = result.content.as_str().unwrap();
+        assert!(!content.contains("# Folders:"));
+        assert!(content.contains("# Files:"));
+        assert!(content.contains(&platform_paths("project/tests/integration_test.rs")));
+    }
+
+    #[gpui::test]
+    async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                "empty_dir": {}
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let model = Arc::new(FakeLanguageModel::default());
+        let tool = Arc::new(ListDirectoryTool);
+
+        let input = json!({
+            "path": "project/empty_dir"
+        });
+
+        let result = cx
+            .update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx))
+            .output
+            .await
+            .unwrap();
+
+        let content = result.content.as_str().unwrap();
+        assert_eq!(content, "project/empty_dir is empty.\n");
+    }
+
+    #[gpui::test]
+    async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                "file.txt": "content"
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let model = Arc::new(FakeLanguageModel::default());
+        let tool = Arc::new(ListDirectoryTool);
+
+        // Test non-existent path
+        let input = json!({
+            "path": "project/nonexistent"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await;
+
+        assert!(result.is_err());
+        assert!(result.unwrap_err().to_string().contains("Path not found"));
+
+        // Test trying to list a file instead of directory
+        let input = json!({
+            "path": "project/file.txt"
+        });
+
+        let result = cx
+            .update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx))
+            .output
+            .await;
+
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("is not a directory")
+        );
+    }
+
+    #[gpui::test]
+    async fn test_list_directory_security(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                "normal_dir": {
+                    "file1.txt": "content",
+                    "file2.txt": "content"
+                },
+                ".mysecrets": "SECRET_KEY=abc123",
+                ".secretdir": {
+                    "config": "special configuration",
+                    "secret.txt": "secret content"
+                },
+                ".mymetadata": "custom metadata",
+                "visible_dir": {
+                    "normal.txt": "normal content",
+                    "special.privatekey": "private key content",
+                    "data.mysensitive": "sensitive data",
+                    ".hidden_subdir": {
+                        "hidden_file.txt": "hidden content"
+                    }
+                }
+            }),
+        )
+        .await;
+
+        // Configure settings explicitly
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
+                    settings.file_scan_exclusions = Some(vec![
+                        "**/.secretdir".to_string(),
+                        "**/.mymetadata".to_string(),
+                        "**/.hidden_subdir".to_string(),
+                    ]);
+                    settings.private_files = Some(vec![
+                        "**/.mysecrets".to_string(),
+                        "**/*.privatekey".to_string(),
+                        "**/*.mysensitive".to_string(),
+                    ]);
+                });
+            });
+        });
+
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let model = Arc::new(FakeLanguageModel::default());
+        let tool = Arc::new(ListDirectoryTool);
+
+        // Listing root directory should exclude private and excluded files
+        let input = json!({
+            "path": "project"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await
+            .unwrap();
+
+        let content = result.content.as_str().unwrap();
+
+        // Should include normal directories
+        assert!(content.contains("normal_dir"), "Should list normal_dir");
+        assert!(content.contains("visible_dir"), "Should list visible_dir");
+
+        // Should NOT include excluded or private files
+        assert!(
+            !content.contains(".secretdir"),
+            "Should not list .secretdir (file_scan_exclusions)"
+        );
+        assert!(
+            !content.contains(".mymetadata"),
+            "Should not list .mymetadata (file_scan_exclusions)"
+        );
+        assert!(
+            !content.contains(".mysecrets"),
+            "Should not list .mysecrets (private_files)"
+        );
+
+        // Trying to list an excluded directory should fail
+        let input = json!({
+            "path": "project/.secretdir"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await;
+
+        assert!(
+            result.is_err(),
+            "Should not be able to list excluded directory"
+        );
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("file_scan_exclusions"),
+            "Error should mention file_scan_exclusions"
+        );
+
+        // Listing a directory should exclude private files within it
+        let input = json!({
+            "path": "project/visible_dir"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await
+            .unwrap();
+
+        let content = result.content.as_str().unwrap();
+
+        // Should include normal files
+        assert!(content.contains("normal.txt"), "Should list normal.txt");
+
+        // Should NOT include private files
+        assert!(
+            !content.contains("privatekey"),
+            "Should not list .privatekey files (private_files)"
+        );
+        assert!(
+            !content.contains("mysensitive"),
+            "Should not list .mysensitive files (private_files)"
+        );
+
+        // Should NOT include subdirectories that match exclusions
+        assert!(
+            !content.contains(".hidden_subdir"),
+            "Should not list .hidden_subdir (file_scan_exclusions)"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+
+        // Create first worktree with its own private files
+        fs.insert_tree(
+            path!("/worktree1"),
+            json!({
+                ".zed": {
+                    "settings.json": r#"{
+                        "file_scan_exclusions": ["**/fixture.*"],
+                        "private_files": ["**/secret.rs", "**/config.toml"]
+                    }"#
+                },
+                "src": {
+                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
+                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
+                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
+                },
+                "tests": {
+                    "test.rs": "mod tests { fn test_it() {} }",
+                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
+                }
+            }),
+        )
+        .await;
+
+        // Create second worktree with different private files
+        fs.insert_tree(
+            path!("/worktree2"),
+            json!({
+                ".zed": {
+                    "settings.json": r#"{
+                        "file_scan_exclusions": ["**/internal.*"],
+                        "private_files": ["**/private.js", "**/data.json"]
+                    }"#
+                },
+                "lib": {
+                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
+                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
+                    "data.json": "{\"api_key\": \"json_secret_key\"}"
+                },
+                "docs": {
+                    "README.md": "# Public Documentation",
+                    "internal.md": "# Internal Secrets and Configuration"
+                }
+            }),
+        )
+        .await;
+
+        // Set global settings
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
+                    settings.file_scan_exclusions =
+                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
+                    settings.private_files = Some(vec!["**/.env".to_string()]);
+                });
+            });
+        });
+
+        let project = Project::test(
+            fs.clone(),
+            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
+            cx,
+        )
+        .await;
+
+        // Wait for worktrees to be fully scanned
+        cx.executor().run_until_parked();
+
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let model = Arc::new(FakeLanguageModel::default());
+        let tool = Arc::new(ListDirectoryTool);
+
+        // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
+        let input = json!({
+            "path": "worktree1/src"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await
+            .unwrap();
+
+        let content = result.content.as_str().unwrap();
+        assert!(content.contains("main.rs"), "Should list main.rs");
+        assert!(
+            !content.contains("secret.rs"),
+            "Should not list secret.rs (local private_files)"
+        );
+        assert!(
+            !content.contains("config.toml"),
+            "Should not list config.toml (local private_files)"
+        );
+
+        // Test listing worktree1/tests - should exclude fixture.sql based on local settings
+        let input = json!({
+            "path": "worktree1/tests"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await
+            .unwrap();
+
+        let content = result.content.as_str().unwrap();
+        assert!(content.contains("test.rs"), "Should list test.rs");
+        assert!(
+            !content.contains("fixture.sql"),
+            "Should not list fixture.sql (local file_scan_exclusions)"
+        );
+
+        // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
+        let input = json!({
+            "path": "worktree2/lib"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await
+            .unwrap();
+
+        let content = result.content.as_str().unwrap();
+        assert!(content.contains("public.js"), "Should list public.js");
+        assert!(
+            !content.contains("private.js"),
+            "Should not list private.js (local private_files)"
+        );
+        assert!(
+            !content.contains("data.json"),
+            "Should not list data.json (local private_files)"
+        );
+
+        // Test listing worktree2/docs - should exclude internal.md based on local settings
+        let input = json!({
+            "path": "worktree2/docs"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await
+            .unwrap();
+
+        let content = result.content.as_str().unwrap();
+        assert!(content.contains("README.md"), "Should list README.md");
+        assert!(
+            !content.contains("internal.md"),
+            "Should not list internal.md (local file_scan_exclusions)"
+        );
+
+        // Test trying to list an excluded directory directly
+        let input = json!({
+            "path": "worktree1/src/secret.rs"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await;
+
+        // This should fail because we're trying to list a file, not a directory
+        assert!(result.is_err(), "Should fail when trying to list a file");
+    }
+}

crates/assistant_tools/src/move_path_tool.rs 🔗

@@ -1,5 +1,5 @@
 use crate::schema::json_schema_for;
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use assistant_tool::{ActionLog, Tool, ToolResult};
 use gpui::{AnyWindowHandle, App, AppContext, Entity, Task};
 use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
@@ -46,6 +46,10 @@ impl Tool for MovePathTool {
         false
     }
 
+    fn may_perform_edits(&self) -> bool {
+        true
+    }
+
     fn description(&self) -> String {
         include_str!("./move_path_tool/description.md").into()
     }
@@ -117,17 +121,10 @@ impl Tool for MovePathTool {
         });
 
         cx.background_spawn(async move {
-            match rename_task.await {
-                Ok(_) => {
-                    Ok(format!("Moved {} to {}", input.source_path, input.destination_path).into())
-                }
-                Err(err) => Err(anyhow!(
-                    "Failed to move {} to {}: {}",
-                    input.source_path,
-                    input.destination_path,
-                    err
-                )),
-            }
+            let _ = rename_task.await.with_context(|| {
+                format!("Moving {} to {}", input.source_path, input.destination_path)
+            })?;
+            Ok(format!("Moved {} to {}", input.source_path, input.destination_path).into())
         })
         .into()
     }

crates/assistant_tools/src/now_tool.rs 🔗

@@ -37,6 +37,10 @@ impl Tool for NowTool {
         false
     }
 
+    fn may_perform_edits(&self) -> bool {
+        false
+    }
+
     fn description(&self) -> String {
         "Returns the current datetime in RFC 3339 format. Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.".into()
     }

crates/assistant_tools/src/open_tool.rs 🔗

@@ -26,7 +26,9 @@ impl Tool for OpenTool {
     fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
         true
     }
-
+    fn may_perform_edits(&self) -> bool {
+        false
+    }
     fn description(&self) -> String {
         include_str!("./open_tool/description.md").to_string()
     }

crates/assistant_tools/src/read_file_tool.rs 🔗

@@ -1,16 +1,21 @@
 use crate::schema::json_schema_for;
-use anyhow::{Result, anyhow};
-use assistant_tool::outline;
+use anyhow::{Context as _, Result, anyhow};
 use assistant_tool::{ActionLog, Tool, ToolResult};
+use assistant_tool::{ToolResultContent, outline};
 use gpui::{AnyWindowHandle, App, Entity, Task};
+use project::{ImageItem, image_store};
 
+use assistant_tool::ToolResultOutput;
 use indoc::formatdoc;
 use itertools::Itertools;
 use language::{Anchor, Point};
-use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
-use project::{AgentLocation, Project};
+use language_model::{
+    LanguageModel, LanguageModelImage, LanguageModelRequest, LanguageModelToolSchemaFormat,
+};
+use project::{AgentLocation, Project, WorktreeSettings};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
+use settings::Settings;
 use std::sync::Arc;
 use ui::IconName;
 use util::markdown::MarkdownInlineCode;
@@ -26,8 +31,8 @@ pub struct ReadFileToolInput {
     /// <example>
     /// If the project has the following root directories:
     ///
-    /// - directory1
-    /// - directory2
+    /// - /a/b/directory1
+    /// - /c/d/directory2
     ///
     /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
     /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
@@ -54,6 +59,10 @@ impl Tool for ReadFileTool {
         false
     }
 
+    fn may_perform_edits(&self) -> bool {
+        false
+    }
+
     fn description(&self) -> String {
         include_str!("./read_file_tool/description.md").into()
     }
@@ -86,7 +95,7 @@ impl Tool for ReadFileTool {
         _request: Arc<LanguageModelRequest>,
         project: Entity<Project>,
         action_log: Entity<ActionLog>,
-        _model: Arc<dyn LanguageModel>,
+        model: Arc<dyn LanguageModel>,
         _window: Option<AnyWindowHandle>,
         cx: &mut App,
     ) -> ToolResult {
@@ -99,7 +108,79 @@ impl Tool for ReadFileTool {
             return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))).into();
         };
 
+        // Error out if this path is either excluded or private in global settings
+        let global_settings = WorktreeSettings::get_global(cx);
+        if global_settings.is_path_excluded(&project_path.path) {
+            return Task::ready(Err(anyhow!(
+                "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
+                &input.path
+            )))
+            .into();
+        }
+
+        if global_settings.is_path_private(&project_path.path) {
+            return Task::ready(Err(anyhow!(
+                "Cannot read file because its path matches the global `private_files` setting: {}",
+                &input.path
+            )))
+            .into();
+        }
+
+        // Error out if this path is either excluded or private in worktree settings
+        let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
+        if worktree_settings.is_path_excluded(&project_path.path) {
+            return Task::ready(Err(anyhow!(
+                "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
+                &input.path
+            )))
+            .into();
+        }
+
+        if worktree_settings.is_path_private(&project_path.path) {
+            return Task::ready(Err(anyhow!(
+                "Cannot read file because its path matches the worktree `private_files` setting: {}",
+                &input.path
+            )))
+            .into();
+        }
+
         let file_path = input.path.clone();
+
+        if image_store::is_image_file(&project, &project_path, cx) {
+            if !model.supports_images() {
+                return Task::ready(Err(anyhow!(
+                    "Attempted to read an image, but Zed doesn't currently support sending images to {}.",
+                    model.name().0
+                )))
+                .into();
+            }
+
+            let task = cx.spawn(async move |cx| -> Result<ToolResultOutput> {
+                let image_entity: Entity<ImageItem> = cx
+                    .update(|cx| {
+                        project.update(cx, |project, cx| {
+                            project.open_image(project_path.clone(), cx)
+                        })
+                    })?
+                    .await?;
+
+                let image =
+                    image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?;
+
+                let language_model_image = cx
+                    .update(|cx| LanguageModelImage::from_image(image, cx))?
+                    .await
+                    .context("processing image")?;
+
+                Ok(ToolResultOutput {
+                    content: ToolResultContent::Image(language_model_image),
+                    output: None,
+                })
+            });
+
+            return task.into();
+        }
+
         cx.spawn(async move |cx| {
             let buffer = cx
                 .update(|cx| {
@@ -112,7 +193,7 @@ impl Tool for ReadFileTool {
                     .as_ref()
                     .map_or(true, |file| !file.disk_state().exists())
             })? {
-                return Err(anyhow!("{} not found", file_path));
+                anyhow::bail!("{file_path} not found");
             }
 
             project.update(cx, |project, cx| {
@@ -208,10 +289,10 @@ impl Tool for ReadFileTool {
 #[cfg(test)]
 mod test {
     use super::*;
-    use gpui::{AppContext, TestAppContext};
+    use gpui::{AppContext, TestAppContext, UpdateGlobal};
     use language::{Language, LanguageConfig, LanguageMatcher};
     use language_model::fake_provider::FakeLanguageModel;
-    use project::{FakeFs, Project};
+    use project::{FakeFs, Project, WorktreeSettings};
     use serde_json::json;
     use settings::SettingsStore;
     use util::path;
@@ -221,7 +302,7 @@ mod test {
         init_test(cx);
 
         let fs = FakeFs::new(cx.executor());
-        fs.insert_tree("/root", json!({})).await;
+        fs.insert_tree(path!("/root"), json!({})).await;
         let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
         let action_log = cx.new(|_| ActionLog::new(project.clone()));
         let model = Arc::new(FakeLanguageModel::default());
@@ -255,7 +336,7 @@ mod test {
 
         let fs = FakeFs::new(cx.executor());
         fs.insert_tree(
-            "/root",
+            path!("/root"),
             json!({
                 "small_file.txt": "This is a small file content"
             }),
@@ -282,7 +363,10 @@ mod test {
                     .output
             })
             .await;
-        assert_eq!(result.unwrap().content, "This is a small file content");
+        assert_eq!(
+            result.unwrap().content.as_str(),
+            Some("This is a small file content")
+        );
     }
 
     #[gpui::test]
@@ -291,7 +375,7 @@ mod test {
 
         let fs = FakeFs::new(cx.executor());
         fs.insert_tree(
-            "/root",
+            path!("/root"),
             json!({
                 "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n    a: u32,\n    b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
             }),
@@ -322,6 +406,7 @@ mod test {
             })
             .await;
         let content = result.unwrap();
+        let content = content.as_str().unwrap();
         assert_eq!(
             content.lines().skip(4).take(6).collect::<Vec<_>>(),
             vec![
@@ -365,6 +450,8 @@ mod test {
             .collect::<Vec<_>>();
         pretty_assertions::assert_eq!(
             content
+                .as_str()
+                .unwrap()
                 .lines()
                 .skip(4)
                 .take(expected_content.len())
@@ -379,7 +466,7 @@ mod test {
 
         let fs = FakeFs::new(cx.executor());
         fs.insert_tree(
-            "/root",
+            path!("/root"),
             json!({
                 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
             }),
@@ -408,7 +495,10 @@ mod test {
                     .output
             })
             .await;
-        assert_eq!(result.unwrap().content, "Line 2\nLine 3\nLine 4");
+        assert_eq!(
+            result.unwrap().content.as_str(),
+            Some("Line 2\nLine 3\nLine 4")
+        );
     }
 
     #[gpui::test]
@@ -417,7 +507,7 @@ mod test {
 
         let fs = FakeFs::new(cx.executor());
         fs.insert_tree(
-            "/root",
+            path!("/root"),
             json!({
                 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
             }),
@@ -448,7 +538,7 @@ mod test {
                     .output
             })
             .await;
-        assert_eq!(result.unwrap().content, "Line 1\nLine 2");
+        assert_eq!(result.unwrap().content.as_str(), Some("Line 1\nLine 2"));
 
         // end_line of 0 should result in at least 1 line
         let result = cx
@@ -471,7 +561,7 @@ mod test {
                     .output
             })
             .await;
-        assert_eq!(result.unwrap().content, "Line 1");
+        assert_eq!(result.unwrap().content.as_str(), Some("Line 1"));
 
         // when start_line > end_line, should still return at least 1 line
         let result = cx
@@ -494,7 +584,7 @@ mod test {
                     .output
             })
             .await;
-        assert_eq!(result.unwrap().content, "Line 3");
+        assert_eq!(result.unwrap().content.as_str(), Some("Line 3"));
     }
 
     fn init_test(cx: &mut TestAppContext) {
@@ -548,4 +638,544 @@ mod test {
         )
         .unwrap()
     }
+
+    #[gpui::test]
+    async fn test_read_file_security(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+
+        fs.insert_tree(
+            path!("/"),
+            json!({
+                "project_root": {
+                    "allowed_file.txt": "This file is in the project",
+                    ".mysecrets": "SECRET_KEY=abc123",
+                    ".secretdir": {
+                        "config": "special configuration"
+                    },
+                    ".mymetadata": "custom metadata",
+                    "subdir": {
+                        "normal_file.txt": "Normal file content",
+                        "special.privatekey": "private key content",
+                        "data.mysensitive": "sensitive data"
+                    }
+                },
+                "outside_project": {
+                    "sensitive_file.txt": "This file is outside the project"
+                }
+            }),
+        )
+        .await;
+
+        cx.update(|cx| {
+            use gpui::UpdateGlobal;
+            use project::WorktreeSettings;
+            use settings::SettingsStore;
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
+                    settings.file_scan_exclusions = Some(vec![
+                        "**/.secretdir".to_string(),
+                        "**/.mymetadata".to_string(),
+                    ]);
+                    settings.private_files = Some(vec![
+                        "**/.mysecrets".to_string(),
+                        "**/*.privatekey".to_string(),
+                        "**/*.mysensitive".to_string(),
+                    ]);
+                });
+            });
+        });
+
+        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let model = Arc::new(FakeLanguageModel::default());
+
+        // Reading a file outside the project worktree should fail
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "path": "/outside_project/sensitive_file.txt"
+                });
+                Arc::new(ReadFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read an absolute path outside a worktree"
+        );
+
+        // Reading a file within the project should succeed
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "path": "project_root/allowed_file.txt"
+                });
+                Arc::new(ReadFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        assert!(
+            result.is_ok(),
+            "read_file_tool should be able to read files inside worktrees"
+        );
+
+        // Reading files that match file_scan_exclusions should fail
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "path": "project_root/.secretdir/config"
+                });
+                Arc::new(ReadFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
+        );
+
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "path": "project_root/.mymetadata"
+                });
+                Arc::new(ReadFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
+        );
+
+        // Reading private files should fail
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "path": "project_root/.mysecrets"
+                });
+                Arc::new(ReadFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read .mysecrets (private_files)"
+        );
+
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "path": "project_root/subdir/special.privatekey"
+                });
+                Arc::new(ReadFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read .privatekey files (private_files)"
+        );
+
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "path": "project_root/subdir/data.mysensitive"
+                });
+                Arc::new(ReadFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read .mysensitive files (private_files)"
+        );
+
+        // Reading a normal file should still work, even with private_files configured
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "path": "project_root/subdir/normal_file.txt"
+                });
+                Arc::new(ReadFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        assert!(result.is_ok(), "Should be able to read normal files");
+        assert_eq!(
+            result.unwrap().content.as_str().unwrap(),
+            "Normal file content"
+        );
+
+        // Path traversal attempts with .. should fail
+        let result = cx
+            .update(|cx| {
+                let input = json!({
+                    "path": "project_root/../outside_project/sensitive_file.txt"
+                });
+                Arc::new(ReadFileTool)
+                    .run(
+                        input,
+                        Arc::default(),
+                        project.clone(),
+                        action_log.clone(),
+                        model.clone(),
+                        None,
+                        cx,
+                    )
+                    .output
+            })
+            .await;
+        assert!(
+            result.is_err(),
+            "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+
+        // Create first worktree with its own private_files setting
+        fs.insert_tree(
+            path!("/worktree1"),
+            json!({
+                "src": {
+                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
+                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
+                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
+                },
+                "tests": {
+                    "test.rs": "mod tests { fn test_it() {} }",
+                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
+                },
+                ".zed": {
+                    "settings.json": r#"{
+                        "file_scan_exclusions": ["**/fixture.*"],
+                        "private_files": ["**/secret.rs", "**/config.toml"]
+                    }"#
+                }
+            }),
+        )
+        .await;
+
+        // Create second worktree with different private_files setting
+        fs.insert_tree(
+            path!("/worktree2"),
+            json!({
+                "lib": {
+                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
+                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
+                    "data.json": "{\"api_key\": \"json_secret_key\"}"
+                },
+                "docs": {
+                    "README.md": "# Public Documentation",
+                    "internal.md": "# Internal Secrets and Configuration"
+                },
+                ".zed": {
+                    "settings.json": r#"{
+                        "file_scan_exclusions": ["**/internal.*"],
+                        "private_files": ["**/private.js", "**/data.json"]
+                    }"#
+                }
+            }),
+        )
+        .await;
+
+        // Set global settings
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
+                    settings.file_scan_exclusions =
+                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
+                    settings.private_files = Some(vec!["**/.env".to_string()]);
+                });
+            });
+        });
+
+        let project = Project::test(
+            fs.clone(),
+            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
+            cx,
+        )
+        .await;
+
+        let action_log = cx.new(|_| ActionLog::new(project.clone()));
+        let model = Arc::new(FakeLanguageModel::default());
+        let tool = Arc::new(ReadFileTool);
+
+        // Test reading allowed files in worktree1
+        let input = json!({
+            "path": "worktree1/src/main.rs"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await
+            .unwrap();
+
+        assert_eq!(
+            result.content.as_str().unwrap(),
+            "fn main() { println!(\"Hello from worktree1\"); }"
+        );
+
+        // Test reading private file in worktree1 should fail
+        let input = json!({
+            "path": "worktree1/src/secret.rs"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await;
+
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("worktree `private_files` setting"),
+            "Error should mention worktree private_files setting"
+        );
+
+        // Test reading excluded file in worktree1 should fail
+        let input = json!({
+            "path": "worktree1/tests/fixture.sql"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await;
+
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("worktree `file_scan_exclusions` setting"),
+            "Error should mention worktree file_scan_exclusions setting"
+        );
+
+        // Test reading allowed files in worktree2
+        let input = json!({
+            "path": "worktree2/lib/public.js"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await
+            .unwrap();
+
+        assert_eq!(
+            result.content.as_str().unwrap(),
+            "export function greet() { return 'Hello from worktree2'; }"
+        );
+
+        // Test reading private file in worktree2 should fail
+        let input = json!({
+            "path": "worktree2/lib/private.js"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await;
+
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("worktree `private_files` setting"),
+            "Error should mention worktree private_files setting"
+        );
+
+        // Test reading excluded file in worktree2 should fail
+        let input = json!({
+            "path": "worktree2/docs/internal.md"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await;
+
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("worktree `file_scan_exclusions` setting"),
+            "Error should mention worktree file_scan_exclusions setting"
+        );
+
+        // Test that files allowed in one worktree but not in another are handled correctly
+        // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
+        let input = json!({
+            "path": "worktree1/src/config.toml"
+        });
+
+        let result = cx
+            .update(|cx| {
+                tool.clone().run(
+                    input,
+                    Arc::default(),
+                    project.clone(),
+                    action_log.clone(),
+                    model.clone(),
+                    None,
+                    cx,
+                )
+            })
+            .output
+            .await;
+
+        assert!(result.is_err());
+        assert!(
+            result
+                .unwrap_err()
+                .to_string()
+                .contains("worktree `private_files` setting"),
+            "Config.toml should be blocked by worktree1's private_files setting"
+        );
+    }
 }

crates/assistant_tools/src/rename_tool/description.md 🔗

@@ -1,15 +0,0 @@
-Renames a symbol across your codebase using the language server's semantic knowledge.
-
-This tool performs a rename refactoring operation on a specified symbol. It uses the project's language server to analyze the code and perform the rename correctly across all files where the symbol is referenced.
-
-Unlike a simple find and replace, this tool understands the semantic meaning of the code, so it only renames the specific symbol you specify and not unrelated text that happens to have the same name.
-
-Examples of symbols you can rename:
-- Variables
-- Functions
-- Classes/structs
-- Fields/properties
-- Methods
-- Interfaces/traits
-
-The language server handles updating all references to the renamed symbol throughout the codebase.

crates/assistant_tools/src/symbol_info_tool/description.md 🔗

@@ -1,11 +0,0 @@
-Gives detailed information about code symbols in your project such as variables, functions, classes, interface, traits, and other programming constructs, using the editor's integrated Language Server Protocol (LSP) servers.
-
-This tool is the preferred way to do things like:
-* Find out where a code symbol is first declared (or first defined - that is, assigned)
-* Find all the places where a code symbol is referenced
-* Find the type definition for a code symbol
-* Find a code symbol's implementation
-
-This tool gives more reliable answers than things like regex searches, because it can account for relevant semantics like aliases. It should be used over textual search tools (e.g. regex) when searching for information about code symbols that this tool supports directly.
-
-This tool should not be used when you need to search for something that is not a code symbol.

crates/assistant_tools/src/templates/create_file_prompt.hbs 🔗

@@ -1,12 +1,15 @@
 You are an expert engineer and your task is to write a new file from scratch.
 
-<file_to_edit>
+You MUST respond with the file's content wrapped in triple backticks (```).
+The backticks should be on their own line.
+The text you output will be saved verbatim as the content of the file.
+Tool calls have been disabled.
+Start your response with ```.
+
+<file_path>
 {{path}}
-</file_to_edit>
+</file_path>
 
 <edit_description>
 {{edit_description}}
 </edit_description>
-
-You MUST respond directly with the file's content, without explanations, additional text or triple backticks.
-The text you output will be saved verbatim as the content of the file.

crates/assistant_tools/src/templates/edit_file_prompt.hbs 🔗

@@ -1,53 +0,0 @@
-You MUST respond with a series of edits to a file, using the following format:
-
-```
-<edits>
-
-<old_text>
-OLD TEXT 1 HERE
-</old_text>
-<new_text>
-NEW TEXT 1 HERE
-</new_text>
-
-<old_text>
-OLD TEXT 2 HERE
-</old_text>
-<new_text>
-NEW TEXT 2 HERE
-</new_text>
-
-<old_text>
-OLD TEXT 3 HERE
-</old_text>
-<new_text>
-NEW TEXT 3 HERE
-</new_text>
-
-</edits>
-```
-
-Rules for editing:
-
-- `old_text` represents lines in the input file that will be replaced with `new_text`.
-- `old_text` MUST exactly match the existing file content, character for character, including indentation.
-- `old_text` MUST NEVER come from the outline, but from actual lines in the file.
-- Strive to be minimal in the lines you replace in `old_text`:
-  - If the lines you want to replace are unique, you MUST include just those in the `old_text`.
-  - If the lines you want to replace are NOT unique, you MUST include enough context around them in `old_text` to distinguish them from other lines.
-- If you want to replace many occurrences of the same text, repeat the same `old_text`/`new_text` pair multiple times and I will apply them sequentially, one occurrence at a time.
-- When reporting multiple edits, each edit assumes the previous one has already been applied! Therefore, you must ensure `old_text` doesn't reference text that has already been modified by a previous edit.
-- Don't explain the edits, just report them.
-- Only edit the file specified in `<file_to_edit>` and NEVER include edits to other files!
-- If you open an <old_text> tag, you MUST close it using </old_text>
-- If you open an <new_text> tag, you MUST close it using </new_text>
-
-<file_to_edit>
-{{path}}
-</file_to_edit>
-
-<edit_description>
-{{edit_description}}
-</edit_description>
-
-Tool calls have been disabled. You MUST start your response with <edits>.

crates/assistant_tools/src/templates/edit_file_prompt_diff_fenced.hbs 🔗

@@ -0,0 +1,77 @@
+You MUST respond with a series of edits to a file, using the following diff format:
+
+```
+<<<<<<< SEARCH line=1
+from flask import Flask
+=======
+import math
+from flask import Flask
+>>>>>>> REPLACE
+
+<<<<<<< SEARCH line=325
+return 0
+=======
+print("Done")
+
+return 0
+>>>>>>> REPLACE
+
+```
+
+# File Editing Instructions
+
+- Use the SEARCH/REPLACE diff format shown above
+- The SEARCH section must exactly match existing file content, including indentation
+- The SEARCH section must come from the actual file, not an outline
+- The SEARCH section cannot be empty
+- `line` should be a starting line number for the text to be replaced
+- Be minimal with replacements:
+  - For unique lines, include only those lines
+  - For non-unique lines, include enough context to identify them
+- Do not escape quotes, newlines, or other characters
+- For multiple occurrences, repeat the same diff block for each instance
+- Edits are sequential - each assumes previous edits are already applied
+- Only edit the specified file
+
+# Example
+
+```
+<<<<<<< SEARCH line=3
+struct User {
+    name: String,
+    email: String,
+}
+=======
+struct User {
+    name: String,
+    email: String,
+    active: bool,
+}
+>>>>>>> REPLACE
+
+<<<<<<< SEARCH line=25
+    let user = User {
+        name: String::from("John"),
+        email: String::from("john@example.com"),
+    };
+=======
+    let user = User {
+        name: String::from("John"),
+        email: String::from("john@example.com"),
+        active: true,
+    };
+>>>>>>> REPLACE
+```
+
+
+# Final instructions
+
+Tool calls have been disabled. You MUST respond using the SEARCH/REPLACE diff format only.
+
+<file_to_edit>
+{{path}}
+</file_to_edit>
+
+<edit_description>
+{{edit_description}}
+</edit_description>

crates/assistant_tools/src/templates/edit_file_prompt_xml.hbs 🔗

@@ -0,0 +1,92 @@
+You MUST respond with a series of edits to a file, using the following format:
+
+```
+<edits>
+
+<old_text line=10>
+OLD TEXT 1 HERE
+</old_text>
+<new_text>
+NEW TEXT 1 HERE
+</new_text>
+
+<old_text line=456>
+OLD TEXT 2 HERE
+</old_text>
+<new_text>
+NEW TEXT 2 HERE
+</new_text>
+
+<old_text line=42>
+OLD TEXT 3 HERE
+</old_text>
+<new_text>
+NEW TEXT 3 HERE
+</new_text>
+
+</edits>
+```
+
+# File Editing Instructions
+
+- Use `<old_text>` and `<new_text>` tags to replace content
+- `<old_text>` must exactly match existing file content, including indentation
+- `<old_text>` must come from the actual file, not an outline
+- `<old_text>` cannot be empty
+- `line` should be a starting line number for the text to be replaced
+- Be minimal with replacements:
+  - For unique lines, include only those lines
+  - For non-unique lines, include enough context to identify them
+- Do not escape quotes, newlines, or other characters within tags
+- For multiple occurrences, repeat the same tag pair for each instance
+- Edits are sequential - each assumes previous edits are already applied
+- Only edit the specified file
+- Always close all tags properly
+
+
+{{!-- The following example adds almost 10% pass rate for Gemini 2.5.
+Claude and gpt-4.1 don't really need it. --}}
+<example>
+<edits>
+
+<old_text line=3>
+struct User {
+    name: String,
+    email: String,
+}
+</old_text>
+<new_text>
+struct User {
+    name: String,
+    email: String,
+    active: bool,
+}
+</new_text>
+
+<old_text line=25>
+    let user = User {
+        name: String::from("John"),
+        email: String::from("john@example.com"),
+    };
+</old_text>
+<new_text>
+    let user = User {
+        name: String::from("John"),
+        email: String::from("john@example.com"),
+        active: true,
+    };
+</new_text>
+
+</edits>
+</example>
+
+
+<file_to_edit>
+{{path}}
+</file_to_edit>
+
+<edit_description>
+{{edit_description}}
+</edit_description>
+
+Tool calls have been disabled. You MUST start your response with <edits>.

crates/assistant_tools/src/terminal_tool.rs 🔗

@@ -1,5 +1,8 @@
-use crate::schema::json_schema_for;
-use anyhow::{Context as _, Result, anyhow, bail};
+use crate::{
+    schema::json_schema_for,
+    ui::{COLLAPSED_LINES, ToolOutputPreview},
+};
+use anyhow::{Context as _, Result, anyhow};
 use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
 use futures::{FutureExt as _, future::Shared};
 use gpui::{
@@ -25,7 +28,7 @@ use terminal_view::TerminalView;
 use theme::ThemeSettings;
 use ui::{Disclosure, Tooltip, prelude::*};
 use util::{
-    get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
+    ResultExt, get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
     time::duration_alt_display,
 };
 use workspace::Workspace;
@@ -77,6 +80,10 @@ impl Tool for TerminalTool {
         true
     }
 
+    fn may_perform_edits(&self) -> bool {
+        false
+    }
+
     fn description(&self) -> String {
         include_str!("./terminal_tool/description.md").to_string()
     }
@@ -125,14 +132,24 @@ impl Tool for TerminalTool {
             Err(err) => return Task::ready(Err(anyhow!(err))).into(),
         };
 
-        let input_path = Path::new(&input.cd);
-        let working_dir = match working_dir(&input, &project, input_path, cx) {
+        let working_dir = match working_dir(&input, &project, cx) {
             Ok(dir) => dir,
             Err(err) => return Task::ready(Err(err)).into(),
         };
         let program = self.determine_shell.clone();
-        let command = format!("({}) </dev/null", input.command);
-        let args = vec!["-c".into(), command.clone()];
+        let command = if cfg!(windows) {
+            format!("$null | & {{{}}}", input.command.replace("\"", "'"))
+        } else if let Some(cwd) = working_dir
+            .as_ref()
+            .and_then(|cwd| cwd.as_os_str().to_str())
+        {
+            // Make sure once we're *inside* the shell, we cd into `cwd`
+            format!("(cd {cwd}; {}) </dev/null", input.command)
+        } else {
+            format!("({}) </dev/null", input.command)
+        };
+        let args = vec!["-c".into(), command];
+
         let cwd = working_dir.clone();
         let env = match &working_dir {
             Some(dir) => project.update(cx, |project, cx| {
@@ -172,9 +189,8 @@ impl Tool for TerminalTool {
                 let mut child = pair.slave.spawn_command(cmd)?;
                 let mut reader = pair.master.try_clone_reader()?;
                 drop(pair);
-                let mut content = Vec::new();
-                reader.read_to_end(&mut content)?;
-                let mut content = String::from_utf8(content)?;
+                let mut content = String::new();
+                reader.read_to_string(&mut content)?;
                 // Massage the pty output a bit to try to match what the terminal codepath gives us
                 LineEnding::normalize(&mut content);
                 content = content
@@ -245,27 +261,29 @@ impl Tool for TerminalTool {
 
                 let terminal_view = window.update(cx, |_, window, cx| {
                     cx.new(|cx| {
-                        TerminalView::new(
+                        let mut view = TerminalView::new(
                             terminal.clone(),
                             workspace.downgrade(),
                             None,
                             project.downgrade(),
-                            true,
                             window,
                             cx,
-                        )
+                        );
+                        view.set_embedded_mode(None, cx);
+                        view
                     })
                 })?;
 
-                let _ = card.update(cx, |card, _| {
+                card.update(cx, |card, _| {
                     card.terminal = Some(terminal_view.clone());
                     card.start_instant = Instant::now();
-                });
+                })
+                .log_err();
 
                 let exit_status = terminal
                     .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
                     .await;
-                let (content, content_line_count) = terminal.update(cx, |terminal, _| {
+                let (content, content_line_count) = terminal.read_with(cx, |terminal, _| {
                     (terminal.get_content(), terminal.total_lines())
                 })?;
 
@@ -276,7 +294,7 @@ impl Tool for TerminalTool {
                     exit_status.map(portable_pty::ExitStatus::from),
                 );
 
-                let _ = card.update(cx, |card, _| {
+                card.update(cx, |card, _| {
                     card.command_finished = true;
                     card.exit_status = exit_status;
                     card.was_content_truncated = processed_content.len() < previous_len;
@@ -284,7 +302,8 @@ impl Tool for TerminalTool {
                     card.content_line_count = content_line_count;
                     card.finished_with_empty_output = finished_with_empty_output;
                     card.elapsed_time = Some(card.start_instant.elapsed());
-                });
+                })
+                .log_err();
 
                 Ok(processed_content.into())
             }
@@ -315,19 +334,13 @@ fn process_content(
     } else {
         content
     };
-    let is_empty = content.trim().is_empty();
-
-    let content = format!(
-        "```\n{}{}```",
-        content,
-        if content.ends_with('\n') { "" } else { "\n" }
-    );
-
+    let content = content.trim();
+    let is_empty = content.is_empty();
+    let content = format!("```\n{content}\n```");
     let content = if should_truncate {
         format!(
-            "Command output too long. The first {} bytes:\n\n{}",
+            "Command output too long. The first {} bytes:\n\n{content}",
             content.len(),
-            content,
         )
     } else {
         content
@@ -367,42 +380,43 @@ fn process_content(
 fn working_dir(
     input: &TerminalToolInput,
     project: &Entity<Project>,
-    input_path: &Path,
     cx: &mut App,
 ) -> Result<Option<PathBuf>> {
     let project = project.read(cx);
+    let cd = &input.cd;
 
-    if input.cd == "." {
-        // Accept "." as meaning "the one worktree" if we only have one worktree.
+    if cd == "." || cd == "" {
+        // Accept "." or "" as meaning "the one worktree" if we only have one worktree.
         let mut worktrees = project.worktrees(cx);
 
         match worktrees.next() {
             Some(worktree) => {
-                if worktrees.next().is_some() {
-                    bail!(
-                        "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
-                    );
-                }
+                anyhow::ensure!(
+                    worktrees.next().is_none(),
+                    "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
+                );
                 Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
             }
             None => Ok(None),
         }
-    } else if input_path.is_absolute() {
-        // Absolute paths are allowed, but only if they're in one of the project's worktrees.
-        if !project
-            .worktrees(cx)
-            .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
-        {
-            bail!("The absolute path must be within one of the project's worktrees");
-        }
-
-        Ok(Some(input_path.into()))
     } else {
-        let Some(worktree) = project.worktree_for_root_name(&input.cd, cx) else {
-            bail!("`cd` directory {:?} not found in the project", input.cd);
-        };
+        let input_path = Path::new(cd);
+
+        if input_path.is_absolute() {
+            // Absolute paths are allowed, but only if they're in one of the project's worktrees.
+            if project
+                .worktrees(cx)
+                .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
+            {
+                return Ok(Some(input_path.into()));
+            }
+        } else {
+            if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
+                return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
+            }
+        }
 
-        Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
+        anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
     }
 }
 
@@ -469,7 +483,6 @@ impl ToolCard for TerminalToolCard {
         let time_elapsed = self
             .elapsed_time
             .unwrap_or_else(|| self.start_instant.elapsed());
-        let should_hide_terminal = tool_failed || self.finished_with_empty_output;
 
         let header_bg = cx
             .theme()
@@ -570,7 +583,7 @@ impl ToolCard for TerminalToolCard {
                         ),
                 )
             })
-            .when(!should_hide_terminal, |header| {
+            .when(!self.finished_with_empty_output, |header| {
                 header.child(
                     Disclosure::new(
                         ("terminal-tool-disclosure", self.entity_id),
@@ -614,19 +627,50 @@ impl ToolCard for TerminalToolCard {
                         ),
                     ),
             )
-            .when(self.preview_expanded && !should_hide_terminal, |this| {
-                this.child(
-                    div()
-                        .pt_2()
-                        .min_h_72()
-                        .border_t_1()
-                        .border_color(border_color)
-                        .bg(cx.theme().colors().editor_background)
-                        .rounded_b_md()
-                        .text_ui_sm(cx)
-                        .child(terminal.clone()),
-                )
-            })
+            .when(
+                self.preview_expanded && !self.finished_with_empty_output,
+                |this| {
+                    this.child(
+                        div()
+                            .pt_2()
+                            .border_t_1()
+                            .border_color(border_color)
+                            .bg(cx.theme().colors().editor_background)
+                            .rounded_b_md()
+                            .text_ui_sm(cx)
+                            .child({
+                                let content_mode = terminal.read(cx).content_mode(window, cx);
+
+                                if content_mode.is_scrollable() {
+                                    div().h_72().child(terminal.clone()).into_any_element()
+                                } else {
+                                    ToolOutputPreview::new(
+                                        terminal.clone().into_any_element(),
+                                        terminal.entity_id(),
+                                    )
+                                    .with_total_lines(self.content_line_count)
+                                    .toggle_state(!content_mode.is_limited())
+                                    .on_toggle({
+                                        let terminal = terminal.clone();
+                                        move |is_expanded, _, cx| {
+                                            terminal.update(cx, |terminal, cx| {
+                                                terminal.set_embedded_mode(
+                                                    if is_expanded {
+                                                        None
+                                                    } else {
+                                                        Some(COLLAPSED_LINES)
+                                                    },
+                                                    cx,
+                                                );
+                                            });
+                                        }
+                                    })
+                                    .into_any_element()
+                                }
+                            }),
+                    )
+                },
+            )
             .into_any()
     }
 }
@@ -647,7 +691,7 @@ fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
 
     MarkdownStyle {
         base_text_style: text_style.clone(),
-        selection_background_color: cx.theme().players().local().selection,
+        selection_background_color: cx.theme().colors().element_selection_background,
         ..Default::default()
     }
 }
@@ -668,8 +712,7 @@ mod tests {
     use super::*;
 
     fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
-        zlog::init();
-        zlog::init_output_stdout();
+        zlog::init_test();
 
         executor.allow_parking();
         cx.update(|cx| {
@@ -723,8 +766,8 @@ mod tests {
             )
         });
 
-        let output = result.output.await.log_err().map(|output| output.content);
-        assert_eq!(output, Some("Command executed successfully.".into()));
+        let output = result.output.await.log_err().unwrap().content;
+        assert_eq!(output.as_str().unwrap(), "Command executed successfully.");
     }
 
     #[gpui::test]
@@ -757,12 +800,13 @@ mod tests {
                 cx,
             );
             cx.spawn(async move |_| {
-                let output = headless_result
-                    .output
-                    .await
-                    .log_err()
-                    .map(|output| output.content);
-                assert_eq!(output, expected);
+                let output = headless_result.output.await.map(|output| output.content);
+                assert_eq!(
+                    output
+                        .ok()
+                        .and_then(|content| content.as_str().map(ToString::to_string)),
+                    expected
+                );
             })
         };
 
@@ -770,7 +814,7 @@ mod tests {
             check(
                 TerminalToolInput {
                     command: "pwd".into(),
-                    cd: "project".into(),
+                    cd: ".".into(),
                 },
                 Some(format!(
                     "```\n{}\n```",
@@ -785,12 +829,9 @@ mod tests {
             check(
                 TerminalToolInput {
                     command: "pwd".into(),
-                    cd: ".".into(),
+                    cd: "other-project".into(),
                 },
-                Some(format!(
-                    "```\n{}\n```",
-                    tree.path().join("project").display()
-                )),
+                None, // other-project is a dir, but *not* a worktree (yet)
                 cx,
             )
         })

crates/assistant_tools/src/thinking_tool.rs 🔗

@@ -28,6 +28,10 @@ impl Tool for ThinkingTool {
         false
     }
 
+    fn may_perform_edits(&self) -> bool {
+        false
+    }
+
     fn description(&self) -> String {
         include_str!("./thinking_tool/description.md").to_string()
     }

crates/assistant_tools/src/ui/tool_output_preview.rs 🔗

@@ -0,0 +1,115 @@
+use gpui::{AnyElement, EntityId, prelude::*};
+use ui::{Tooltip, prelude::*};
+
+#[derive(IntoElement)]
+pub struct ToolOutputPreview<F>
+where
+    F: Fn(bool, &mut Window, &mut App) + 'static,
+{
+    content: AnyElement,
+    entity_id: EntityId,
+    full_height: bool,
+    total_lines: usize,
+    collapsed_fade: bool,
+    on_toggle: Option<F>,
+}
+
+pub const COLLAPSED_LINES: usize = 10;
+
+impl<F> ToolOutputPreview<F>
+where
+    F: Fn(bool, &mut Window, &mut App) + 'static,
+{
+    pub fn new(content: AnyElement, entity_id: EntityId) -> Self {
+        Self {
+            content,
+            entity_id,
+            full_height: true,
+            total_lines: 0,
+            collapsed_fade: false,
+            on_toggle: None,
+        }
+    }
+
+    pub fn with_total_lines(mut self, total_lines: usize) -> Self {
+        self.total_lines = total_lines;
+        self
+    }
+
+    pub fn toggle_state(mut self, full_height: bool) -> Self {
+        self.full_height = full_height;
+        self
+    }
+
+    pub fn with_collapsed_fade(mut self) -> Self {
+        self.collapsed_fade = true;
+        self
+    }
+
+    pub fn on_toggle(mut self, listener: F) -> Self {
+        self.on_toggle = Some(listener);
+        self
+    }
+}
+
+impl<F> RenderOnce for ToolOutputPreview<F>
+where
+    F: Fn(bool, &mut Window, &mut App) + 'static,
+{
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        if self.total_lines <= COLLAPSED_LINES {
+            return self.content;
+        }
+        let border_color = cx.theme().colors().border.opacity(0.6);
+
+        let (icon, tooltip_label) = if self.full_height {
+            (IconName::ChevronUp, "Collapse")
+        } else {
+            (IconName::ChevronDown, "Expand")
+        };
+
+        let gradient_overlay =
+            if self.collapsed_fade && !self.full_height {
+                Some(div().absolute().bottom_5().left_0().w_full().h_2_5().bg(
+                    gpui::linear_gradient(
+                        0.,
+                        gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
+                        gpui::linear_color_stop(
+                            cx.theme().colors().editor_background.opacity(0.),
+                            1.,
+                        ),
+                    ),
+                ))
+            } else {
+                None
+            };
+
+        v_flex()
+            .relative()
+            .child(self.content)
+            .children(gradient_overlay)
+            .child(
+                h_flex()
+                    .id(("expand-button", self.entity_id))
+                    .flex_none()
+                    .cursor_pointer()
+                    .h_5()
+                    .justify_center()
+                    .border_t_1()
+                    .rounded_b_md()
+                    .border_color(border_color)
+                    .bg(cx.theme().colors().editor_background)
+                    .hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
+                    .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
+                    .tooltip(Tooltip::text(tooltip_label))
+                    .when_some(self.on_toggle, |this, on_toggle| {
+                        this.on_click({
+                            move |_, window, cx| {
+                                on_toggle(!self.full_height, window, cx);
+                            }
+                        })
+                    }),
+            )
+            .into_any()
+    }
+}

crates/assistant_tools/src/web_search_tool.rs 🔗

@@ -3,7 +3,9 @@ use std::{sync::Arc, time::Duration};
 use crate::schema::json_schema_for;
 use crate::ui::ToolCallCardHeader;
 use anyhow::{Context as _, Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
+use assistant_tool::{
+    ActionLog, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus,
+};
 use futures::{Future, FutureExt, TryFutureExt};
 use gpui::{
     AnyWindowHandle, App, AppContext, Context, Entity, IntoElement, Task, WeakEntity, Window,
@@ -34,6 +36,10 @@ impl Tool for WebSearchTool {
         false
     }
 
+    fn may_perform_edits(&self) -> bool {
+        false
+    }
+
     fn description(&self) -> String {
         "Search the web for information using your query. Use this when you need real-time information, facts, or data that might not be in your training. Results will include snippets and links from relevant web pages.".into()
     }
@@ -73,9 +79,13 @@ impl Tool for WebSearchTool {
             let search_task = search_task.clone();
             async move {
                 let response = search_task.await.map_err(|err| anyhow!(err))?;
-                serde_json::to_string(&response)
-                    .context("Failed to serialize search results")
-                    .map(Into::into)
+                Ok(ToolResultOutput {
+                    content: ToolResultContent::Text(
+                        serde_json::to_string(&response)
+                            .context("Failed to serialize search results")?,
+                    ),
+                    output: Some(serde_json::to_value(response)?),
+                })
             }
         });
 
@@ -84,6 +94,18 @@ impl Tool for WebSearchTool {
             card: Some(cx.new(|cx| WebSearchToolCard::new(search_task, cx)).into()),
         }
     }
+
+    fn deserialize_card(
+        self: Arc<Self>,
+        output: serde_json::Value,
+        _project: Entity<Project>,
+        _window: &mut Window,
+        cx: &mut App,
+    ) -> Option<assistant_tool::AnyToolCard> {
+        let output = serde_json::from_value::<WebSearchResponse>(output).ok()?;
+        let card = cx.new(|cx| WebSearchToolCard::new(Task::ready(Ok(output)), cx));
+        Some(card.into())
+    }
 }
 
 #[derive(RegisterComponent)]
@@ -148,7 +170,7 @@ impl ToolCard for WebSearchToolCard {
                     .gap_1()
                     .children(response.results.iter().enumerate().map(|(index, result)| {
                         let title = result.title.clone();
-                        let url = result.url.clone();
+                        let url = SharedString::from(result.url.clone());
 
                         Button::new(("result", index), title)
                             .label_size(LabelSize::Small)

crates/audio/src/assets.rs 🔗

@@ -1,6 +1,6 @@
 use std::{io::Cursor, sync::Arc};
 
-use anyhow::Result;
+use anyhow::{Context as _, Result};
 use collections::HashMap;
 use gpui::{App, AssetSource, Global};
 use rodio::{
@@ -44,8 +44,8 @@ impl SoundRegistry {
         let bytes = self
             .assets
             .load(&path)?
-            .map(Ok)
-            .unwrap_or_else(|| Err(anyhow::anyhow!("No such asset available")))?
+            .map(anyhow::Ok)
+            .with_context(|| format!("No asset available for path {path}"))??
             .into_owned();
         let cursor = Cursor::new(bytes);
         let source = Decoder::new(cursor)?.convert_samples::<f32>().buffered();

crates/audio/src/audio.rs 🔗

@@ -18,6 +18,7 @@ pub enum Sound {
     Unmute,
     StartScreenshare,
     StopScreenshare,
+    AgentDone,
 }
 
 impl Sound {
@@ -29,6 +30,7 @@ impl Sound {
             Self::Unmute => "unmute",
             Self::StartScreenshare => "start_screenshare",
             Self::StopScreenshare => "stop_screenshare",
+            Self::AgentDone => "agent_done",
         }
     }
 }

crates/auto_update/src/auto_update.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use client::{Client, TelemetrySettings};
 use db::RELEASE_CHANNEL;
 use db::kvp::KEY_VALUE_STORE;
@@ -39,13 +39,26 @@ struct UpdateRequestBody {
     destination: &'static str,
 }
 
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum VersionCheckType {
+    Sha(AppCommitSha),
+    Semantic(SemanticVersion),
+}
+
 #[derive(Clone, PartialEq, Eq)]
 pub enum AutoUpdateStatus {
     Idle,
     Checking,
-    Downloading,
-    Installing,
-    Updated { binary_path: PathBuf },
+    Downloading {
+        version: VersionCheckType,
+    },
+    Installing {
+        version: VersionCheckType,
+    },
+    Updated {
+        binary_path: PathBuf,
+        version: VersionCheckType,
+    },
     Errored,
 }
 
@@ -62,7 +75,7 @@ pub struct AutoUpdater {
     pending_poll: Option<Task<Option<()>>>,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Deserialize, Clone, Debug)]
 pub struct JsonRelease {
     pub version: String,
     pub url: String,
@@ -208,7 +221,7 @@ pub fn check(_: &Check, window: &mut Window, cx: &mut App) {
     }
 
     if let Some(updater) = AutoUpdater::get(cx) {
-        updater.update(cx, |updater, cx| updater.poll(cx));
+        updater.update(cx, |updater, cx| updater.poll(UpdateCheckType::Manual, cx));
     } else {
         drop(window.prompt(
             gpui::PromptLevel::Info,
@@ -283,6 +296,11 @@ impl InstallerDir {
     }
 }
 
+pub enum UpdateCheckType {
+    Automatic,
+    Manual,
+}
+
 impl AutoUpdater {
     pub fn get(cx: &mut App) -> Option<Entity<Self>> {
         cx.default_global::<GlobalAutoUpdate>().0.clone()
@@ -300,14 +318,14 @@ impl AutoUpdater {
     pub fn start_polling(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
         cx.spawn(async move |this, cx| {
             loop {
-                this.update(cx, |this, cx| this.poll(cx))?;
+                this.update(cx, |this, cx| this.poll(UpdateCheckType::Automatic, cx))?;
                 cx.background_executor().timer(POLL_INTERVAL).await;
             }
         })
     }
 
-    pub fn poll(&mut self, cx: &mut Context<Self>) {
-        if self.pending_poll.is_some() || self.status.is_updated() {
+    pub fn poll(&mut self, check_type: UpdateCheckType, cx: &mut Context<Self>) {
+        if self.pending_poll.is_some() {
             return;
         }
 
@@ -318,8 +336,18 @@ impl AutoUpdater {
             this.update(cx, |this, cx| {
                 this.pending_poll = None;
                 if let Err(error) = result {
-                    log::error!("auto-update failed: error:{:?}", error);
-                    this.status = AutoUpdateStatus::Errored;
+                    this.status = match check_type {
+                        // Be quiet if the check was automated (e.g. when offline)
+                        UpdateCheckType::Automatic => {
+                            log::info!("auto-update check failed: error:{:?}", error);
+                            AutoUpdateStatus::Idle
+                        }
+                        UpdateCheckType::Manual => {
+                            log::error!("auto-update failed: error:{:?}", error);
+                            AutoUpdateStatus::Errored
+                        }
+                    };
+
                     cx.notify();
                 }
             })
@@ -358,7 +386,7 @@ impl AutoUpdater {
             cx.default_global::<GlobalAutoUpdate>()
                 .0
                 .clone()
-                .ok_or_else(|| anyhow!("auto-update not initialized"))
+                .context("auto-update not initialized")
         })??;
 
         let release = Self::get_release(
@@ -402,7 +430,7 @@ impl AutoUpdater {
             cx.default_global::<GlobalAutoUpdate>()
                 .0
                 .clone()
-                .ok_or_else(|| anyhow!("auto-update not initialized"))
+                .context("auto-update not initialized")
         })??;
 
         let release = Self::get_release(
@@ -456,12 +484,11 @@ impl AutoUpdater {
             let mut body = Vec::new();
             response.body_mut().read_to_end(&mut body).await?;
 
-            if !response.status().is_success() {
-                return Err(anyhow!(
-                    "failed to fetch release: {:?}",
-                    String::from_utf8_lossy(&body),
-                ));
-            }
+            anyhow::ensure!(
+                response.status().is_success(),
+                "failed to fetch release: {:?}",
+                String::from_utf8_lossy(&body),
+            );
 
             serde_json::from_slice(body.as_slice()).with_context(|| {
                 format!(
@@ -484,47 +511,125 @@ impl AutoUpdater {
     }
 
     async fn update(this: Entity<Self>, mut cx: AsyncApp) -> Result<()> {
-        let (client, current_version, release_channel) = this.update(&mut cx, |this, cx| {
+        let (client, installed_version, previous_status, release_channel) =
+            this.read_with(&mut cx, |this, cx| {
+                (
+                    this.http_client.clone(),
+                    this.current_version,
+                    this.status.clone(),
+                    ReleaseChannel::try_global(cx),
+                )
+            })?;
+
+        this.update(&mut cx, |this, cx| {
             this.status = AutoUpdateStatus::Checking;
             cx.notify();
-            (
-                this.http_client.clone(),
-                this.current_version,
-                ReleaseChannel::try_global(cx),
-            )
         })?;
 
-        let release =
+        let fetched_release_data =
             Self::get_latest_release(&this, "zed", OS, ARCH, release_channel, &mut cx).await?;
-
-        let should_download = match *RELEASE_CHANNEL {
-            ReleaseChannel::Nightly => cx
-                .update(|cx| AppCommitSha::try_global(cx).map(|sha| release.version != sha.0))
-                .ok()
-                .flatten()
-                .unwrap_or(true),
-            _ => release.version.parse::<SemanticVersion>()? > current_version,
-        };
-
-        if !should_download {
-            this.update(&mut cx, |this, cx| {
-                this.status = AutoUpdateStatus::Idle;
+        let fetched_version = fetched_release_data.clone().version;
+        let app_commit_sha = cx.update(|cx| AppCommitSha::try_global(cx).map(|sha| sha.full()));
+        let newer_version = Self::check_if_fetched_version_is_newer(
+            *RELEASE_CHANNEL,
+            app_commit_sha,
+            installed_version,
+            fetched_version,
+            previous_status.clone(),
+        )?;
+
+        let Some(newer_version) = newer_version else {
+            return this.update(&mut cx, |this, cx| {
+                let status = match previous_status {
+                    AutoUpdateStatus::Updated { .. } => previous_status,
+                    _ => AutoUpdateStatus::Idle,
+                };
+                this.status = status;
                 cx.notify();
-            })?;
-            return Ok(());
-        }
+            });
+        };
 
         this.update(&mut cx, |this, cx| {
-            this.status = AutoUpdateStatus::Downloading;
+            this.status = AutoUpdateStatus::Downloading {
+                version: newer_version.clone(),
+            };
             cx.notify();
         })?;
 
         let installer_dir = InstallerDir::new().await?;
+        let target_path = Self::target_path(&installer_dir).await?;
+        download_release(&target_path, fetched_release_data, client, &cx).await?;
+
+        this.update(&mut cx, |this, cx| {
+            this.status = AutoUpdateStatus::Installing {
+                version: newer_version.clone(),
+            };
+            cx.notify();
+        })?;
+
+        let binary_path = Self::binary_path(installer_dir, target_path, &cx).await?;
+
+        this.update(&mut cx, |this, cx| {
+            this.set_should_show_update_notification(true, cx)
+                .detach_and_log_err(cx);
+            this.status = AutoUpdateStatus::Updated {
+                binary_path,
+                version: newer_version,
+            };
+            cx.notify();
+        })
+    }
+
+    fn check_if_fetched_version_is_newer(
+        release_channel: ReleaseChannel,
+        app_commit_sha: Result<Option<String>>,
+        installed_version: SemanticVersion,
+        fetched_version: String,
+        status: AutoUpdateStatus,
+    ) -> Result<Option<VersionCheckType>> {
+        let parsed_fetched_version = fetched_version.parse::<SemanticVersion>();
+
+        if let AutoUpdateStatus::Updated { version, .. } = status {
+            match version {
+                VersionCheckType::Sha(cached_version) => {
+                    let should_download = fetched_version != cached_version.full();
+                    let newer_version = should_download
+                        .then(|| VersionCheckType::Sha(AppCommitSha::new(fetched_version)));
+                    return Ok(newer_version);
+                }
+                VersionCheckType::Semantic(cached_version) => {
+                    return Self::check_if_fetched_version_is_newer_non_nightly(
+                        cached_version,
+                        parsed_fetched_version?,
+                    );
+                }
+            }
+        }
+
+        match release_channel {
+            ReleaseChannel::Nightly => {
+                let should_download = app_commit_sha
+                    .ok()
+                    .flatten()
+                    .map(|sha| fetched_version != sha)
+                    .unwrap_or(true);
+                let newer_version = should_download
+                    .then(|| VersionCheckType::Sha(AppCommitSha::new(fetched_version)));
+                Ok(newer_version)
+            }
+            _ => Self::check_if_fetched_version_is_newer_non_nightly(
+                installed_version,
+                parsed_fetched_version?,
+            ),
+        }
+    }
+
+    async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf> {
         let filename = match OS {
-            "macos" => Ok("Zed.dmg"),
+            "macos" => anyhow::Ok("Zed.dmg"),
             "linux" => Ok("zed.tar.gz"),
             "windows" => Ok("ZedUpdateInstaller.exe"),
-            _ => Err(anyhow!("not supported: {:?}", OS)),
+            unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
         }?;
 
         #[cfg(not(target_os = "windows"))]
@@ -533,29 +638,29 @@ impl AutoUpdater {
             "Aborting. Could not find rsync which is required for auto-updates."
         );
 
-        let downloaded_asset = installer_dir.path().join(filename);
-        download_release(&downloaded_asset, release, client, &cx).await?;
-
-        this.update(&mut cx, |this, cx| {
-            this.status = AutoUpdateStatus::Installing;
-            cx.notify();
-        })?;
-
-        let binary_path = match OS {
-            "macos" => install_release_macos(&installer_dir, downloaded_asset, &cx).await,
-            "linux" => install_release_linux(&installer_dir, downloaded_asset, &cx).await,
-            "windows" => install_release_windows(downloaded_asset).await,
-            _ => Err(anyhow!("not supported: {:?}", OS)),
-        }?;
+        Ok(installer_dir.path().join(filename))
+    }
 
-        this.update(&mut cx, |this, cx| {
-            this.set_should_show_update_notification(true, cx)
-                .detach_and_log_err(cx);
-            this.status = AutoUpdateStatus::Updated { binary_path };
-            cx.notify();
-        })?;
+    async fn binary_path(
+        installer_dir: InstallerDir,
+        target_path: PathBuf,
+        cx: &AsyncApp,
+    ) -> Result<PathBuf> {
+        match OS {
+            "macos" => install_release_macos(&installer_dir, target_path, cx).await,
+            "linux" => install_release_linux(&installer_dir, target_path, cx).await,
+            "windows" => install_release_windows(target_path).await,
+            unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
+        }
+    }
 
-        Ok(())
+    fn check_if_fetched_version_is_newer_non_nightly(
+        installed_version: SemanticVersion,
+        fetched_version: SemanticVersion,
+    ) -> Result<Option<VersionCheckType>> {
+        let should_download = fetched_version > installed_version;
+        let newer_version = should_download.then(|| VersionCheckType::Semantic(fetched_version));
+        Ok(newer_version)
     }
 
     pub fn set_should_show_update_notification(
@@ -601,12 +706,11 @@ async fn download_remote_server_binary(
     let request_body = AsyncBody::from(serde_json::to_string(&update_request_body)?);
 
     let mut response = client.get(&release.url, request_body, true).await?;
-    if !response.status().is_success() {
-        return Err(anyhow!(
-            "failed to download remote server release: {:?}",
-            response.status()
-        ));
-    }
+    anyhow::ensure!(
+        response.status().is_success(),
+        "failed to download remote server release: {:?}",
+        response.status()
+    );
     smol::io::copy(response.body_mut(), &mut temp_file).await?;
     smol::fs::rename(&temp, &target_path).await?;
 
@@ -753,7 +857,7 @@ async fn install_release_macos(
     let running_app_path = cx.update(|cx| cx.app_path())??;
     let running_app_filename = running_app_path
         .file_name()
-        .ok_or_else(|| anyhow!("invalid running app path"))?;
+        .with_context(|| format!("invalid running app path {running_app_path:?}"))?;
 
     let mount_path = temp_dir.path().join("Zed");
     let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
@@ -831,3 +935,255 @@ pub fn check_pending_installation() -> bool {
     }
     false
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_stable_does_not_update_when_fetched_version_is_not_higher() {
+        let release_channel = ReleaseChannel::Stable;
+        let app_commit_sha = Ok(Some("a".to_string()));
+        let installed_version = SemanticVersion::new(1, 0, 0);
+        let status = AutoUpdateStatus::Idle;
+        let fetched_version = SemanticVersion::new(1, 0, 0);
+
+        let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
+            release_channel,
+            app_commit_sha,
+            installed_version,
+            fetched_version.to_string(),
+            status,
+        );
+
+        assert_eq!(newer_version.unwrap(), None);
+    }
+
+    #[test]
+    fn test_stable_does_update_when_fetched_version_is_higher() {
+        let release_channel = ReleaseChannel::Stable;
+        let app_commit_sha = Ok(Some("a".to_string()));
+        let installed_version = SemanticVersion::new(1, 0, 0);
+        let status = AutoUpdateStatus::Idle;
+        let fetched_version = SemanticVersion::new(1, 0, 1);
+
+        let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
+            release_channel,
+            app_commit_sha,
+            installed_version,
+            fetched_version.to_string(),
+            status,
+        );
+
+        assert_eq!(
+            newer_version.unwrap(),
+            Some(VersionCheckType::Semantic(fetched_version))
+        );
+    }
+
+    #[test]
+    fn test_stable_does_not_update_when_fetched_version_is_not_higher_than_cached() {
+        let release_channel = ReleaseChannel::Stable;
+        let app_commit_sha = Ok(Some("a".to_string()));
+        let installed_version = SemanticVersion::new(1, 0, 0);
+        let status = AutoUpdateStatus::Updated {
+            binary_path: PathBuf::new(),
+            version: VersionCheckType::Semantic(SemanticVersion::new(1, 0, 1)),
+        };
+        let fetched_version = SemanticVersion::new(1, 0, 1);
+
+        let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
+            release_channel,
+            app_commit_sha,
+            installed_version,
+            fetched_version.to_string(),
+            status,
+        );
+
+        assert_eq!(newer_version.unwrap(), None);
+    }
+
+    #[test]
+    fn test_stable_does_update_when_fetched_version_is_higher_than_cached() {
+        let release_channel = ReleaseChannel::Stable;
+        let app_commit_sha = Ok(Some("a".to_string()));
+        let installed_version = SemanticVersion::new(1, 0, 0);
+        let status = AutoUpdateStatus::Updated {
+            binary_path: PathBuf::new(),
+            version: VersionCheckType::Semantic(SemanticVersion::new(1, 0, 1)),
+        };
+        let fetched_version = SemanticVersion::new(1, 0, 2);
+
+        let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
+            release_channel,
+            app_commit_sha,
+            installed_version,
+            fetched_version.to_string(),
+            status,
+        );
+
+        assert_eq!(
+            newer_version.unwrap(),
+            Some(VersionCheckType::Semantic(fetched_version))
+        );
+    }
+
+    #[test]
+    fn test_nightly_does_not_update_when_fetched_sha_is_same() {
+        let release_channel = ReleaseChannel::Nightly;
+        let app_commit_sha = Ok(Some("a".to_string()));
+        let installed_version = SemanticVersion::new(1, 0, 0);
+        let status = AutoUpdateStatus::Idle;
+        let fetched_sha = "a".to_string();
+
+        let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
+            release_channel,
+            app_commit_sha,
+            installed_version,
+            fetched_sha,
+            status,
+        );
+
+        assert_eq!(newer_version.unwrap(), None);
+    }
+
+    #[test]
+    fn test_nightly_does_update_when_fetched_sha_is_not_same() {
+        let release_channel = ReleaseChannel::Nightly;
+        let app_commit_sha = Ok(Some("a".to_string()));
+        let installed_version = SemanticVersion::new(1, 0, 0);
+        let status = AutoUpdateStatus::Idle;
+        let fetched_sha = "b".to_string();
+
+        let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
+            release_channel,
+            app_commit_sha,
+            installed_version,
+            fetched_sha.clone(),
+            status,
+        );
+
+        assert_eq!(
+            newer_version.unwrap(),
+            Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha)))
+        );
+    }
+
+    #[test]
+    fn test_nightly_does_not_update_when_fetched_sha_is_same_as_cached() {
+        let release_channel = ReleaseChannel::Nightly;
+        let app_commit_sha = Ok(Some("a".to_string()));
+        let installed_version = SemanticVersion::new(1, 0, 0);
+        let status = AutoUpdateStatus::Updated {
+            binary_path: PathBuf::new(),
+            version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())),
+        };
+        let fetched_sha = "b".to_string();
+
+        let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
+            release_channel,
+            app_commit_sha,
+            installed_version,
+            fetched_sha,
+            status,
+        );
+
+        assert_eq!(newer_version.unwrap(), None);
+    }
+
+    #[test]
+    fn test_nightly_does_update_when_fetched_sha_is_not_same_as_cached() {
+        let release_channel = ReleaseChannel::Nightly;
+        let app_commit_sha = Ok(Some("a".to_string()));
+        let installed_version = SemanticVersion::new(1, 0, 0);
+        let status = AutoUpdateStatus::Updated {
+            binary_path: PathBuf::new(),
+            version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())),
+        };
+        let fetched_sha = "c".to_string();
+
+        let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
+            release_channel,
+            app_commit_sha,
+            installed_version,
+            fetched_sha.clone(),
+            status,
+        );
+
+        assert_eq!(
+            newer_version.unwrap(),
+            Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha)))
+        );
+    }
+
+    #[test]
+    fn test_nightly_does_update_when_installed_versions_sha_cannot_be_retrieved() {
+        let release_channel = ReleaseChannel::Nightly;
+        let app_commit_sha = Ok(None);
+        let installed_version = SemanticVersion::new(1, 0, 0);
+        let status = AutoUpdateStatus::Idle;
+        let fetched_sha = "a".to_string();
+
+        let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
+            release_channel,
+            app_commit_sha,
+            installed_version,
+            fetched_sha.clone(),
+            status,
+        );
+
+        assert_eq!(
+            newer_version.unwrap(),
+            Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha)))
+        );
+    }
+
+    #[test]
+    fn test_nightly_does_not_update_when_cached_update_is_same_as_fetched_and_installed_versions_sha_cannot_be_retrieved()
+     {
+        let release_channel = ReleaseChannel::Nightly;
+        let app_commit_sha = Ok(None);
+        let installed_version = SemanticVersion::new(1, 0, 0);
+        let status = AutoUpdateStatus::Updated {
+            binary_path: PathBuf::new(),
+            version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())),
+        };
+        let fetched_sha = "b".to_string();
+
+        let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
+            release_channel,
+            app_commit_sha,
+            installed_version,
+            fetched_sha,
+            status,
+        );
+
+        assert_eq!(newer_version.unwrap(), None);
+    }
+
+    #[test]
+    fn test_nightly_does_update_when_cached_update_is_not_same_as_fetched_and_installed_versions_sha_cannot_be_retrieved()
+     {
+        let release_channel = ReleaseChannel::Nightly;
+        let app_commit_sha = Ok(None);
+        let installed_version = SemanticVersion::new(1, 0, 0);
+        let status = AutoUpdateStatus::Updated {
+            binary_path: PathBuf::new(),
+            version: VersionCheckType::Sha(AppCommitSha::new("b".to_string())),
+        };
+        let fetched_sha = "c".to_string();
+
+        let newer_version = AutoUpdater::check_if_fetched_version_is_newer(
+            release_channel,
+            app_commit_sha,
+            installed_version,
+            fetched_sha.clone(),
+            status,
+        );
+
+        assert_eq!(
+            newer_version.unwrap(),
+            Some(VersionCheckType::Sha(AppCommitSha::new(fetched_sha)))
+        );
+    }
+}

crates/auto_update_helper/src/auto_update_helper.rs 🔗

@@ -22,7 +22,7 @@ mod windows_impl {
 
     use super::dialog::create_dialog_window;
     use super::updater::perform_update;
-    use anyhow::{Context, Result};
+    use anyhow::{Context as _, Result};
     use windows::{
         Win32::{
             Foundation::{HWND, LPARAM, WPARAM},

crates/auto_update_helper/src/updater.rs 🔗

@@ -4,7 +4,7 @@ use std::{
     time::{Duration, Instant},
 };
 
-use anyhow::{Context, Result};
+use anyhow::{Context as _, Result};
 use windows::Win32::{
     Foundation::{HWND, LPARAM, WPARAM},
     System::Threading::CREATE_NEW_PROCESS_GROUP,
@@ -124,9 +124,7 @@ pub(crate) fn perform_update(app_dir: &Path, hwnd: Option<isize>) -> Result<()>
     for job in JOBS.iter() {
         let start = Instant::now();
         loop {
-            if start.elapsed().as_secs() > 2 {
-                return Err(anyhow::anyhow!("Timed out"));
-            }
+            anyhow::ensure!(start.elapsed().as_secs() <= 2, "Timed out");
             match (*job)(app_dir) {
                 Ok(_) => {
                     unsafe { PostMessageW(hwnd, WM_JOB_UPDATED, WPARAM(0), LPARAM(0))? };

crates/auto_update_ui/src/auto_update_ui.rs 🔗

@@ -1,7 +1,7 @@
 use auto_update::AutoUpdater;
 use client::proto::UpdateNotification;
 use editor::{Editor, MultiBuffer};
-use gpui::{App, Context, DismissEvent, Entity, SharedString, Window, actions, prelude::*};
+use gpui::{App, Context, DismissEvent, Entity, Window, actions, prelude::*};
 use http_client::HttpClient;
 use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView};
 use release_channel::{AppVersion, ReleaseChannel};
@@ -82,7 +82,10 @@ fn view_release_notes_locally(
                         .update_in(cx, |workspace, window, cx| {
                             let project = workspace.project().clone();
                             let buffer = project.update(cx, |project, cx| {
-                                project.create_local_buffer("", markdown, cx)
+                                let buffer = project.create_local_buffer("", markdown, cx);
+                                project
+                                    .mark_buffer_as_non_searchable(buffer.read(cx).remote_id(), cx);
+                                buffer
                             });
                             buffer.update(cx, |buffer, cx| {
                                 buffer.edit([(0..0, body.release_notes)], None, cx)
@@ -91,7 +94,6 @@ fn view_release_notes_locally(
 
                             let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
 
-                            let tab_content = SharedString::from(body.title.to_string());
                             let editor = cx.new(|cx| {
                                 Editor::for_multibuffer(buffer, Some(project), window, cx)
                             });
@@ -102,7 +104,6 @@ fn view_release_notes_locally(
                                     editor,
                                     workspace_handle,
                                     language_registry,
-                                    tab_content,
                                     window,
                                     cx,
                                 );
@@ -129,6 +130,11 @@ pub fn notify_if_app_was_updated(cx: &mut App) {
     let Some(updater) = AutoUpdater::get(cx) else {
         return;
     };
+
+    if let ReleaseChannel::Nightly = ReleaseChannel::global(cx) {
+        return;
+    }
+
     let should_show_notification = updater.read(cx).should_show_update_notification(cx);
     cx.spawn(async move |cx| {
         let should_show_notification = should_show_notification.await?;

crates/bedrock/src/bedrock.rs 🔗

@@ -3,7 +3,7 @@ mod models;
 use std::collections::HashMap;
 use std::pin::Pin;
 
-use anyhow::{Error, Result, anyhow};
+use anyhow::{Context as _, Error, Result, anyhow};
 use aws_sdk_bedrockruntime as bedrock;
 pub use aws_sdk_bedrockruntime as bedrock_client;
 pub use aws_sdk_bedrockruntime::types::{
@@ -97,7 +97,7 @@ pub async fn stream_completion(
             }
         })
         .await
-        .map_err(|err| anyhow!("failed to spawn task: {err:?}"))?
+        .context("spawning a task")?
 }
 
 pub fn aws_document_to_value(document: &Document) -> Value {
@@ -152,7 +152,7 @@ pub enum Thinking {
 #[derive(Debug)]
 pub struct Request {
     pub model: String,
-    pub max_tokens: u32,
+    pub max_tokens: u64,
     pub messages: Vec<BedrockMessage>,
     pub tools: Option<BedrockToolConfig>,
     pub thinking: Option<Thinking>,

crates/bedrock/src/models.rs 🔗

@@ -1,4 +1,3 @@
-use anyhow::anyhow;
 use serde::{Deserialize, Serialize};
 use strum::EnumIter;
 
@@ -12,11 +11,32 @@ pub enum BedrockModelMode {
     },
 }
 
+#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
+pub struct BedrockModelCacheConfiguration {
+    pub max_cache_anchors: usize,
+    pub min_total_token: u64,
+}
+
 #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
 #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)]
 pub enum Model {
     // Anthropic models (already included)
     #[default]
+    #[serde(rename = "claude-sonnet-4", alias = "claude-sonnet-4-latest")]
+    ClaudeSonnet4,
+    #[serde(
+        rename = "claude-sonnet-4-thinking",
+        alias = "claude-sonnet-4-thinking-latest"
+    )]
+    ClaudeSonnet4Thinking,
+    #[serde(rename = "claude-opus-4", alias = "claude-opus-4-latest")]
+    ClaudeOpus4,
+    #[serde(
+        rename = "claude-opus-4-thinking",
+        alias = "claude-opus-4-thinking-latest"
+    )]
+    ClaudeOpus4Thinking,
     #[serde(rename = "claude-3-5-sonnet-v2", alias = "claude-3-5-sonnet-latest")]
     Claude3_5SonnetV2,
     #[serde(rename = "claude-3-7-sonnet", alias = "claude-3-7-sonnet-latest")]
@@ -38,6 +58,7 @@ pub enum Model {
     AmazonNovaLite,
     AmazonNovaMicro,
     AmazonNovaPro,
+    AmazonNovaPremier,
     // AI21 models
     AI21J2GrandeInstruct,
     AI21J2JumboInstruct,
@@ -63,29 +84,44 @@ pub enum Model {
     MetaLlama318BInstructV1,
     MetaLlama3170BInstructV1_128k,
     MetaLlama3170BInstructV1,
-    MetaLlama3211BInstructV1,
-    MetaLlama3290BInstructV1,
+    MetaLlama31405BInstructV1,
     MetaLlama321BInstructV1,
     MetaLlama323BInstructV1,
+    MetaLlama3211BInstructV1,
+    MetaLlama3290BInstructV1,
+    MetaLlama3370BInstructV1,
+    #[allow(non_camel_case_types)]
+    MetaLlama4Scout17BInstructV1,
+    #[allow(non_camel_case_types)]
+    MetaLlama4Maverick17BInstructV1,
     // Mistral models
     MistralMistral7BInstructV0,
     MistralMixtral8x7BInstructV0,
     MistralMistralLarge2402V1,
     MistralMistralSmall2402V1,
+    MistralPixtralLarge2502V1,
+    // Writer models
+    PalmyraWriterX5,
+    PalmyraWriterX4,
     #[serde(rename = "custom")]
     Custom {
         name: String,
-        max_tokens: usize,
+        max_tokens: u64,
         /// The name displayed in the UI, such as in the assistant panel model dropdown menu.
         display_name: Option<String>,
-        max_output_tokens: Option<u32>,
+        max_output_tokens: Option<u64>,
         default_temperature: Option<f32>,
+        cache_configuration: Option<BedrockModelCacheConfiguration>,
     },
 }
 
 impl Model {
-    pub fn default_fast() -> Self {
-        Self::Claude3_5Haiku
+    pub fn default_fast(region: &str) -> Self {
+        if region.starts_with("us-") {
+            Self::Claude3_5Haiku
+        } else {
+            Self::Claude3Haiku
+        }
     }
 
     pub fn from_id(id: &str) -> anyhow::Result<Self> {
@@ -102,12 +138,76 @@ impl Model {
         } else if id.starts_with("claude-3-7-sonnet-thinking") {
             Ok(Self::Claude3_7SonnetThinking)
         } else {
-            Err(anyhow!("invalid model id"))
+            anyhow::bail!("invalid model id {id}");
         }
     }
 
     pub fn id(&self) -> &str {
         match self {
+            Model::ClaudeSonnet4 => "claude-4-sonnet",
+            Model::ClaudeSonnet4Thinking => "claude-4-sonnet-thinking",
+            Model::ClaudeOpus4 => "claude-4-opus",
+            Model::ClaudeOpus4Thinking => "claude-4-opus-thinking",
+            Model::Claude3_5SonnetV2 => "claude-3-5-sonnet-v2",
+            Model::Claude3_5Sonnet => "claude-3-5-sonnet",
+            Model::Claude3Opus => "claude-3-opus",
+            Model::Claude3Sonnet => "claude-3-sonnet",
+            Model::Claude3Haiku => "claude-3-haiku",
+            Model::Claude3_5Haiku => "claude-3-5-haiku",
+            Model::Claude3_7Sonnet => "claude-3-7-sonnet",
+            Model::Claude3_7SonnetThinking => "claude-3-7-sonnet-thinking",
+            Model::AmazonNovaLite => "amazon-nova-lite",
+            Model::AmazonNovaMicro => "amazon-nova-micro",
+            Model::AmazonNovaPro => "amazon-nova-pro",
+            Model::AmazonNovaPremier => "amazon-nova-premier",
+            Model::DeepSeekR1 => "deepseek-r1",
+            Model::AI21J2GrandeInstruct => "ai21-j2-grande-instruct",
+            Model::AI21J2JumboInstruct => "ai21-j2-jumbo-instruct",
+            Model::AI21J2Mid => "ai21-j2-mid",
+            Model::AI21J2MidV1 => "ai21-j2-mid-v1",
+            Model::AI21J2Ultra => "ai21-j2-ultra",
+            Model::AI21J2UltraV1_8k => "ai21-j2-ultra-v1-8k",
+            Model::AI21J2UltraV1 => "ai21-j2-ultra-v1",
+            Model::AI21JambaInstructV1 => "ai21-jamba-instruct-v1",
+            Model::AI21Jamba15LargeV1 => "ai21-jamba-1-5-large-v1",
+            Model::AI21Jamba15MiniV1 => "ai21-jamba-1-5-mini-v1",
+            Model::CohereCommandTextV14_4k => "cohere-command-text-v14-4k",
+            Model::CohereCommandRV1 => "cohere-command-r-v1",
+            Model::CohereCommandRPlusV1 => "cohere-command-r-plus-v1",
+            Model::CohereCommandLightTextV14_4k => "cohere-command-light-text-v14-4k",
+            Model::MetaLlama38BInstructV1 => "meta-llama3-8b-instruct-v1",
+            Model::MetaLlama370BInstructV1 => "meta-llama3-70b-instruct-v1",
+            Model::MetaLlama318BInstructV1_128k => "meta-llama3-1-8b-instruct-v1-128k",
+            Model::MetaLlama318BInstructV1 => "meta-llama3-1-8b-instruct-v1",
+            Model::MetaLlama3170BInstructV1_128k => "meta-llama3-1-70b-instruct-v1-128k",
+            Model::MetaLlama3170BInstructV1 => "meta-llama3-1-70b-instruct-v1",
+            Model::MetaLlama31405BInstructV1 => "meta-llama3-1-405b-instruct-v1",
+            Model::MetaLlama321BInstructV1 => "meta-llama3-2-1b-instruct-v1",
+            Model::MetaLlama323BInstructV1 => "meta-llama3-2-3b-instruct-v1",
+            Model::MetaLlama3211BInstructV1 => "meta-llama3-2-11b-instruct-v1",
+            Model::MetaLlama3290BInstructV1 => "meta-llama3-2-90b-instruct-v1",
+            Model::MetaLlama3370BInstructV1 => "meta-llama3-3-70b-instruct-v1",
+            Model::MetaLlama4Scout17BInstructV1 => "meta-llama4-scout-17b-instruct-v1",
+            Model::MetaLlama4Maverick17BInstructV1 => "meta-llama4-maverick-17b-instruct-v1",
+            Model::MistralMistral7BInstructV0 => "mistral-7b-instruct-v0",
+            Model::MistralMixtral8x7BInstructV0 => "mistral-mixtral-8x7b-instruct-v0",
+            Model::MistralMistralLarge2402V1 => "mistral-large-2402-v1",
+            Model::MistralMistralSmall2402V1 => "mistral-small-2402-v1",
+            Model::MistralPixtralLarge2502V1 => "mistral-pixtral-large-2502-v1",
+            Model::PalmyraWriterX4 => "palmyra-writer-x4",
+            Model::PalmyraWriterX5 => "palmyra-writer-x5",
+            Self::Custom { name, .. } => name,
+        }
+    }
+
+    pub fn request_id(&self) -> &str {
+        match self {
+            Model::ClaudeSonnet4 | Model::ClaudeSonnet4Thinking => {
+                "anthropic.claude-sonnet-4-20250514-v1:0"
+            }
+            Model::ClaudeOpus4 | Model::ClaudeOpus4Thinking => {
+                "anthropic.claude-opus-4-20250514-v1:0"
+            }
             Model::Claude3_5SonnetV2 => "anthropic.claude-3-5-sonnet-20241022-v2:0",
             Model::Claude3_5Sonnet => "anthropic.claude-3-5-sonnet-20240620-v1:0",
             Model::Claude3Opus => "anthropic.claude-3-opus-20240229-v1:0",
@@ -120,7 +220,8 @@ impl Model {
             Model::AmazonNovaLite => "amazon.nova-lite-v1:0",
             Model::AmazonNovaMicro => "amazon.nova-micro-v1:0",
             Model::AmazonNovaPro => "amazon.nova-pro-v1:0",
-            Model::DeepSeekR1 => "us.deepseek.r1-v1:0",
+            Model::AmazonNovaPremier => "amazon.nova-premier-v1:0",
+            Model::DeepSeekR1 => "deepseek.r1-v1:0",
             Model::AI21J2GrandeInstruct => "ai21.j2-grande-instruct",
             Model::AI21J2JumboInstruct => "ai21.j2-jumbo-instruct",
             Model::AI21J2Mid => "ai21.j2-mid",
@@ -137,24 +238,35 @@ impl Model {
             Model::CohereCommandLightTextV14_4k => "cohere.command-light-text-v14:7:4k",
             Model::MetaLlama38BInstructV1 => "meta.llama3-8b-instruct-v1:0",
             Model::MetaLlama370BInstructV1 => "meta.llama3-70b-instruct-v1:0",
-            Model::MetaLlama318BInstructV1_128k => "meta.llama3-1-8b-instruct-v1:0:128k",
+            Model::MetaLlama318BInstructV1_128k => "meta.llama3-1-8b-instruct-v1:0",
             Model::MetaLlama318BInstructV1 => "meta.llama3-1-8b-instruct-v1:0",
-            Model::MetaLlama3170BInstructV1_128k => "meta.llama3-1-70b-instruct-v1:0:128k",
+            Model::MetaLlama3170BInstructV1_128k => "meta.llama3-1-70b-instruct-v1:0",
             Model::MetaLlama3170BInstructV1 => "meta.llama3-1-70b-instruct-v1:0",
+            Model::MetaLlama31405BInstructV1 => "meta.llama3-1-405b-instruct-v1:0",
             Model::MetaLlama3211BInstructV1 => "meta.llama3-2-11b-instruct-v1:0",
             Model::MetaLlama3290BInstructV1 => "meta.llama3-2-90b-instruct-v1:0",
             Model::MetaLlama321BInstructV1 => "meta.llama3-2-1b-instruct-v1:0",
             Model::MetaLlama323BInstructV1 => "meta.llama3-2-3b-instruct-v1:0",
+            Model::MetaLlama3370BInstructV1 => "meta.llama3-3-70b-instruct-v1:0",
+            Model::MetaLlama4Scout17BInstructV1 => "meta.llama4-scout-17b-instruct-v1:0",
+            Model::MetaLlama4Maverick17BInstructV1 => "meta.llama4-maverick-17b-instruct-v1:0",
             Model::MistralMistral7BInstructV0 => "mistral.mistral-7b-instruct-v0:2",
             Model::MistralMixtral8x7BInstructV0 => "mistral.mixtral-8x7b-instruct-v0:1",
             Model::MistralMistralLarge2402V1 => "mistral.mistral-large-2402-v1:0",
             Model::MistralMistralSmall2402V1 => "mistral.mistral-small-2402-v1:0",
+            Model::MistralPixtralLarge2502V1 => "mistral.pixtral-large-2502-v1:0",
+            Model::PalmyraWriterX4 => "writer.palmyra-x4-v1:0",
+            Model::PalmyraWriterX5 => "writer.palmyra-x5-v1:0",
             Self::Custom { name, .. } => name,
         }
     }
 
     pub fn display_name(&self) -> &str {
         match self {
+            Self::ClaudeSonnet4 => "Claude Sonnet 4",
+            Self::ClaudeSonnet4Thinking => "Claude Sonnet 4 Thinking",
+            Self::ClaudeOpus4 => "Claude Opus 4",
+            Self::ClaudeOpus4Thinking => "Claude Opus 4 Thinking",
             Self::Claude3_5SonnetV2 => "Claude 3.5 Sonnet v2",
             Self::Claude3_5Sonnet => "Claude 3.5 Sonnet",
             Self::Claude3Opus => "Claude 3 Opus",
@@ -166,6 +278,7 @@ impl Model {
             Self::AmazonNovaLite => "Amazon Nova Lite",
             Self::AmazonNovaMicro => "Amazon Nova Micro",
             Self::AmazonNovaPro => "Amazon Nova Pro",
+            Self::AmazonNovaPremier => "Amazon Nova Premier",
             Self::DeepSeekR1 => "DeepSeek R1",
             Self::AI21J2GrandeInstruct => "AI21 Jurassic2 Grande Instruct",
             Self::AI21J2JumboInstruct => "AI21 Jurassic2 Jumbo Instruct",
@@ -181,43 +294,62 @@ impl Model {
             Self::CohereCommandRV1 => "Cohere Command R V1",
             Self::CohereCommandRPlusV1 => "Cohere Command R Plus V1",
             Self::CohereCommandLightTextV14_4k => "Cohere Command Light Text V14 4K",
-            Self::MetaLlama38BInstructV1 => "Meta Llama 3 8B Instruct V1",
-            Self::MetaLlama370BInstructV1 => "Meta Llama 3 70B Instruct V1",
-            Self::MetaLlama318BInstructV1_128k => "Meta Llama 3 1.8B Instruct V1 128K",
-            Self::MetaLlama318BInstructV1 => "Meta Llama 3 1.8B Instruct V1",
-            Self::MetaLlama3170BInstructV1_128k => "Meta Llama 3 1 70B Instruct V1 128K",
-            Self::MetaLlama3170BInstructV1 => "Meta Llama 3 1 70B Instruct V1",
-            Self::MetaLlama3211BInstructV1 => "Meta Llama 3 2 11B Instruct V1",
-            Self::MetaLlama3290BInstructV1 => "Meta Llama 3 2 90B Instruct V1",
-            Self::MetaLlama321BInstructV1 => "Meta Llama 3 2 1B Instruct V1",
-            Self::MetaLlama323BInstructV1 => "Meta Llama 3 2 3B Instruct V1",
+            Self::MetaLlama38BInstructV1 => "Meta Llama 3 8B Instruct",
+            Self::MetaLlama370BInstructV1 => "Meta Llama 3 70B Instruct",
+            Self::MetaLlama318BInstructV1_128k => "Meta Llama 3.1 8B Instruct 128K",
+            Self::MetaLlama318BInstructV1 => "Meta Llama 3.1 8B Instruct",
+            Self::MetaLlama3170BInstructV1_128k => "Meta Llama 3.1 70B Instruct 128K",
+            Self::MetaLlama3170BInstructV1 => "Meta Llama 3.1 70B Instruct",
+            Self::MetaLlama31405BInstructV1 => "Meta Llama 3.1 405B Instruct",
+            Self::MetaLlama3211BInstructV1 => "Meta Llama 3.2 11B Instruct",
+            Self::MetaLlama3290BInstructV1 => "Meta Llama 3.2 90B Instruct",
+            Self::MetaLlama321BInstructV1 => "Meta Llama 3.2 1B Instruct",
+            Self::MetaLlama323BInstructV1 => "Meta Llama 3.2 3B Instruct",
+            Self::MetaLlama3370BInstructV1 => "Meta Llama 3.3 70B Instruct",
+            Self::MetaLlama4Scout17BInstructV1 => "Meta Llama 4 Scout 17B Instruct",
+            Self::MetaLlama4Maverick17BInstructV1 => "Meta Llama 4 Maverick 17B Instruct",
             Self::MistralMistral7BInstructV0 => "Mistral 7B Instruct V0",
             Self::MistralMixtral8x7BInstructV0 => "Mistral Mixtral 8x7B Instruct V0",
             Self::MistralMistralLarge2402V1 => "Mistral Large 2402 V1",
             Self::MistralMistralSmall2402V1 => "Mistral Small 2402 V1",
+            Self::MistralPixtralLarge2502V1 => "Pixtral Large 25.02 V1",
+            Self::PalmyraWriterX5 => "Writer Palmyra X5",
+            Self::PalmyraWriterX4 => "Writer Palmyra X4",
             Self::Custom {
                 display_name, name, ..
             } => display_name.as_deref().unwrap_or(name),
         }
     }
 
-    pub fn max_token_count(&self) -> usize {
+    pub fn max_token_count(&self) -> u64 {
         match self {
             Self::Claude3_5SonnetV2
             | Self::Claude3Opus
             | Self::Claude3Sonnet
             | Self::Claude3_5Haiku
-            | Self::Claude3_7Sonnet => 200_000,
+            | Self::Claude3_7Sonnet
+            | Self::ClaudeSonnet4
+            | Self::ClaudeOpus4
+            | Self::ClaudeSonnet4Thinking
+            | Self::ClaudeOpus4Thinking => 200_000,
+            Self::AmazonNovaPremier => 1_000_000,
+            Self::PalmyraWriterX5 => 1_000_000,
+            Self::PalmyraWriterX4 => 128_000,
             Self::Custom { max_tokens, .. } => *max_tokens,
-            _ => 200_000,
+            _ => 128_000,
         }
     }
 
-    pub fn max_output_tokens(&self) -> u32 {
+    pub fn max_output_tokens(&self) -> u64 {
         match self {
             Self::Claude3Opus | Self::Claude3Sonnet | Self::Claude3_5Haiku => 4_096,
-            Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => 128_000,
-            Self::Claude3_5SonnetV2 => 8_192,
+            Self::Claude3_7Sonnet
+            | Self::Claude3_7SonnetThinking
+            | Self::ClaudeSonnet4
+            | Self::ClaudeSonnet4Thinking
+            | Self::ClaudeOpus4
+            | Model::ClaudeOpus4Thinking => 128_000,
+            Self::Claude3_5SonnetV2 | Self::PalmyraWriterX4 | Self::PalmyraWriterX5 => 8_192,
             Self::Custom {
                 max_output_tokens, ..
             } => max_output_tokens.unwrap_or(4_096),
@@ -231,7 +363,11 @@ impl Model {
             | Self::Claude3Opus
             | Self::Claude3Sonnet
             | Self::Claude3_5Haiku
-            | Self::Claude3_7Sonnet => 1.0,
+            | Self::Claude3_7Sonnet
+            | Self::ClaudeOpus4
+            | Self::ClaudeOpus4Thinking
+            | Self::ClaudeSonnet4
+            | Self::ClaudeSonnet4Thinking => 1.0,
             Self::Custom {
                 default_temperature,
                 ..
@@ -249,10 +385,17 @@ impl Model {
             | Self::Claude3_5SonnetV2
             | Self::Claude3_7Sonnet
             | Self::Claude3_7SonnetThinking
+            | Self::ClaudeOpus4
+            | Self::ClaudeOpus4Thinking
+            | Self::ClaudeSonnet4
+            | Self::ClaudeSonnet4Thinking
             | Self::Claude3_5Haiku => true,
 
             // Amazon Nova models (all support tool use)
-            Self::AmazonNovaPro | Self::AmazonNovaLite | Self::AmazonNovaMicro => true,
+            Self::AmazonNovaPremier
+            | Self::AmazonNovaPro
+            | Self::AmazonNovaLite
+            | Self::AmazonNovaMicro => true,
 
             // AI21 Jamba 1.5 models support tool use
             Self::AI21Jamba15LargeV1 | Self::AI21Jamba15MiniV1 => true,
@@ -266,16 +409,72 @@ impl Model {
         }
     }
 
+    pub fn supports_caching(&self) -> bool {
+        match self {
+            // Only Claude models on Bedrock support caching
+            // Nova models support only text caching
+            // https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html#prompt-caching-models
+            Self::Claude3_5Haiku
+            | Self::Claude3_7Sonnet
+            | Self::Claude3_7SonnetThinking
+            | Self::ClaudeSonnet4
+            | Self::ClaudeSonnet4Thinking
+            | Self::ClaudeOpus4
+            | Self::ClaudeOpus4Thinking => true,
+
+            // Custom models - check if they have cache configuration
+            Self::Custom {
+                cache_configuration,
+                ..
+            } => cache_configuration.is_some(),
+
+            // All other models don't support caching
+            _ => false,
+        }
+    }
+
+    pub fn cache_configuration(&self) -> Option<BedrockModelCacheConfiguration> {
+        match self {
+            Self::Claude3_7Sonnet
+            | Self::Claude3_7SonnetThinking
+            | Self::ClaudeSonnet4
+            | Self::ClaudeSonnet4Thinking
+            | Self::ClaudeOpus4
+            | Self::ClaudeOpus4Thinking => Some(BedrockModelCacheConfiguration {
+                max_cache_anchors: 4,
+                min_total_token: 1024,
+            }),
+
+            Self::Claude3_5Haiku => Some(BedrockModelCacheConfiguration {
+                max_cache_anchors: 4,
+                min_total_token: 2048,
+            }),
+
+            Self::Custom {
+                cache_configuration,
+                ..
+            } => cache_configuration.clone(),
+
+            _ => None,
+        }
+    }
+
     pub fn mode(&self) -> BedrockModelMode {
         match self {
             Model::Claude3_7SonnetThinking => BedrockModelMode::Thinking {
                 budget_tokens: Some(4096),
             },
+            Model::ClaudeSonnet4Thinking => BedrockModelMode::Thinking {
+                budget_tokens: Some(4096),
+            },
+            Model::ClaudeOpus4Thinking => BedrockModelMode::Thinking {
+                budget_tokens: Some(4096),
+            },
             _ => BedrockModelMode::Default,
         }
     }
 
-    pub fn cross_region_inference_id(&self, region: &str) -> Result<String, anyhow::Error> {
+    pub fn cross_region_inference_id(&self, region: &str) -> anyhow::Result<String> {
         let region_group = if region.starts_with("us-gov-") {
             "us-gov"
         } else if region.starts_with("us-") {
@@ -288,60 +487,89 @@ impl Model {
             // Canada and South America regions - default to US profiles
             "us"
         } else {
-            // Unknown region
-            return Err(anyhow!("Unsupported Region"));
+            anyhow::bail!("Unsupported Region {region}");
         };
 
-        let model_id = self.id();
+        let model_id = self.request_id();
 
         match (self, region_group) {
             // Custom models can't have CRI IDs
-            (Model::Custom { .. }, _) => Ok(self.id().into()),
+            (Model::Custom { .. }, _) => Ok(self.request_id().into()),
 
             // Models with US Gov only
             (Model::Claude3_5Sonnet, "us-gov") | (Model::Claude3Haiku, "us-gov") => {
                 Ok(format!("{}.{}", region_group, model_id))
             }
 
-            // Models available only in US
-            (Model::Claude3Opus, "us")
-            | (Model::Claude3_7Sonnet, "us")
-            | (Model::Claude3_7SonnetThinking, "us") => {
+            // Available everywhere
+            (Model::AmazonNovaLite | Model::AmazonNovaMicro | Model::AmazonNovaPro, _) => {
                 Ok(format!("{}.{}", region_group, model_id))
             }
 
-            // Models available in US, EU, and APAC
-            (Model::Claude3_5SonnetV2, "us")
-            | (Model::Claude3_5SonnetV2, "apac")
-            | (Model::Claude3_5Sonnet, _)
-            | (Model::Claude3Haiku, _)
-            | (Model::Claude3Sonnet, _)
-            | (Model::AmazonNovaLite, _)
-            | (Model::AmazonNovaMicro, _)
-            | (Model::AmazonNovaPro, _) => Ok(format!("{}.{}", region_group, model_id)),
-
-            // Models with limited EU availability
-            (Model::MetaLlama321BInstructV1, "us")
-            | (Model::MetaLlama321BInstructV1, "eu")
-            | (Model::MetaLlama323BInstructV1, "us")
-            | (Model::MetaLlama323BInstructV1, "eu") => {
-                Ok(format!("{}.{}", region_group, model_id))
-            }
-
-            // US-only models (all remaining Meta models)
-            (Model::MetaLlama38BInstructV1, "us")
-            | (Model::MetaLlama370BInstructV1, "us")
-            | (Model::MetaLlama318BInstructV1, "us")
-            | (Model::MetaLlama318BInstructV1_128k, "us")
-            | (Model::MetaLlama3170BInstructV1, "us")
-            | (Model::MetaLlama3170BInstructV1_128k, "us")
-            | (Model::MetaLlama3211BInstructV1, "us")
-            | (Model::MetaLlama3290BInstructV1, "us") => {
-                Ok(format!("{}.{}", region_group, model_id))
-            }
+            // Models in US
+            (
+                Model::AmazonNovaPremier
+                | Model::Claude3_5Haiku
+                | Model::Claude3_5Sonnet
+                | Model::Claude3_5SonnetV2
+                | Model::Claude3_7Sonnet
+                | Model::Claude3_7SonnetThinking
+                | Model::ClaudeSonnet4
+                | Model::ClaudeSonnet4Thinking
+                | Model::ClaudeOpus4
+                | Model::ClaudeOpus4Thinking
+                | Model::Claude3Haiku
+                | Model::Claude3Opus
+                | Model::Claude3Sonnet
+                | Model::DeepSeekR1
+                | Model::MetaLlama31405BInstructV1
+                | Model::MetaLlama3170BInstructV1_128k
+                | Model::MetaLlama3170BInstructV1
+                | Model::MetaLlama318BInstructV1_128k
+                | Model::MetaLlama318BInstructV1
+                | Model::MetaLlama3211BInstructV1
+                | Model::MetaLlama321BInstructV1
+                | Model::MetaLlama323BInstructV1
+                | Model::MetaLlama3290BInstructV1
+                | Model::MetaLlama3370BInstructV1
+                | Model::MetaLlama4Maverick17BInstructV1
+                | Model::MetaLlama4Scout17BInstructV1
+                | Model::MistralPixtralLarge2502V1
+                | Model::PalmyraWriterX4
+                | Model::PalmyraWriterX5,
+                "us",
+            ) => Ok(format!("{}.{}", region_group, model_id)),
+
+            // Models available in EU
+            (
+                Model::Claude3_5Sonnet
+                | Model::Claude3_7Sonnet
+                | Model::Claude3_7SonnetThinking
+                | Model::ClaudeSonnet4
+                | Model::ClaudeSonnet4Thinking
+                | Model::Claude3Haiku
+                | Model::Claude3Sonnet
+                | Model::MetaLlama321BInstructV1
+                | Model::MetaLlama323BInstructV1
+                | Model::MistralPixtralLarge2502V1,
+                "eu",
+            ) => Ok(format!("{}.{}", region_group, model_id)),
+
+            // Models available in APAC
+            (
+                Model::Claude3_5Sonnet
+                | Model::Claude3_5SonnetV2
+                | Model::Claude3Haiku
+                | Model::Claude3Sonnet
+                | Model::Claude3_7Sonnet
+                | Model::Claude3_7SonnetThinking
+                | Model::ClaudeSonnet4
+                | Model::ClaudeSonnet4Thinking,
+                "apac",
+            ) => Ok(format!("{}.{}", region_group, model_id)),
 
             // Any other combination is not supported
-            _ => Ok(self.id().into()),
+            _ => Ok(self.request_id().into()),
         }
     }
 }
@@ -371,6 +599,10 @@ mod tests {
     #[test]
     fn test_eu_region_inference_ids() -> anyhow::Result<()> {
         // Test European regions
+        assert_eq!(
+            Model::ClaudeSonnet4.cross_region_inference_id("eu-west-1")?,
+            "eu.anthropic.claude-sonnet-4-20250514-v1:0"
+        );
         assert_eq!(
             Model::Claude3Sonnet.cross_region_inference_id("eu-west-1")?,
             "eu.anthropic.claude-3-sonnet-20240229-v1:0"
@@ -389,6 +621,10 @@ mod tests {
             Model::Claude3_5SonnetV2.cross_region_inference_id("ap-northeast-1")?,
             "apac.anthropic.claude-3-5-sonnet-20241022-v2:0"
         );
+        assert_eq!(
+            Model::Claude3_5SonnetV2.cross_region_inference_id("ap-southeast-2")?,
+            "apac.anthropic.claude-3-5-sonnet-20241022-v2:0"
+        );
         assert_eq!(
             Model::AmazonNovaLite.cross_region_inference_id("ap-south-1")?,
             "apac.amazon.nova-lite-v1:0"
@@ -415,7 +651,11 @@ mod tests {
         // Test Meta models
         assert_eq!(
             Model::MetaLlama370BInstructV1.cross_region_inference_id("us-east-1")?,
-            "us.meta.llama3-70b-instruct-v1:0"
+            "meta.llama3-70b-instruct-v1:0"
+        );
+        assert_eq!(
+            Model::MetaLlama3170BInstructV1.cross_region_inference_id("us-east-1")?,
+            "us.meta.llama3-1-70b-instruct-v1:0"
         );
         assert_eq!(
             Model::MetaLlama321BInstructV1.cross_region_inference_id("eu-west-1")?,
@@ -478,6 +718,7 @@ mod tests {
             display_name: Some("My Custom Model".to_string()),
             max_output_tokens: Some(8192),
             default_temperature: Some(0.7),
+            cache_configuration: None,
         };
 
         // Custom model should return its name unchanged
@@ -488,4 +729,39 @@ mod tests {
 
         Ok(())
     }
+
+    #[test]
+    fn test_friendly_id_vs_request_id() {
+        // Test that id() returns friendly identifiers
+        assert_eq!(Model::Claude3_5SonnetV2.id(), "claude-3-5-sonnet-v2");
+        assert_eq!(Model::AmazonNovaLite.id(), "amazon-nova-lite");
+        assert_eq!(Model::DeepSeekR1.id(), "deepseek-r1");
+        assert_eq!(
+            Model::MetaLlama38BInstructV1.id(),
+            "meta-llama3-8b-instruct-v1"
+        );
+
+        // Test that request_id() returns actual backend model IDs
+        assert_eq!(
+            Model::Claude3_5SonnetV2.request_id(),
+            "anthropic.claude-3-5-sonnet-20241022-v2:0"
+        );
+        assert_eq!(Model::AmazonNovaLite.request_id(), "amazon.nova-lite-v1:0");
+        assert_eq!(Model::DeepSeekR1.request_id(), "deepseek.r1-v1:0");
+        assert_eq!(
+            Model::MetaLlama38BInstructV1.request_id(),
+            "meta.llama3-8b-instruct-v1:0"
+        );
+
+        // Test thinking models have different friendly IDs but same request IDs
+        assert_eq!(Model::ClaudeSonnet4.id(), "claude-4-sonnet");
+        assert_eq!(
+            Model::ClaudeSonnet4Thinking.id(),
+            "claude-4-sonnet-thinking"
+        );
+        assert_eq!(
+            Model::ClaudeSonnet4.request_id(),
+            Model::ClaudeSonnet4Thinking.request_id()
+        );
+    }
 }

crates/breadcrumbs/Cargo.toml 🔗

@@ -16,6 +16,7 @@ doctest = false
 editor.workspace = true
 gpui.workspace = true
 itertools.workspace = true
+settings.workspace = true
 theme.workspace = true
 ui.workspace = true
 workspace.workspace = true

crates/breadcrumbs/src/breadcrumbs.rs 🔗

@@ -1,14 +1,15 @@
 use editor::Editor;
 use gpui::{
-    Context, Element, EventEmitter, Focusable, IntoElement, ParentElement, Render, StyledText,
-    Subscription, Window,
+    Context, Element, EventEmitter, Focusable, FontWeight, IntoElement, ParentElement, Render,
+    StyledText, Subscription, Window,
 };
 use itertools::Itertools;
+use settings::Settings;
 use std::cmp;
 use theme::ActiveTheme;
 use ui::{ButtonLike, ButtonStyle, Label, Tooltip, prelude::*};
 use workspace::{
-    ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
+    TabBarSettings, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
     item::{BreadcrumbText, ItemEvent, ItemHandle},
 };
 
@@ -71,16 +72,23 @@ impl Render for Breadcrumbs {
             );
         }
 
-        let highlighted_segments = segments.into_iter().map(|segment| {
+        let highlighted_segments = segments.into_iter().enumerate().map(|(index, segment)| {
             let mut text_style = window.text_style();
-            if let Some(font) = segment.font {
-                text_style.font_family = font.family;
-                text_style.font_features = font.features;
+            if let Some(ref font) = segment.font {
+                text_style.font_family = font.family.clone();
+                text_style.font_features = font.features.clone();
                 text_style.font_style = font.style;
                 text_style.font_weight = font.weight;
             }
             text_style.color = Color::Muted.color(cx);
 
+            if index == 0 && !TabBarSettings::get_global(cx).show && active_item.is_dirty(cx) {
+                if let Some(styled_element) = apply_dirty_filename_style(&segment, &text_style, cx)
+                {
+                    return styled_element;
+                }
+            }
+
             StyledText::new(segment.text.replace('\n', "⏎"))
                 .with_default_highlights(&text_style, segment.highlights.unwrap_or_default())
                 .into_any()
@@ -184,3 +192,46 @@ impl ToolbarItemView for Breadcrumbs {
         self.pane_focused = pane_focused;
     }
 }
+
+fn apply_dirty_filename_style(
+    segment: &BreadcrumbText,
+    text_style: &gpui::TextStyle,
+    cx: &mut Context<Breadcrumbs>,
+) -> Option<gpui::AnyElement> {
+    let text = segment.text.replace('\n', "⏎");
+
+    let filename_position = std::path::Path::new(&segment.text)
+        .file_name()
+        .and_then(|f| {
+            let filename_str = f.to_string_lossy();
+            segment.text.rfind(filename_str.as_ref())
+        })?;
+
+    let bold_weight = FontWeight::BOLD;
+    let default_color = Color::Default.color(cx);
+
+    if filename_position == 0 {
+        let mut filename_style = text_style.clone();
+        filename_style.font_weight = bold_weight;
+        filename_style.color = default_color;
+
+        return Some(
+            StyledText::new(text)
+                .with_default_highlights(&filename_style, [])
+                .into_any(),
+        );
+    }
+
+    let highlight_style = gpui::HighlightStyle {
+        font_weight: Some(bold_weight),
+        color: Some(default_color),
+        ..Default::default()
+    };
+
+    let highlight = vec![(filename_position..text.len(), highlight_style)];
+    Some(
+        StyledText::new(text)
+            .with_default_highlights(&text_style, highlight)
+            .into_any(),
+    )
+}

crates/buffer_diff/Cargo.toml 🔗

@@ -31,9 +31,9 @@ workspace-hack.workspace = true
 
 [dev-dependencies]
 ctor.workspace = true
-env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 rand.workspace = true
 serde_json.workspace = true
 text = { workspace = true, features = ["test-support"] }
 unindent.workspace = true
+zlog.workspace = true

crates/buffer_diff/src/buffer_diff.rs 🔗

@@ -1028,7 +1028,11 @@ impl BufferDiff {
         let (base_text_changed, mut changed_range) =
             match (state.base_text_exists, new_state.base_text_exists) {
                 (false, false) => (true, None),
-                (true, true) if state.base_text.remote_id() == new_state.base_text.remote_id() => {
+                (true, true)
+                    if state.base_text.remote_id() == new_state.base_text.remote_id()
+                        && state.base_text.syntax_update_count()
+                            == new_state.base_text.syntax_update_count() =>
+                {
                     (false, new_state.compare(&state, buffer))
                 }
                 _ => (true, Some(text::Anchor::MIN..text::Anchor::MAX)),
@@ -1346,9 +1350,7 @@ mod tests {
 
     #[ctor::ctor]
     fn init_logger() {
-        if std::env::var("RUST_LOG").is_ok() {
-            env_logger::init();
-        }
+        zlog::init_test();
     }
 
     #[gpui::test]
@@ -1865,7 +1867,7 @@ mod tests {
             let hunk = diff.hunks(&buffer, cx).next().unwrap();
 
             let new_index_text = diff
-                .stage_or_unstage_hunks(true, &[hunk.clone()], &buffer, true, cx)
+                .stage_or_unstage_hunks(true, std::slice::from_ref(&hunk), &buffer, true, cx)
                 .unwrap()
                 .to_string();
             assert_eq!(new_index_text, buffer_text);

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

@@ -2,7 +2,7 @@ pub mod participant;
 pub mod room;
 
 use crate::call_settings::CallSettings;
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use audio::Audio;
 use client::{ChannelId, Client, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIVE, proto};
 use collections::HashSet;
@@ -116,7 +116,7 @@ impl ActiveCall {
         envelope: TypedEnvelope<proto::IncomingCall>,
         mut cx: AsyncApp,
     ) -> Result<proto::Ack> {
-        let user_store = this.update(&mut cx, |this, _| this.user_store.clone())?;
+        let user_store = this.read_with(&mut cx, |this, _| this.user_store.clone())?;
         let call = IncomingCall {
             room_id: envelope.payload.room_id,
             participants: user_store
@@ -187,7 +187,7 @@ impl ActiveCall {
 
         let invite = if let Some(room) = room {
             cx.spawn(async move |_, cx| {
-                let room = room.await.map_err(|err| anyhow!("{:?}", err))?;
+                let room = room.await.map_err(|err| anyhow!("{err:?}"))?;
 
                 let initial_project_id = if let Some(initial_project) = initial_project {
                     Some(
@@ -236,7 +236,7 @@ impl ActiveCall {
                 .shared();
             self.pending_room_creation = Some(room.clone());
             cx.background_spawn(async move {
-                room.await.map_err(|err| anyhow!("{:?}", err))?;
+                room.await.map_err(|err| anyhow!("{err:?}"))?;
                 anyhow::Ok(())
             })
         };
@@ -326,7 +326,7 @@ impl ActiveCall {
             .0
             .borrow_mut()
             .take()
-            .ok_or_else(|| anyhow!("no incoming call"))?;
+            .context("no incoming call")?;
         telemetry::event!("Incoming Call Declined", room_id = call.room_id);
         self.client.send(proto::DeclineCall {
             room_id: call.room_id,
@@ -399,12 +399,9 @@ impl ActiveCall {
         project: Entity<Project>,
         cx: &mut Context<Self>,
     ) -> Result<()> {
-        if let Some((room, _)) = self.room.as_ref() {
-            self.report_call_event("Project Unshared", cx);
-            room.update(cx, |room, cx| room.unshare_project(project, cx))
-        } else {
-            Err(anyhow!("no active call"))
-        }
+        let (room, _) = self.room.as_ref().context("no active call")?;
+        self.report_call_event("Project Unshared", cx);
+        room.update(cx, |room, cx| room.unshare_project(project, cx))
     }
 
     pub fn location(&self) -> Option<&WeakEntity<Project>> {

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

@@ -1,4 +1,4 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use client::{ParticipantIndex, User, proto};
 use collections::HashMap;
 use gpui::WeakEntity;
@@ -18,17 +18,17 @@ pub enum ParticipantLocation {
 
 impl ParticipantLocation {
     pub fn from_proto(location: Option<proto::ParticipantLocation>) -> Result<Self> {
-        match location.and_then(|l| l.variant) {
-            Some(proto::participant_location::Variant::SharedProject(project)) => {
+        match location
+            .and_then(|l| l.variant)
+            .context("participant location was not provided")?
+        {
+            proto::participant_location::Variant::SharedProject(project) => {
                 Ok(Self::SharedProject {
                     project_id: project.id,
                 })
             }
-            Some(proto::participant_location::Variant::UnsharedProject(_)) => {
-                Ok(Self::UnsharedProject)
-            }
-            Some(proto::participant_location::Variant::External(_)) => Ok(Self::External),
-            None => Err(anyhow!("participant location was not provided")),
+            proto::participant_location::Variant::UnsharedProject(_) => Ok(Self::UnsharedProject),
+            proto::participant_location::Variant::External(_) => Ok(Self::External),
         }
     }
 }

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

@@ -2,7 +2,7 @@ use crate::{
     call_settings::CallSettings,
     participant::{LocalParticipant, ParticipantLocation, RemoteParticipant},
 };
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use audio::{Audio, Sound};
 use client::{
     ChannelId, Client, ParticipantIndex, TypedEnvelope, User, UserStore,
@@ -165,7 +165,7 @@ impl Room {
     ) -> Task<Result<Entity<Self>>> {
         cx.spawn(async move |cx| {
             let response = client.request(proto::CreateRoom {}).await?;
-            let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
+            let room_proto = response.room.context("invalid room")?;
             let room = cx.new(|cx| {
                 let mut room = Self::new(
                     room_proto.id,
@@ -270,7 +270,7 @@ impl Room {
         user_store: Entity<UserStore>,
         mut cx: AsyncApp,
     ) -> Result<Entity<Self>> {
-        let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
+        let room_proto = response.room.context("invalid room")?;
         let room = cx.new(|cx| {
             Self::new(
                 room_proto.id,
@@ -360,7 +360,7 @@ impl Room {
                 log::info!("detected client disconnection");
 
                 this.upgrade()
-                    .ok_or_else(|| anyhow!("room was dropped"))?
+                    .context("room was dropped")?
                     .update(cx, |this, cx| {
                         this.status = RoomStatus::Rejoining;
                         cx.notify();
@@ -428,9 +428,7 @@ impl Room {
             log::info!("reconnection failed, leaving room");
             this.update(cx, |this, cx| this.leave(cx))?.await?;
         }
-        Err(anyhow!(
-            "can't reconnect to room: client failed to re-establish connection"
-        ))
+        anyhow::bail!("can't reconnect to room: client failed to re-establish connection");
     }
 
     fn rejoin(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
@@ -494,7 +492,7 @@ impl Room {
             let response = response.await?;
             let message_id = response.message_id;
             let response = response.payload;
-            let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
+            let room_proto = response.room.context("invalid room")?;
             this.update(cx, |this, cx| {
                 this.status = RoomStatus::Online;
                 this.apply_room_update(room_proto, cx)?;
@@ -645,10 +643,7 @@ impl Room {
         envelope: TypedEnvelope<proto::RoomUpdated>,
         mut cx: AsyncApp,
     ) -> Result<()> {
-        let room = envelope
-            .payload
-            .room
-            .ok_or_else(|| anyhow!("invalid room"))?;
+        let room = envelope.payload.room.context("invalid room")?;
         this.update(&mut cx, |this, cx| this.apply_room_update(room, cx))?
     }
 
@@ -937,12 +932,15 @@ impl Room {
             } => {
                 let user_id = participant.identity().0.parse()?;
                 let track_id = track.sid();
-                let participant = self.remote_participants.get_mut(&user_id).ok_or_else(|| {
-                    anyhow!(
-                        "{:?} subscribed to track by unknown participant {user_id}",
-                        self.client.user_id()
-                    )
-                })?;
+                let participant =
+                    self.remote_participants
+                        .get_mut(&user_id)
+                        .with_context(|| {
+                            format!(
+                                "{:?} subscribed to track by unknown participant {user_id}",
+                                self.client.user_id()
+                            )
+                        })?;
                 if self.live_kit.as_ref().map_or(true, |kit| kit.deafened) {
                     if publication.is_audio() {
                         publication.set_enabled(false, cx);
@@ -972,12 +970,15 @@ impl Room {
                 track, participant, ..
             } => {
                 let user_id = participant.identity().0.parse()?;
-                let participant = self.remote_participants.get_mut(&user_id).ok_or_else(|| {
-                    anyhow!(
-                        "{:?}, unsubscribed from track by unknown participant {user_id}",
-                        self.client.user_id()
-                    )
-                })?;
+                let participant =
+                    self.remote_participants
+                        .get_mut(&user_id)
+                        .with_context(|| {
+                            format!(
+                                "{:?}, unsubscribed from track by unknown participant {user_id}",
+                                self.client.user_id()
+                            )
+                        })?;
                 match track {
                     livekit_client::RemoteTrack::Audio(track) => {
                         participant.audio_tracks.remove(&track.sid());
@@ -1324,7 +1325,7 @@ impl Room {
                 let live_kit = this
                     .live_kit
                     .as_mut()
-                    .ok_or_else(|| anyhow!("live-kit was not initialized"))?;
+                    .context("live-kit was not initialized")?;
 
                 let canceled = if let LocalTrack::Pending {
                     publish_id: cur_publish_id,
@@ -1389,7 +1390,7 @@ impl Room {
 
         cx.spawn(async move |this, cx| {
             let sources = sources.await??;
-            let source = sources.first().ok_or_else(|| anyhow!("no display found"))?;
+            let source = sources.first().context("no display found")?;
 
             let publication = participant.publish_screenshare_track(&**source, cx).await;
 
@@ -1397,7 +1398,7 @@ impl Room {
                 let live_kit = this
                     .live_kit
                     .as_mut()
-                    .ok_or_else(|| anyhow!("live-kit was not initialized"))?;
+                    .context("live-kit was not initialized")?;
 
                 let canceled = if let LocalTrack::Pending {
                     publish_id: cur_publish_id,
@@ -1485,16 +1486,14 @@ impl Room {
     }
 
     pub fn unshare_screen(&mut self, cx: &mut Context<Self>) -> Result<()> {
-        if self.status.is_offline() {
-            return Err(anyhow!("room is offline"));
-        }
+        anyhow::ensure!(!self.status.is_offline(), "room is offline");
 
         let live_kit = self
             .live_kit
             .as_mut()
-            .ok_or_else(|| anyhow!("live-kit was not initialized"))?;
+            .context("live-kit was not initialized")?;
         match mem::take(&mut live_kit.screen_track) {
-            LocalTrack::None => Err(anyhow!("screen was not shared")),
+            LocalTrack::None => anyhow::bail!("screen was not shared"),
             LocalTrack::Pending { .. } => {
                 cx.notify();
                 Ok(())

crates/call/src/call_settings.rs 🔗

@@ -12,6 +12,7 @@ pub struct CallSettings {
 
 /// Configuration of voice calls in Zed.
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+#[schemars(deny_unknown_fields)]
 pub struct CallSettingsContent {
     /// Whether the microphone should be muted when joining a channel or a call.
     ///

crates/channel/Cargo.toml 🔗

@@ -24,6 +24,7 @@ futures.workspace = true
 gpui.workspace = true
 language.workspace = true
 log.workspace = true
+postage.workspace = true
 rand.workspace = true
 release_channel.workspace = true
 rpc.workspace = true

crates/channel/src/channel_buffer.rs 🔗

@@ -35,6 +35,7 @@ pub struct ChannelBuffer {
 pub enum ChannelBufferEvent {
     CollaboratorsChanged,
     Disconnected,
+    Connected,
     BufferEdited,
     ChannelChanged,
 }
@@ -103,6 +104,17 @@ impl ChannelBuffer {
         }
     }
 
+    pub fn connected(&mut self, cx: &mut Context<Self>) {
+        self.connected = true;
+        if self.subscription.is_none() {
+            let Ok(subscription) = self.client.subscribe_to_entity(self.channel_id.0) else {
+                return;
+            };
+            self.subscription = Some(subscription.set_entity(&cx.entity(), &mut cx.to_async()));
+            cx.emit(ChannelBufferEvent::Connected);
+        }
+    }
+
     pub fn remote_id(&self, cx: &App) -> BufferId {
         self.buffer.read(cx).remote_id()
     }

crates/channel/src/channel_chat.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{Channel, ChannelStore};
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use client::{
     ChannelId, Client, Subscription, TypedEnvelope, UserId, proto,
     user::{User, UserStore},
@@ -170,15 +170,16 @@ impl ChannelChat {
         message: MessageParams,
         cx: &mut Context<Self>,
     ) -> Result<Task<Result<u64>>> {
-        if message.text.trim().is_empty() {
-            Err(anyhow!("message body can't be empty"))?;
-        }
+        anyhow::ensure!(
+            !message.text.trim().is_empty(),
+            "message body can't be empty"
+        );
 
         let current_user = self
             .user_store
             .read(cx)
             .current_user()
-            .ok_or_else(|| anyhow!("current_user is not present"))?;
+            .context("current_user is not present")?;
 
         let channel_id = self.channel_id;
         let pending_id = ChannelMessageId::Pending(post_inc(&mut self.next_pending_message_id));
@@ -215,7 +216,7 @@ impl ChannelChat {
             });
             let response = request.await?;
             drop(outgoing_message_guard);
-            let response = response.message.ok_or_else(|| anyhow!("invalid message"))?;
+            let response = response.message.context("invalid message")?;
             let id = response.id;
             let message = ChannelMessage::from_proto(response, &user_store, cx).await?;
             this.update(cx, |this, cx| {
@@ -386,7 +387,7 @@ impl ChannelChat {
         let loaded_messages = messages_from_proto(proto_messages, &user_store, cx).await?;
 
         let first_loaded_message_id = loaded_messages.first().map(|m| m.id);
-        let loaded_message_ids = this.update(cx, |this, _| {
+        let loaded_message_ids = this.read_with(cx, |this, _| {
             let mut loaded_message_ids: HashSet<u64> = HashSet::default();
             for message in loaded_messages.iter() {
                 if let Some(saved_message_id) = message.id.into() {
@@ -456,7 +457,7 @@ impl ChannelChat {
                 )
                 .await?;
 
-                let pending_messages = this.update(cx, |this, _| {
+                let pending_messages = this.read_with(cx, |this, _| {
                     this.pending_messages().cloned().collect::<Vec<_>>()
                 })?;
 
@@ -470,7 +471,7 @@ impl ChannelChat {
                     });
                     let response = request.await?;
                     let message = ChannelMessage::from_proto(
-                        response.message.ok_or_else(|| anyhow!("invalid message"))?,
+                        response.message.context("invalid message")?,
                         &user_store,
                         cx,
                     )
@@ -530,11 +531,8 @@ impl ChannelChat {
         message: TypedEnvelope<proto::ChannelMessageSent>,
         mut cx: AsyncApp,
     ) -> Result<()> {
-        let user_store = this.update(&mut cx, |this, _| this.user_store.clone())?;
-        let message = message
-            .payload
-            .message
-            .ok_or_else(|| anyhow!("empty message"))?;
+        let user_store = this.read_with(&mut cx, |this, _| this.user_store.clone())?;
+        let message = message.payload.message.context("empty message")?;
         let message_id = message.id;
 
         let message = ChannelMessage::from_proto(message, &user_store, &mut cx).await?;
@@ -565,11 +563,8 @@ impl ChannelChat {
         message: TypedEnvelope<proto::ChannelMessageUpdate>,
         mut cx: AsyncApp,
     ) -> Result<()> {
-        let user_store = this.update(&mut cx, |this, _| this.user_store.clone())?;
-        let message = message
-            .payload
-            .message
-            .ok_or_else(|| anyhow!("empty message"))?;
+        let user_store = this.read_with(&mut cx, |this, _| this.user_store.clone())?;
+        let message = message.payload.message.context("empty message")?;
 
         let message = ChannelMessage::from_proto(message, &user_store, &mut cx).await?;
 
@@ -753,10 +748,7 @@ impl ChannelMessage {
                 .collect(),
             timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64)?,
             sender,
-            nonce: message
-                .nonce
-                .ok_or_else(|| anyhow!("nonce is required"))?
-                .into(),
+            nonce: message.nonce.context("nonce is required")?.into(),
             reply_to_message_id: message.reply_to_message_id,
             edited_at,
         })

crates/channel/src/channel_store.rs 🔗

@@ -1,16 +1,17 @@
 mod channel_index;
 
 use crate::{ChannelMessage, channel_buffer::ChannelBuffer, channel_chat::ChannelChat};
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use channel_index::ChannelIndex;
 use client::{ChannelId, Client, ClientSettings, Subscription, User, UserId, UserStore};
-use collections::{HashMap, HashSet, hash_map};
+use collections::{HashMap, HashSet};
 use futures::{Future, FutureExt, StreamExt, channel::mpsc, future::Shared};
 use gpui::{
     App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, SharedString, Task,
     WeakEntity,
 };
 use language::Capability;
+use postage::{sink::Sink, watch};
 use rpc::{
     TypedEnvelope,
     proto::{self, ChannelRole, ChannelVisibility},
@@ -43,6 +44,7 @@ pub struct ChannelStore {
     opened_chats: HashMap<ChannelId, OpenEntityHandle<ChannelChat>>,
     client: Arc<Client>,
     did_subscribe: bool,
+    channels_loaded: (watch::Sender<bool>, watch::Receiver<bool>),
     user_store: Entity<UserStore>,
     _rpc_subscriptions: [Subscription; 2],
     _watch_connection_status: Task<Option<()>>,
@@ -56,6 +58,7 @@ pub struct Channel {
     pub name: SharedString,
     pub visibility: proto::ChannelVisibility,
     pub parent_path: Vec<ChannelId>,
+    pub channel_order: i32,
 }
 
 #[derive(Default, Debug)]
@@ -110,7 +113,7 @@ pub struct ChannelMembership {
     pub role: proto::ChannelRole,
 }
 impl ChannelMembership {
-    pub fn sort_key(&self) -> MembershipSortKey {
+    pub fn sort_key(&self) -> MembershipSortKey<'_> {
         MembershipSortKey {
             role_order: match self.role {
                 proto::ChannelRole::Admin => 0,
@@ -218,6 +221,7 @@ impl ChannelStore {
             }),
             channel_states: Default::default(),
             did_subscribe: false,
+            channels_loaded: watch::channel_with(false),
         }
     }
 
@@ -233,6 +237,48 @@ impl ChannelStore {
         }
     }
 
+    pub fn wait_for_channels(
+        &mut self,
+        timeout: Duration,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        let mut channels_loaded_rx = self.channels_loaded.1.clone();
+        if *channels_loaded_rx.borrow() {
+            return Task::ready(Ok(()));
+        }
+
+        let mut status_receiver = self.client.status();
+        if status_receiver.borrow().is_connected() {
+            self.initialize();
+        }
+
+        let mut timer = cx.background_executor().timer(timeout).fuse();
+        cx.spawn(async move |this, cx| {
+            loop {
+                futures::select_biased! {
+                    channels_loaded = channels_loaded_rx.next().fuse() => {
+                        if let Some(true) = channels_loaded {
+                            return Ok(());
+                        }
+                    }
+                    status = status_receiver.next().fuse() => {
+                        if let Some(status) = status {
+                            if status.is_connected() {
+                                this.update(cx, |this, _cx| {
+                                    this.initialize();
+                                }).ok();
+                            }
+                        }
+                        continue;
+                    }
+                    _ = timer => {
+                        return Err(anyhow!("{:?} elapsed without receiving channels", timeout));
+                    }
+                }
+            }
+        })
+    }
+
     pub fn client(&self) -> Arc<Client> {
         self.client.clone()
     }
@@ -308,6 +354,7 @@ impl ChannelStore {
         let channel_store = cx.entity();
         self.open_channel_resource(
             channel_id,
+            "notes",
             |this| &mut this.opened_buffers,
             async move |channel, cx| {
                 ChannelBuffer::new(channel, client, user_store, channel_store, cx).await
@@ -332,10 +379,8 @@ impl ChannelStore {
         cx.spawn(async move |this, cx| {
             if let Some(request) = request {
                 let response = request.await?;
-                let this = this
-                    .upgrade()
-                    .ok_or_else(|| anyhow!("channel store dropped"))?;
-                let user_store = this.update(cx, |this, _| this.user_store.clone())?;
+                let this = this.upgrade().context("channel store dropped")?;
+                let user_store = this.read_with(cx, |this, _| this.user_store.clone())?;
                 ChannelMessage::from_proto_vec(response.messages, &user_store, cx).await
             } else {
                 Ok(Vec::new())
@@ -440,6 +485,7 @@ impl ChannelStore {
         let this = cx.entity();
         self.open_channel_resource(
             channel_id,
+            "chat",
             |this| &mut this.opened_chats,
             async move |channel, cx| ChannelChat::new(channel, this, user_store, client, cx).await,
             cx,
@@ -454,6 +500,7 @@ impl ChannelStore {
     fn open_channel_resource<T, F>(
         &mut self,
         channel_id: ChannelId,
+        resource_name: &'static str,
         get_map: fn(&mut Self) -> &mut HashMap<ChannelId, OpenEntityHandle<T>>,
         load: F,
         cx: &mut Context<Self>,
@@ -463,58 +510,56 @@ impl ChannelStore {
         T: 'static,
     {
         let task = loop {
-            match get_map(self).entry(channel_id) {
-                hash_map::Entry::Occupied(e) => match e.get() {
-                    OpenEntityHandle::Open(entity) => {
-                        if let Some(entity) = entity.upgrade() {
-                            break Task::ready(Ok(entity)).shared();
-                        } else {
-                            get_map(self).remove(&channel_id);
-                            continue;
-                        }
-                    }
-                    OpenEntityHandle::Loading(task) => {
-                        break task.clone();
+            match get_map(self).get(&channel_id) {
+                Some(OpenEntityHandle::Open(entity)) => {
+                    if let Some(entity) = entity.upgrade() {
+                        break Task::ready(Ok(entity)).shared();
+                    } else {
+                        get_map(self).remove(&channel_id);
+                        continue;
                     }
-                },
-                hash_map::Entry::Vacant(e) => {
-                    let task = cx
-                        .spawn(async move |this, cx| {
-                            let channel = this.update(cx, |this, _| {
-                                this.channel_for_id(channel_id).cloned().ok_or_else(|| {
-                                    Arc::new(anyhow!("no channel for id: {}", channel_id))
-                                })
-                            })??;
-
-                            load(channel, cx).await.map_err(Arc::new)
-                        })
-                        .shared();
-
-                    e.insert(OpenEntityHandle::Loading(task.clone()));
-                    cx.spawn({
-                        let task = task.clone();
-                        async move |this, cx| {
-                            let result = task.await;
-                            this.update(cx, |this, _| match result {
-                                Ok(model) => {
-                                    get_map(this).insert(
-                                        channel_id,
-                                        OpenEntityHandle::Open(model.downgrade()),
-                                    );
-                                }
-                                Err(_) => {
-                                    get_map(this).remove(&channel_id);
-                                }
-                            })
-                            .ok();
-                        }
-                    })
-                    .detach();
-                    break task;
                 }
+                Some(OpenEntityHandle::Loading(task)) => break task.clone(),
+                None => {}
             }
+
+            let channels_ready = self.wait_for_channels(Duration::from_secs(10), cx);
+            let task = cx
+                .spawn(async move |this, cx| {
+                    channels_ready.await?;
+                    let channel = this.read_with(cx, |this, _| {
+                        this.channel_for_id(channel_id)
+                            .cloned()
+                            .ok_or_else(|| Arc::new(anyhow!("no channel for id: {channel_id}")))
+                    })??;
+
+                    load(channel, cx).await.map_err(Arc::new)
+                })
+                .shared();
+
+            get_map(self).insert(channel_id, OpenEntityHandle::Loading(task.clone()));
+            let task = cx.spawn({
+                async move |this, cx| {
+                    let result = task.await;
+                    this.update(cx, |this, _| match &result {
+                        Ok(model) => {
+                            get_map(this)
+                                .insert(channel_id, OpenEntityHandle::Open(model.downgrade()));
+                        }
+                        Err(_) => {
+                            get_map(this).remove(&channel_id);
+                        }
+                    })?;
+                    result
+                }
+            });
+            break task.shared();
         };
-        cx.background_spawn(async move { task.await.map_err(|error| anyhow!("{}", error)) })
+        cx.background_spawn(async move {
+            task.await.map_err(|error| {
+                anyhow!("{error}").context(format!("failed to open channel {resource_name}"))
+            })
+        })
     }
 
     pub fn is_channel_admin(&self, channel_id: ChannelId) -> bool {
@@ -578,9 +623,7 @@ impl ChannelStore {
                 })
                 .await?;
 
-            let channel = response
-                .channel
-                .ok_or_else(|| anyhow!("missing channel in response"))?;
+            let channel = response.channel.context("missing channel in response")?;
             let channel_id = ChannelId(channel.id);
 
             this.update(cx, |this, cx| {
@@ -618,7 +661,24 @@ impl ChannelStore {
                     to: to.0,
                 })
                 .await?;
+            Ok(())
+        })
+    }
 
+    pub fn reorder_channel(
+        &mut self,
+        channel_id: ChannelId,
+        direction: proto::reorder_channel::Direction,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        let client = self.client.clone();
+        cx.spawn(async move |_, _| {
+            client
+                .request(proto::ReorderChannel {
+                    channel_id: channel_id.0,
+                    direction: direction.into(),
+                })
+                .await?;
             Ok(())
         })
     }
@@ -752,7 +812,7 @@ impl ChannelStore {
                 })
                 .await?
                 .channel
-                .ok_or_else(|| anyhow!("missing channel in response"))?;
+                .context("missing channel in response")?;
             this.update(cx, |this, cx| {
                 let task = this.update_channels(
                     proto::UpdateChannels {
@@ -852,7 +912,7 @@ impl ChannelStore {
         message: TypedEnvelope<proto::UpdateChannels>,
         mut cx: AsyncApp,
     ) -> Result<()> {
-        this.update(&mut cx, |this, _| {
+        this.read_with(&mut cx, |this, _| {
             this.update_channels_tx
                 .unbounded_send(message.payload)
                 .unwrap();
@@ -976,6 +1036,7 @@ impl ChannelStore {
                                     .log_err();
 
                                 if let Some(operations) = operations {
+                                    channel_buffer.connected(cx);
                                     let client = this.client.clone();
                                     cx.background_spawn(async move {
                                         let operations = operations.await;
@@ -1016,8 +1077,8 @@ impl ChannelStore {
 
                 if let Some(this) = this.upgrade() {
                     this.update(cx, |this, cx| {
-                        for (_, buffer) in this.opened_buffers.drain() {
-                            if let OpenEntityHandle::Open(buffer) = buffer {
+                        for (_, buffer) in &this.opened_buffers {
+                            if let OpenEntityHandle::Open(buffer) = &buffer {
                                 if let Some(buffer) = buffer.upgrade() {
                                     buffer.update(cx, |buffer, cx| buffer.disconnect(cx));
                                 }
@@ -1030,6 +1091,18 @@ impl ChannelStore {
         });
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn reset(&mut self) {
+        self.channel_invitations.clear();
+        self.channel_index.clear();
+        self.channel_participants.clear();
+        self.outgoing_invites.clear();
+        self.opened_buffers.clear();
+        self.opened_chats.clear();
+        self.disconnect_channel_buffers_task = None;
+        self.channel_states.clear();
+    }
+
     pub(crate) fn update_channels(
         &mut self,
         payload: proto::UpdateChannels,
@@ -1054,6 +1127,7 @@ impl ChannelStore {
                         visibility: channel.visibility(),
                         name: channel.name.into(),
                         parent_path: channel.parent_path.into_iter().map(ChannelId).collect(),
+                        channel_order: channel.channel_order,
                     }),
                 ),
             }
@@ -1119,6 +1193,8 @@ impl ChannelStore {
                     .or_default()
                     .update_latest_message_id(latest_channel_message.message_id);
             }
+
+            self.channels_loaded.0.try_send(true).log_err();
         }
 
         cx.notify();

crates/channel/src/channel_store/channel_index.rs 🔗

@@ -32,7 +32,7 @@ impl ChannelIndex {
             .retain(|channel_id| !channels.contains(channel_id));
     }
 
-    pub fn bulk_insert(&mut self) -> ChannelPathsInsertGuard {
+    pub fn bulk_insert(&mut self) -> ChannelPathsInsertGuard<'_> {
         ChannelPathsInsertGuard {
             channels_ordered: &mut self.channels_ordered,
             channels_by_id: &mut self.channels_by_id,
@@ -61,11 +61,13 @@ impl ChannelPathsInsertGuard<'_> {
 
             ret = existing_channel.visibility != channel_proto.visibility()
                 || existing_channel.name != channel_proto.name
-                || existing_channel.parent_path != parent_path;
+                || existing_channel.parent_path != parent_path
+                || existing_channel.channel_order != channel_proto.channel_order;
 
             existing_channel.visibility = channel_proto.visibility();
             existing_channel.name = channel_proto.name.into();
             existing_channel.parent_path = parent_path;
+            existing_channel.channel_order = channel_proto.channel_order;
         } else {
             self.channels_by_id.insert(
                 ChannelId(channel_proto.id),
@@ -74,6 +76,7 @@ impl ChannelPathsInsertGuard<'_> {
                     visibility: channel_proto.visibility(),
                     name: channel_proto.name.into(),
                     parent_path,
+                    channel_order: channel_proto.channel_order,
                 }),
             );
             self.insert_root(ChannelId(channel_proto.id));
@@ -100,17 +103,18 @@ impl Drop for ChannelPathsInsertGuard<'_> {
 fn channel_path_sorting_key(
     id: ChannelId,
     channels_by_id: &BTreeMap<ChannelId, Arc<Channel>>,
-) -> impl Iterator<Item = (&str, ChannelId)> {
-    let (parent_path, name) = channels_by_id
-        .get(&id)
-        .map_or((&[] as &[_], None), |channel| {
-            (
-                channel.parent_path.as_slice(),
-                Some((channel.name.as_ref(), channel.id)),
-            )
-        });
+) -> impl Iterator<Item = (i32, ChannelId)> {
+    let (parent_path, order_and_id) =
+        channels_by_id
+            .get(&id)
+            .map_or((&[] as &[_], None), |channel| {
+                (
+                    channel.parent_path.as_slice(),
+                    Some((channel.channel_order, channel.id)),
+                )
+            });
     parent_path
         .iter()
-        .filter_map(|id| Some((channels_by_id.get(id)?.name.as_ref(), *id)))
-        .chain(name)
+        .filter_map(|id| Some((channels_by_id.get(id)?.channel_order, *id)))
+        .chain(order_and_id)
 }

crates/channel/src/channel_store_tests.rs 🔗

@@ -21,12 +21,14 @@ fn test_update_channels(cx: &mut App) {
                     name: "b".to_string(),
                     visibility: proto::ChannelVisibility::Members as i32,
                     parent_path: Vec::new(),
+                    channel_order: 1,
                 },
                 proto::Channel {
                     id: 2,
                     name: "a".to_string(),
                     visibility: proto::ChannelVisibility::Members as i32,
                     parent_path: Vec::new(),
+                    channel_order: 2,
                 },
             ],
             ..Default::default()
@@ -37,8 +39,8 @@ fn test_update_channels(cx: &mut App) {
         &channel_store,
         &[
             //
-            (0, "a".to_string()),
             (0, "b".to_string()),
+            (0, "a".to_string()),
         ],
         cx,
     );
@@ -52,12 +54,14 @@ fn test_update_channels(cx: &mut App) {
                     name: "x".to_string(),
                     visibility: proto::ChannelVisibility::Members as i32,
                     parent_path: vec![1],
+                    channel_order: 1,
                 },
                 proto::Channel {
                     id: 4,
                     name: "y".to_string(),
                     visibility: proto::ChannelVisibility::Members as i32,
                     parent_path: vec![2],
+                    channel_order: 1,
                 },
             ],
             ..Default::default()
@@ -67,15 +71,111 @@ fn test_update_channels(cx: &mut App) {
     assert_channels(
         &channel_store,
         &[
-            (0, "a".to_string()),
-            (1, "y".to_string()),
             (0, "b".to_string()),
             (1, "x".to_string()),
+            (0, "a".to_string()),
+            (1, "y".to_string()),
         ],
         cx,
     );
 }
 
+#[gpui::test]
+fn test_update_channels_order_independent(cx: &mut App) {
+    /// Based on: https://stackoverflow.com/a/59939809
+    fn unique_permutations<T: Clone>(items: Vec<T>) -> Vec<Vec<T>> {
+        if items.len() == 1 {
+            vec![items]
+        } else {
+            let mut output: Vec<Vec<T>> = vec![];
+
+            for (ix, first) in items.iter().enumerate() {
+                let mut remaining_elements = items.clone();
+                remaining_elements.remove(ix);
+                for mut permutation in unique_permutations(remaining_elements) {
+                    permutation.insert(0, first.clone());
+                    output.push(permutation);
+                }
+            }
+            output
+        }
+    }
+
+    let test_data = vec![
+        proto::Channel {
+            id: 6,
+            name: "β".to_string(),
+            visibility: proto::ChannelVisibility::Members as i32,
+            parent_path: vec![1, 3],
+            channel_order: 1,
+        },
+        proto::Channel {
+            id: 5,
+            name: "α".to_string(),
+            visibility: proto::ChannelVisibility::Members as i32,
+            parent_path: vec![1],
+            channel_order: 2,
+        },
+        proto::Channel {
+            id: 3,
+            name: "x".to_string(),
+            visibility: proto::ChannelVisibility::Members as i32,
+            parent_path: vec![1],
+            channel_order: 1,
+        },
+        proto::Channel {
+            id: 4,
+            name: "y".to_string(),
+            visibility: proto::ChannelVisibility::Members as i32,
+            parent_path: vec![2],
+            channel_order: 1,
+        },
+        proto::Channel {
+            id: 1,
+            name: "b".to_string(),
+            visibility: proto::ChannelVisibility::Members as i32,
+            parent_path: Vec::new(),
+            channel_order: 1,
+        },
+        proto::Channel {
+            id: 2,
+            name: "a".to_string(),
+            visibility: proto::ChannelVisibility::Members as i32,
+            parent_path: Vec::new(),
+            channel_order: 2,
+        },
+    ];
+
+    let channel_store = init_test(cx);
+    let permutations = unique_permutations(test_data);
+
+    for test_instance in permutations {
+        channel_store.update(cx, |channel_store, _| channel_store.reset());
+
+        update_channels(
+            &channel_store,
+            proto::UpdateChannels {
+                channels: test_instance,
+                ..Default::default()
+            },
+            cx,
+        );
+
+        assert_channels(
+            &channel_store,
+            &[
+                (0, "b".to_string()),
+                (1, "x".to_string()),
+                (2, "β".to_string()),
+                (1, "α".to_string()),
+                (0, "a".to_string()),
+                (1, "y".to_string()),
+            ],
+            cx,
+        );
+    }
+}
+
 #[gpui::test]
 fn test_dangling_channel_paths(cx: &mut App) {
     let channel_store = init_test(cx);
@@ -89,18 +189,21 @@ fn test_dangling_channel_paths(cx: &mut App) {
                     name: "a".to_string(),
                     visibility: proto::ChannelVisibility::Members as i32,
                     parent_path: vec![],
+                    channel_order: 1,
                 },
                 proto::Channel {
                     id: 1,
                     name: "b".to_string(),
                     visibility: proto::ChannelVisibility::Members as i32,
                     parent_path: vec![0],
+                    channel_order: 1,
                 },
                 proto::Channel {
                     id: 2,
                     name: "c".to_string(),
                     visibility: proto::ChannelVisibility::Members as i32,
                     parent_path: vec![0, 1],
+                    channel_order: 1,
                 },
             ],
             ..Default::default()
@@ -137,7 +240,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
     let user_id = 5;
     let channel_id = 5;
     let channel_store = cx.update(init_test);
-    let client = channel_store.update(cx, |s, _| s.client());
+    let client = channel_store.read_with(cx, |s, _| s.client());
     let server = FakeServer::for_client(user_id, &client, cx).await;
 
     // Get the available channels.
@@ -147,6 +250,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
             name: "the-channel".to_string(),
             visibility: proto::ChannelVisibility::Members as i32,
             parent_path: vec![],
+            channel_order: 1,
         }],
         ..Default::default()
     });
@@ -165,7 +269,6 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
                 github_login: "nathansobo".into(),
                 avatar_url: "http://avatar.com/nathansobo".into(),
                 name: None,
-                email: None,
             }],
         },
     );
@@ -219,7 +322,6 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
                 github_login: "maxbrunsfeld".into(),
                 avatar_url: "http://avatar.com/maxbrunsfeld".into(),
                 name: None,
-                email: None,
             }],
         },
     );
@@ -264,7 +366,6 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
                 github_login: "as-cii".into(),
                 avatar_url: "http://avatar.com/as-cii".into(),
                 name: None,
-                email: None,
             }],
         },
     );

crates/cli/src/cli.rs 🔗

@@ -13,6 +13,7 @@ pub enum CliRequest {
     Open {
         paths: Vec<String>,
         urls: Vec<String>,
+        diff_paths: Vec<[String; 2]>,
         wait: bool,
         open_new_workspace: Option<bool>,
         env: Option<HashMap<String, String>>,

crates/cli/src/main.rs 🔗

@@ -89,6 +89,9 @@ struct Args {
     /// Will attempt to give the correct command to run
     #[arg(long)]
     system_specs: bool,
+    /// Pairs of file paths to diff. Can be specified multiple times.
+    #[arg(long, action = clap::ArgAction::Append, num_args = 2, value_names = ["OLD_PATH", "NEW_PATH"])]
+    diff: Vec<String>,
     /// Uninstall Zed from user system
     #[cfg(all(
         any(target_os = "linux", target_os = "macos"),
@@ -127,8 +130,11 @@ fn parse_path_with_position(argument_str: &str) -> anyhow::Result<String> {
 }
 
 fn main() -> Result<()> {
+    #[cfg(unix)]
+    util::prevent_root_execution();
+
     // Exit flatpak sandbox if needed
-    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+    #[cfg(target_os = "linux")]
     {
         flatpak::try_restart_to_host();
         flatpak::ld_extra_libs();
@@ -152,7 +158,7 @@ fn main() -> Result<()> {
         paths::set_custom_data_dir(dir);
     }
 
-    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+    #[cfg(target_os = "linux")]
     let args = flatpak::set_bin_if_no_escape(args);
 
     let app = Detect::detect(args.zed.as_deref()).context("Bundle detection")?;
@@ -169,7 +175,7 @@ fn main() -> Result<()> {
             "To retrieve the system specs on the command line, run the following command:",
             &format!("{} --system-specs", path.display()),
         ];
-        return Err(anyhow::anyhow!(msg.join("\n")));
+        anyhow::bail!(msg.join("\n"));
     }
 
     #[cfg(all(
@@ -229,9 +235,17 @@ fn main() -> Result<()> {
     let exit_status = Arc::new(Mutex::new(None));
     let mut paths = vec![];
     let mut urls = vec![];
+    let mut diff_paths = vec![];
     let mut stdin_tmp_file: Option<fs::File> = None;
     let mut anonymous_fd_tmp_files = vec![];
 
+    for path in args.diff.chunks(2) {
+        diff_paths.push([
+            parse_path_with_position(&path[0])?,
+            parse_path_with_position(&path[1])?,
+        ]);
+    }
+
     for path in args.paths_with_position.iter() {
         if path.starts_with("zed://")
             || path.starts_with("http://")
@@ -255,11 +269,10 @@ fn main() -> Result<()> {
         }
     }
 
-    if let Some(_) = args.dev_server_token {
-        return Err(anyhow::anyhow!(
-            "Dev servers were removed in v0.157.x please upgrade to SSH remoting: https://zed.dev/docs/remote-development"
-        ))?;
-    }
+    anyhow::ensure!(
+        args.dev_server_token.is_none(),
+        "Dev servers were removed in v0.157.x please upgrade to SSH remoting: https://zed.dev/docs/remote-development"
+    );
 
     let sender: JoinHandle<anyhow::Result<()>> = thread::spawn({
         let exit_status = exit_status.clone();
@@ -271,6 +284,7 @@ fn main() -> Result<()> {
             tx.send(CliRequest::Open {
                 paths,
                 urls,
+                diff_paths,
                 wait: args.wait,
                 open_new_workspace,
                 env,
@@ -360,7 +374,7 @@ fn anonymous_fd(path: &str) -> Option<fs::File> {
         let file = unsafe { fs::File::from_raw_fd(fd) };
         return Some(file);
     }
-    #[cfg(target_os = "macos")]
+    #[cfg(any(target_os = "macos", target_os = "freebsd"))]
     {
         use std::os::{
             fd::{self, FromRawFd},
@@ -378,7 +392,7 @@ fn anonymous_fd(path: &str) -> Option<fs::File> {
         let file = unsafe { fs::File::from_raw_fd(fd) };
         return Some(file);
     }
-    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
+    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))]
     {
         _ = path;
         // not implemented for bsd, windows. Could be, but isn't yet
@@ -400,7 +414,7 @@ mod linux {
         time::Duration,
     };
 
-    use anyhow::anyhow;
+    use anyhow::{Context as _, anyhow};
     use cli::FORCE_CLI_MODE_ENV_VAR_NAME;
     use fork::Fork;
 
@@ -417,9 +431,7 @@ mod linux {
                 path.to_path_buf().canonicalize()?
             } else {
                 let cli = env::current_exe()?;
-                let dir = cli
-                    .parent()
-                    .ok_or_else(|| anyhow!("no parent path for cli"))?;
+                let dir = cli.parent().context("no parent path for cli")?;
 
                 // libexec is the standard, lib/zed is for Arch (and other non-libexec distros),
                 // ./zed is for the target directory in development builds.
@@ -428,8 +440,8 @@ mod linux {
                 possible_locations
                     .iter()
                     .find_map(|p| dir.join(p).canonicalize().ok().filter(|path| path != &cli))
-                    .ok_or_else(|| {
-                        anyhow!("could not find any of: {}", possible_locations.join(", "))
+                    .with_context(|| {
+                        format!("could not find any of: {}", possible_locations.join(", "))
                     })?
             };
 
@@ -525,7 +537,7 @@ mod linux {
     }
 }
 
-#[cfg(any(target_os = "linux", target_os = "freebsd"))]
+#[cfg(target_os = "linux")]
 mod flatpak {
     use std::ffi::OsString;
     use std::path::PathBuf;
@@ -759,7 +771,7 @@ mod windows {
 
 #[cfg(target_os = "macos")]
 mod mac_os {
-    use anyhow::{Context as _, Result, anyhow};
+    use anyhow::{Context as _, Result};
     use core_foundation::{
         array::{CFArray, CFIndex},
         base::TCFType as _,
@@ -800,9 +812,10 @@ mod mac_os {
         let cli_path = std::env::current_exe()?.canonicalize()?;
         let mut app_path = cli_path.clone();
         while app_path.extension() != Some(OsStr::new("app")) {
-            if !app_path.pop() {
-                return Err(anyhow!("cannot find app bundle containing {:?}", cli_path));
-            }
+            anyhow::ensure!(
+                app_path.pop(),
+                "cannot find app bundle containing {cli_path:?}"
+            );
         }
         Ok(app_path)
     }

crates/client/Cargo.toml 🔗

@@ -19,21 +19,25 @@ test-support = ["clock/test-support", "collections/test-support", "gpui/test-sup
 anyhow.workspace = true
 async-recursion = "0.3"
 async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manual-roots"] }
+base64.workspace = true
 chrono = { workspace = true, features = ["serde"] }
 clock.workspace = true
 collections.workspace = true
 credentials_provider.workspace = true
+derive_more.workspace = true
 feature_flags.workspace = true
 futures.workspace = true
 gpui.workspace = true
 gpui_tokio.workspace = true
 http_client.workspace = true
 http_client_tls.workspace = true
+httparse = "1.10"
 log.workspace = true
 paths.workspace = true
 parking_lot.workspace = true
 postage.workspace = true
 rand.workspace = true
+regex.workspace = true
 release_channel.workspace = true
 rpc = { workspace = true, features = ["gpui"] }
 schemars.workspace = true
@@ -46,7 +50,7 @@ telemetry_events.workspace = true
 text.workspace = true
 thiserror.workspace = true
 time.workspace = true
-tiny_http = "0.8"
+tiny_http.workspace = true
 tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io"] }
 url.workspace = true
 util.workspace = true
@@ -54,18 +58,27 @@ worktree.workspace = true
 telemetry.workspace = true
 tokio.workspace = true
 workspace-hack.workspace = true
+zed_llm_client.workspace = true
 
 [dev-dependencies]
 clock = { workspace = true, features = ["test-support"] }
 collections = { workspace = true, features = ["test-support"] }
+fs.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
+http_client = { workspace = true, features = ["test-support"] }
 rpc = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
 util = { workspace = true, features = ["test-support"] }
-http_client = { workspace = true, features = ["test-support"] }
 
 [target.'cfg(target_os = "windows")'.dependencies]
 windows.workspace = true
 
 [target.'cfg(target_os = "macos")'.dependencies]
 cocoa.workspace = true
+
+[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
+tokio-native-tls = "0.3"
+
+[target.'cfg(not(any(target_os = "windows", target_os = "macos")))'.dependencies]
+rustls-pki-types = "1.12"
+tokio-rustls = { version = "0.26", features = ["tls12", "ring"], default-features = false }

crates/client/src/client.rs 🔗

@@ -1,7 +1,7 @@
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 
-mod socks;
+mod proxy;
 pub mod telemetry;
 pub mod user;
 pub mod zed_urls;
@@ -24,13 +24,13 @@ use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions};
 use http_client::{AsyncBody, HttpClient, HttpClientWithUrl};
 use parking_lot::RwLock;
 use postage::watch;
+use proxy::connect_proxy_stream;
 use rand::prelude::*;
 use release_channel::{AppVersion, ReleaseChannel};
 use rpc::proto::{AnyTypedEnvelope, EnvelopedMessage, PeerId, RequestMessage};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsSources};
-use socks::connect_socks_proxy_stream;
 use std::pin::Pin;
 use std::{
     any::TypeId,
@@ -490,14 +490,14 @@ impl<T: 'static> Drop for PendingEntitySubscription<T> {
     }
 }
 
-#[derive(Copy, Clone)]
+#[derive(Copy, Clone, Deserialize, Debug)]
 pub struct TelemetrySettings {
     pub diagnostics: bool,
     pub metrics: bool,
 }
 
 /// Control what info is collected by Zed.
-#[derive(Default, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Default, Clone, Serialize, Deserialize, JsonSchema, Debug)]
 pub struct TelemetrySettingsContent {
     /// Send debug info like crash reports.
     ///
@@ -515,25 +515,7 @@ impl settings::Settings for TelemetrySettings {
     type FileContent = TelemetrySettingsContent;
 
     fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
-        Ok(Self {
-            diagnostics: sources
-                .user
-                .as_ref()
-                .or(sources.server.as_ref())
-                .and_then(|v| v.diagnostics)
-                .unwrap_or(
-                    sources
-                        .default
-                        .diagnostics
-                        .ok_or_else(Self::missing_default)?,
-                ),
-            metrics: sources
-                .user
-                .as_ref()
-                .or(sources.server.as_ref())
-                .and_then(|v| v.metrics)
-                .unwrap_or(sources.default.metrics.ok_or_else(Self::missing_default)?),
-        })
+        sources.json_merge()
     }
 
     fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) {
@@ -729,9 +711,10 @@ impl Client {
         let id = (TypeId::of::<T>(), remote_id);
 
         let mut state = self.handler_set.lock();
-        if state.entities_by_type_and_remote_id.contains_key(&id) {
-            return Err(anyhow!("already subscribed to entity"));
-        }
+        anyhow::ensure!(
+            !state.entities_by_type_and_remote_id.contains_key(&id),
+            "already subscribed to entity"
+        );
 
         state
             .entities_by_type_and_remote_id
@@ -922,7 +905,15 @@ impl Client {
                         }
 
                         futures::select_biased! {
-                            result = self.set_connection(conn, cx).fuse() => ConnectionResult::Result(result.context("client auth and connect")),
+                            result = self.set_connection(conn, cx).fuse() => {
+                                match result.context("client auth and connect") {
+                                    Ok(()) => ConnectionResult::Result(Ok(())),
+                                    Err(err) => {
+                                        self.set_status(Status::ConnectionError, cx);
+                                        ConnectionResult::Result(Err(err))
+                                    },
+                                }
+                            },
                             _ = timeout => {
                                 self.set_status(Status::ConnectionError, cx);
                                 ConnectionResult::Timeout
@@ -980,10 +971,7 @@ impl Client {
                         hello_message_type_name
                     )
                 })?;
-            let peer_id = hello
-                .payload
-                .peer_id
-                .ok_or_else(|| anyhow!("invalid peer id"))?;
+            let peer_id = hello.payload.peer_id.context("invalid peer id")?;
             Ok(peer_id)
         };
 
@@ -1093,22 +1081,19 @@ impl Client {
             }
 
             let response = http.get(&url, Default::default(), false).await?;
-            let collab_url = if response.status().is_redirection() {
-                response
-                    .headers()
-                    .get("Location")
-                    .ok_or_else(|| anyhow!("missing location header in /rpc response"))?
-                    .to_str()
-                    .map_err(EstablishConnectionError::other)?
-                    .to_string()
-            } else {
-                Err(anyhow!(
-                    "unexpected /rpc response status {}",
-                    response.status()
-                ))?
-            };
-
-            Url::parse(&collab_url).context("invalid rpc url")
+            anyhow::ensure!(
+                response.status().is_redirection(),
+                "unexpected /rpc response status {}",
+                response.status()
+            );
+            let collab_url = response
+                .headers()
+                .get("Location")
+                .context("missing location header in /rpc response")?
+                .to_str()
+                .map_err(EstablishConnectionError::other)?
+                .to_string();
+            Url::parse(&collab_url).with_context(|| format!("parsing colab rpc url {collab_url}"))
         }
     }
 
@@ -1150,13 +1135,13 @@ impl Client {
             let rpc_host = rpc_url
                 .host_str()
                 .zip(rpc_url.port_or_known_default())
-                .ok_or_else(|| anyhow!("missing host in rpc url"))?;
+                .context("missing host in rpc url")?;
 
             let stream = {
                 let handle = cx.update(|cx| gpui_tokio::Tokio::handle(cx)).ok().unwrap();
                 let _guard = handle.enter();
                 match proxy {
-                    Some(proxy) => connect_socks_proxy_stream(&proxy, rpc_host).await?,
+                    Some(proxy) => connect_proxy_stream(&proxy, rpc_host).await?,
                     None => Box::new(TcpStream::connect(rpc_host).await?),
                 }
             };
@@ -1305,16 +1290,13 @@ impl Client {
                                     )
                                     .context("failed to respond to login http request")?;
                                     return Ok((
-                                        user_id
-                                            .ok_or_else(|| anyhow!("missing user_id parameter"))?,
-                                        access_token.ok_or_else(|| {
-                                            anyhow!("missing access_token parameter")
-                                        })?,
+                                        user_id.context("missing user_id parameter")?,
+                                        access_token.context("missing access_token parameter")?,
                                     ));
                                 }
                             }
 
-                            Err(anyhow!("didn't receive login redirect"))
+                            anyhow::bail!("didn't receive login redirect");
                         })
                         .await?;
 
@@ -1432,13 +1414,12 @@ impl Client {
         let mut response = http.send(request).await?;
         let mut body = String::new();
         response.body_mut().read_to_string(&mut body).await?;
-        if !response.status().is_success() {
-            Err(anyhow!(
-                "admin user request failed {} - {}",
-                response.status().as_u16(),
-                body,
-            ))?;
-        }
+        anyhow::ensure!(
+            response.status().is_success(),
+            "admin user request failed {} - {}",
+            response.status().as_u16(),
+            body,
+        );
         let response: AuthenticatedUserResponse = serde_json::from_str(&body)?;
 
         // Use the admin API token to authenticate as the impersonated user.
@@ -1475,7 +1456,7 @@ impl Client {
         if let Status::Connected { connection_id, .. } = *self.status().borrow() {
             Ok(connection_id)
         } else {
-            Err(anyhow!("not connected"))
+            anyhow::bail!("not connected");
         }
     }
 
@@ -1869,7 +1850,7 @@ mod tests {
         let (done_tx2, done_rx2) = smol::channel::unbounded();
         AnyProtoClient::from(client.clone()).add_entity_message_handler(
             move |entity: Entity<TestEntity>, _: TypedEnvelope<proto::JoinProject>, mut cx| {
-                match entity.update(&mut cx, |entity, _| entity.id).unwrap() {
+                match entity.read_with(&mut cx, |entity, _| entity.id).unwrap() {
                     1 => done_tx1.try_send(()).unwrap(),
                     2 => done_tx2.try_send(()).unwrap(),
                     _ => unreachable!(),
@@ -1906,8 +1887,16 @@ mod tests {
             .set_entity(&entity3, &mut cx.to_async());
         drop(subscription3);
 
-        server.send(proto::JoinProject { project_id: 1 });
-        server.send(proto::JoinProject { project_id: 2 });
+        server.send(proto::JoinProject {
+            project_id: 1,
+            committer_name: None,
+            committer_email: None,
+        });
+        server.send(proto::JoinProject {
+            project_id: 2,
+            committer_name: None,
+            committer_email: None,
+        });
         done_rx1.recv().await.unwrap();
         done_rx2.recv().await.unwrap();
     }

crates/client/src/proxy.rs 🔗

@@ -0,0 +1,66 @@
+//! client proxy
+
+mod http_proxy;
+mod socks_proxy;
+
+use anyhow::{Context as _, Result};
+use http_client::Url;
+use http_proxy::{HttpProxyType, connect_http_proxy_stream, parse_http_proxy};
+use socks_proxy::{SocksVersion, connect_socks_proxy_stream, parse_socks_proxy};
+
+pub(crate) async fn connect_proxy_stream(
+    proxy: &Url,
+    rpc_host: (&str, u16),
+) -> Result<Box<dyn AsyncReadWrite>> {
+    let Some(((proxy_domain, proxy_port), proxy_type)) = parse_proxy_type(proxy) else {
+        // If parsing the proxy URL fails, we must avoid falling back to an insecure connection.
+        // SOCKS proxies are often used in contexts where security and privacy are critical,
+        // so any fallback could expose users to significant risks.
+        anyhow::bail!("Parsing proxy url failed");
+    };
+
+    // Connect to proxy and wrap protocol later
+    let stream = tokio::net::TcpStream::connect((proxy_domain.as_str(), proxy_port))
+        .await
+        .context("Failed to connect to proxy")?;
+
+    let proxy_stream = match proxy_type {
+        ProxyType::SocksProxy(proxy) => connect_socks_proxy_stream(stream, proxy, rpc_host).await?,
+        ProxyType::HttpProxy(proxy) => {
+            connect_http_proxy_stream(stream, proxy, rpc_host, &proxy_domain).await?
+        }
+    };
+
+    Ok(proxy_stream)
+}
+
+enum ProxyType<'t> {
+    SocksProxy(SocksVersion<'t>),
+    HttpProxy(HttpProxyType<'t>),
+}
+
+fn parse_proxy_type(proxy: &Url) -> Option<((String, u16), ProxyType<'_>)> {
+    let scheme = proxy.scheme();
+    let host = proxy.host()?.to_string();
+    let port = proxy.port_or_known_default()?;
+    let proxy_type = match scheme {
+        scheme if scheme.starts_with("socks") => {
+            Some(ProxyType::SocksProxy(parse_socks_proxy(scheme, proxy)))
+        }
+        scheme if scheme.starts_with("http") => {
+            Some(ProxyType::HttpProxy(parse_http_proxy(scheme, proxy)))
+        }
+        _ => None,
+    }?;
+
+    Some(((host, port), proxy_type))
+}
+
+pub(crate) trait AsyncReadWrite:
+    tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static
+{
+}
+impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static> AsyncReadWrite
+    for T
+{
+}

crates/client/src/proxy/http_proxy.rs 🔗

@@ -0,0 +1,193 @@
+use anyhow::{Context, Result};
+use base64::Engine;
+use httparse::{EMPTY_HEADER, Response};
+use tokio::{
+    io::{AsyncBufReadExt, AsyncWriteExt, BufStream},
+    net::TcpStream,
+};
+#[cfg(any(target_os = "windows", target_os = "macos"))]
+use tokio_native_tls::{TlsConnector, native_tls};
+#[cfg(not(any(target_os = "windows", target_os = "macos")))]
+use tokio_rustls::TlsConnector;
+use url::Url;
+
+use super::AsyncReadWrite;
+
+pub(super) enum HttpProxyType<'t> {
+    HTTP(Option<HttpProxyAuthorization<'t>>),
+    HTTPS(Option<HttpProxyAuthorization<'t>>),
+}
+
+pub(super) struct HttpProxyAuthorization<'t> {
+    username: &'t str,
+    password: &'t str,
+}
+
+pub(super) fn parse_http_proxy<'t>(scheme: &str, proxy: &'t Url) -> HttpProxyType<'t> {
+    let auth = proxy.password().map(|password| HttpProxyAuthorization {
+        username: proxy.username(),
+        password,
+    });
+    if scheme.starts_with("https") {
+        HttpProxyType::HTTPS(auth)
+    } else {
+        HttpProxyType::HTTP(auth)
+    }
+}
+
+pub(crate) async fn connect_http_proxy_stream(
+    stream: TcpStream,
+    http_proxy: HttpProxyType<'_>,
+    rpc_host: (&str, u16),
+    proxy_domain: &str,
+) -> Result<Box<dyn AsyncReadWrite>> {
+    match http_proxy {
+        HttpProxyType::HTTP(auth) => http_connect(stream, rpc_host, auth).await,
+        HttpProxyType::HTTPS(auth) => https_connect(stream, rpc_host, auth, proxy_domain).await,
+    }
+    .context("error connecting to http/https proxy")
+}
+
+async fn http_connect<T>(
+    stream: T,
+    target: (&str, u16),
+    auth: Option<HttpProxyAuthorization<'_>>,
+) -> Result<Box<dyn AsyncReadWrite>>
+where
+    T: AsyncReadWrite,
+{
+    let mut stream = BufStream::new(stream);
+    let request = make_request(target, auth);
+    stream.write_all(request.as_bytes()).await?;
+    stream.flush().await?;
+    check_response(&mut stream).await?;
+    Ok(Box::new(stream))
+}
+
+#[cfg(any(target_os = "windows", target_os = "macos"))]
+async fn https_connect<T>(
+    stream: T,
+    target: (&str, u16),
+    auth: Option<HttpProxyAuthorization<'_>>,
+    proxy_domain: &str,
+) -> Result<Box<dyn AsyncReadWrite>>
+where
+    T: AsyncReadWrite,
+{
+    let tls_connector = TlsConnector::from(native_tls::TlsConnector::new()?);
+    let stream = tls_connector.connect(proxy_domain, stream).await?;
+    http_connect(stream, target, auth).await
+}
+
+#[cfg(not(any(target_os = "windows", target_os = "macos")))]
+async fn https_connect<T>(
+    stream: T,
+    target: (&str, u16),
+    auth: Option<HttpProxyAuthorization<'_>>,
+    proxy_domain: &str,
+) -> Result<Box<dyn AsyncReadWrite>>
+where
+    T: AsyncReadWrite,
+{
+    let proxy_domain = rustls_pki_types::ServerName::try_from(proxy_domain)
+        .context("Address resolution failed")?
+        .to_owned();
+    let tls_connector = TlsConnector::from(std::sync::Arc::new(http_client_tls::tls_config()));
+    let stream = tls_connector.connect(proxy_domain, stream).await?;
+    http_connect(stream, target, auth).await
+}
+
+fn make_request(target: (&str, u16), auth: Option<HttpProxyAuthorization<'_>>) -> String {
+    let (host, port) = target;
+    let mut request = format!(
+        "CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\nProxy-Connection: Keep-Alive\r\n"
+    );
+    if let Some(HttpProxyAuthorization { username, password }) = auth {
+        let auth =
+            base64::prelude::BASE64_STANDARD.encode(format!("{username}:{password}").as_bytes());
+        let auth = format!("Proxy-Authorization: Basic {auth}\r\n");
+        request.push_str(&auth);
+    }
+    request.push_str("\r\n");
+    request
+}
+
+async fn check_response<T>(stream: &mut BufStream<T>) -> Result<()>
+where
+    T: AsyncReadWrite,
+{
+    let response = recv_response(stream).await?;
+    let mut dummy_headers = [EMPTY_HEADER; MAX_RESPONSE_HEADERS];
+    let mut parser = Response::new(&mut dummy_headers);
+    parser.parse(response.as_bytes())?;
+
+    match parser.code {
+        Some(code) => {
+            if code == 200 {
+                Ok(())
+            } else {
+                Err(anyhow::anyhow!(
+                    "Proxy connection failed with HTTP code: {code}"
+                ))
+            }
+        }
+        None => Err(anyhow::anyhow!(
+            "Proxy connection failed with no HTTP code: {}",
+            parser.reason.unwrap_or("Unknown reason")
+        )),
+    }
+}
+
+const MAX_RESPONSE_HEADER_LENGTH: usize = 4096;
+const MAX_RESPONSE_HEADERS: usize = 16;
+
+async fn recv_response<T>(stream: &mut BufStream<T>) -> Result<String>
+where
+    T: AsyncReadWrite,
+{
+    let mut response = String::new();
+    loop {
+        if stream.read_line(&mut response).await? == 0 {
+            return Err(anyhow::anyhow!("End of stream"));
+        }
+
+        if MAX_RESPONSE_HEADER_LENGTH < response.len() {
+            return Err(anyhow::anyhow!("Maximum response header length exceeded"));
+        }
+
+        if response.ends_with("\r\n\r\n") {
+            return Ok(response);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use url::Url;
+
+    use super::{HttpProxyAuthorization, HttpProxyType, parse_http_proxy};
+
+    #[test]
+    fn test_parse_http_proxy() {
+        let proxy = Url::parse("http://proxy.example.com:1080").unwrap();
+        let scheme = proxy.scheme();
+
+        let version = parse_http_proxy(scheme, &proxy);
+        assert!(matches!(version, HttpProxyType::HTTP(None)))
+    }
+
+    #[test]
+    fn test_parse_http_proxy_with_auth() {
+        let proxy = Url::parse("http://username:password@proxy.example.com:1080").unwrap();
+        let scheme = proxy.scheme();
+
+        let version = parse_http_proxy(scheme, &proxy);
+        assert!(matches!(
+            version,
+            HttpProxyType::HTTP(Some(HttpProxyAuthorization {
+                username: "username",
+                password: "password"
+            }))
+        ))
+    }
+}

crates/client/src/proxy/socks_proxy.rs 🔗

@@ -0,0 +1,226 @@
+//! socks proxy
+
+use anyhow::{Context as _, Result};
+use http_client::Url;
+use tokio::net::TcpStream;
+use tokio_socks::{
+    IntoTargetAddr, TargetAddr,
+    tcp::{Socks4Stream, Socks5Stream},
+};
+
+use super::AsyncReadWrite;
+
+/// Identification to a Socks V4 Proxy
+pub(super) struct Socks4Identification<'a> {
+    user_id: &'a str,
+}
+
+/// Authorization to a Socks V5 Proxy
+pub(super) struct Socks5Authorization<'a> {
+    username: &'a str,
+    password: &'a str,
+}
+
+/// Socks Proxy Protocol Version
+///
+/// V4 allows idenfication using a user_id
+/// V5 allows authorization using a username and password
+pub(super) enum SocksVersion<'a> {
+    V4 {
+        local_dns: bool,
+        identification: Option<Socks4Identification<'a>>,
+    },
+    V5 {
+        local_dns: bool,
+        authorization: Option<Socks5Authorization<'a>>,
+    },
+}
+
+pub(super) fn parse_socks_proxy<'t>(scheme: &str, proxy: &'t Url) -> SocksVersion<'t> {
+    if scheme.starts_with("socks4") {
+        let identification = match proxy.username() {
+            "" => None,
+            username => Some(Socks4Identification { user_id: username }),
+        };
+        SocksVersion::V4 {
+            local_dns: scheme != "socks4a",
+            identification,
+        }
+    } else {
+        let authorization = proxy.password().map(|password| Socks5Authorization {
+            username: proxy.username(),
+            password,
+        });
+        SocksVersion::V5 {
+            local_dns: scheme != "socks5h",
+            authorization,
+        }
+    }
+}
+
+pub(super) async fn connect_socks_proxy_stream(
+    stream: TcpStream,
+    socks_version: SocksVersion<'_>,
+    rpc_host: (&str, u16),
+) -> Result<Box<dyn AsyncReadWrite>> {
+    let rpc_host = rpc_host
+        .into_target_addr()
+        .context("Failed to parse target addr")?;
+
+    let local_dns = match &socks_version {
+        SocksVersion::V4 { local_dns, .. } => local_dns,
+        SocksVersion::V5 { local_dns, .. } => local_dns,
+    };
+    let rpc_host = match (rpc_host, local_dns) {
+        (TargetAddr::Domain(domain, port), true) => {
+            let ip_addr = tokio::net::lookup_host((domain.as_ref(), port))
+                .await
+                .with_context(|| format!("Failed to lookup domain {}", domain))?
+                .next()
+                .ok_or_else(|| anyhow::anyhow!("Failed to lookup domain {}", domain))?;
+            TargetAddr::Ip(ip_addr)
+        }
+        (rpc_host, _) => rpc_host,
+    };
+
+    match socks_version {
+        SocksVersion::V4 {
+            identification: None,
+            ..
+        } => {
+            let socks = Socks4Stream::connect_with_socket(stream, rpc_host)
+                .await
+                .context("error connecting to socks")?;
+            Ok(Box::new(socks))
+        }
+        SocksVersion::V4 {
+            identification: Some(Socks4Identification { user_id }),
+            ..
+        } => {
+            let socks = Socks4Stream::connect_with_userid_and_socket(stream, rpc_host, user_id)
+                .await
+                .context("error connecting to socks")?;
+            Ok(Box::new(socks))
+        }
+        SocksVersion::V5 {
+            authorization: None,
+            ..
+        } => {
+            let socks = Socks5Stream::connect_with_socket(stream, rpc_host)
+                .await
+                .context("error connecting to socks")?;
+            Ok(Box::new(socks))
+        }
+        SocksVersion::V5 {
+            authorization: Some(Socks5Authorization { username, password }),
+            ..
+        } => {
+            let socks = Socks5Stream::connect_with_password_and_socket(
+                stream, rpc_host, username, password,
+            )
+            .await
+            .context("error connecting to socks")?;
+            Ok(Box::new(socks))
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use url::Url;
+
+    use super::*;
+
+    #[test]
+    fn parse_socks4() {
+        let proxy = Url::parse("socks4://proxy.example.com:1080").unwrap();
+        let scheme = proxy.scheme();
+
+        let version = parse_socks_proxy(scheme, &proxy);
+        assert!(matches!(
+            version,
+            SocksVersion::V4 {
+                local_dns: true,
+                identification: None
+            }
+        ))
+    }
+
+    #[test]
+    fn parse_socks4_with_identification() {
+        let proxy = Url::parse("socks4://userid@proxy.example.com:1080").unwrap();
+        let scheme = proxy.scheme();
+
+        let version = parse_socks_proxy(scheme, &proxy);
+        assert!(matches!(
+            version,
+            SocksVersion::V4 {
+                local_dns: true,
+                identification: Some(Socks4Identification { user_id: "userid" })
+            }
+        ))
+    }
+
+    #[test]
+    fn parse_socks4_with_remote_dns() {
+        let proxy = Url::parse("socks4a://proxy.example.com:1080").unwrap();
+        let scheme = proxy.scheme();
+
+        let version = parse_socks_proxy(scheme, &proxy);
+        assert!(matches!(
+            version,
+            SocksVersion::V4 {
+                local_dns: false,
+                identification: None
+            }
+        ))
+    }
+
+    #[test]
+    fn parse_socks5() {
+        let proxy = Url::parse("socks5://proxy.example.com:1080").unwrap();
+        let scheme = proxy.scheme();
+
+        let version = parse_socks_proxy(scheme, &proxy);
+        assert!(matches!(
+            version,
+            SocksVersion::V5 {
+                local_dns: true,
+                authorization: None
+            }
+        ))
+    }
+
+    #[test]
+    fn parse_socks5_with_authorization() {
+        let proxy = Url::parse("socks5://username:password@proxy.example.com:1080").unwrap();
+        let scheme = proxy.scheme();
+
+        let version = parse_socks_proxy(scheme, &proxy);
+        assert!(matches!(
+            version,
+            SocksVersion::V5 {
+                local_dns: true,
+                authorization: Some(Socks5Authorization {
+                    username: "username",
+                    password: "password"
+                })
+            }
+        ))
+    }
+
+    #[test]
+    fn parse_socks5_with_remote_dns() {
+        let proxy = Url::parse("socks5h://proxy.example.com:1080").unwrap();
+        let scheme = proxy.scheme();
+
+        let version = parse_socks_proxy(scheme, &proxy);
+        assert!(matches!(
+            version,
+            SocksVersion::V5 {
+                local_dns: false,
+                authorization: None
+            }
+        ))
+    }
+}

crates/client/src/socks.rs 🔗

@@ -1,176 +0,0 @@
-//! socks proxy
-use anyhow::{Context, Result, anyhow};
-use http_client::Url;
-use tokio_socks::tcp::{Socks4Stream, Socks5Stream};
-
-/// Identification to a Socks V4 Proxy
-struct Socks4Identification<'a> {
-    user_id: &'a str,
-}
-
-/// Authorization to a Socks V5 Proxy
-struct Socks5Authorization<'a> {
-    username: &'a str,
-    password: &'a str,
-}
-
-/// Socks Proxy Protocol Version
-///
-/// V4 allows idenfication using a user_id
-/// V5 allows authorization using a username and password
-enum SocksVersion<'a> {
-    V4(Option<Socks4Identification<'a>>),
-    V5(Option<Socks5Authorization<'a>>),
-}
-
-pub(crate) async fn connect_socks_proxy_stream(
-    proxy: &Url,
-    rpc_host: (&str, u16),
-) -> Result<Box<dyn AsyncReadWrite>> {
-    let Some((socks_proxy, version)) = parse_socks_proxy(proxy) else {
-        // If parsing the proxy URL fails, we must avoid falling back to an insecure connection.
-        // SOCKS proxies are often used in contexts where security and privacy are critical,
-        // so any fallback could expose users to significant risks.
-        return Err(anyhow!("Parsing proxy url failed"));
-    };
-
-    // Connect to proxy and wrap protocol later
-    let stream = tokio::net::TcpStream::connect(socks_proxy)
-        .await
-        .context("Failed to connect to socks proxy")?;
-
-    let socks: Box<dyn AsyncReadWrite> = match version {
-        SocksVersion::V4(None) => {
-            let socks = Socks4Stream::connect_with_socket(stream, rpc_host)
-                .await
-                .context("error connecting to socks")?;
-            Box::new(socks)
-        }
-        SocksVersion::V4(Some(Socks4Identification { user_id })) => {
-            let socks = Socks4Stream::connect_with_userid_and_socket(stream, rpc_host, user_id)
-                .await
-                .context("error connecting to socks")?;
-            Box::new(socks)
-        }
-        SocksVersion::V5(None) => {
-            let socks = Socks5Stream::connect_with_socket(stream, rpc_host)
-                .await
-                .context("error connecting to socks")?;
-            Box::new(socks)
-        }
-        SocksVersion::V5(Some(Socks5Authorization { username, password })) => {
-            let socks = Socks5Stream::connect_with_password_and_socket(
-                stream, rpc_host, username, password,
-            )
-            .await
-            .context("error connecting to socks")?;
-            Box::new(socks)
-        }
-    };
-
-    Ok(socks)
-}
-
-fn parse_socks_proxy(proxy: &Url) -> Option<((String, u16), SocksVersion<'_>)> {
-    let scheme = proxy.scheme();
-    let socks_version = if scheme.starts_with("socks4") {
-        let identification = match proxy.username() {
-            "" => None,
-            username => Some(Socks4Identification { user_id: username }),
-        };
-        SocksVersion::V4(identification)
-    } else if scheme.starts_with("socks") {
-        let authorization = proxy.password().map(|password| Socks5Authorization {
-            username: proxy.username(),
-            password,
-        });
-        SocksVersion::V5(authorization)
-    } else {
-        return None;
-    };
-
-    let host = proxy.host()?.to_string();
-    let port = proxy.port_or_known_default()?;
-
-    Some(((host, port), socks_version))
-}
-
-pub(crate) trait AsyncReadWrite:
-    tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static
-{
-}
-impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static> AsyncReadWrite
-    for T
-{
-}
-
-#[cfg(test)]
-mod tests {
-    use url::Url;
-
-    use super::*;
-
-    #[test]
-    fn parse_socks4() {
-        let proxy = Url::parse("socks4://proxy.example.com:1080").unwrap();
-
-        let ((host, port), version) = parse_socks_proxy(&proxy).unwrap();
-        assert_eq!(host, "proxy.example.com");
-        assert_eq!(port, 1080);
-        assert!(matches!(version, SocksVersion::V4(None)))
-    }
-
-    #[test]
-    fn parse_socks4_with_identification() {
-        let proxy = Url::parse("socks4://userid@proxy.example.com:1080").unwrap();
-
-        let ((host, port), version) = parse_socks_proxy(&proxy).unwrap();
-        assert_eq!(host, "proxy.example.com");
-        assert_eq!(port, 1080);
-        assert!(matches!(
-            version,
-            SocksVersion::V4(Some(Socks4Identification { user_id: "userid" }))
-        ))
-    }
-
-    #[test]
-    fn parse_socks5() {
-        let proxy = Url::parse("socks5://proxy.example.com:1080").unwrap();
-
-        let ((host, port), version) = parse_socks_proxy(&proxy).unwrap();
-        assert_eq!(host, "proxy.example.com");
-        assert_eq!(port, 1080);
-        assert!(matches!(version, SocksVersion::V5(None)))
-    }
-
-    #[test]
-    fn parse_socks5_with_authorization() {
-        let proxy = Url::parse("socks5://username:password@proxy.example.com:1080").unwrap();
-
-        let ((host, port), version) = parse_socks_proxy(&proxy).unwrap();
-        assert_eq!(host, "proxy.example.com");
-        assert_eq!(port, 1080);
-        assert!(matches!(
-            version,
-            SocksVersion::V5(Some(Socks5Authorization {
-                username: "username",
-                password: "password"
-            }))
-        ))
-    }
-
-    /// If parsing the proxy URL fails, we must avoid falling back to an insecure connection.
-    /// SOCKS proxies are often used in contexts where security and privacy are critical,
-    /// so any fallback could expose users to significant risks.
-    #[tokio::test]
-    async fn fails_on_bad_proxy() {
-        // Should fail connecting because http is not a valid Socks proxy scheme
-        let proxy = Url::parse("http://localhost:2313").unwrap();
-
-        let result = connect_socks_proxy_stream(&proxy, ("test", 1080)).await;
-        match result {
-            Err(e) => assert_eq!(e.to_string(), "Parsing proxy url failed"),
-            Ok(_) => panic!("Connecting on bad proxy should fail"),
-        };
-    }
-}

crates/client/src/telemetry.rs 🔗

@@ -8,10 +8,11 @@ use futures::{Future, FutureExt, StreamExt};
 use gpui::{App, AppContext as _, BackgroundExecutor, Task};
 use http_client::{self, AsyncBody, HttpClient, HttpClientWithUrl, Method, Request};
 use parking_lot::Mutex;
+use regex::Regex;
 use release_channel::ReleaseChannel;
 use settings::{Settings, SettingsStore};
 use sha2::{Digest, Sha256};
-use std::collections::{HashMap, HashSet};
+use std::collections::HashSet;
 use std::fs::File;
 use std::io::Write;
 use std::sync::LazyLock;
@@ -45,31 +46,13 @@ struct TelemetryState {
     first_event_date_time: Option<Instant>,
     event_coalescer: EventCoalescer,
     max_queue_size: usize,
-    worktree_id_map: WorktreeIdMap,
+    worktrees_with_project_type_events_sent: HashSet<WorktreeId>,
 
     os_name: String,
     app_version: String,
     os_version: Option<String>,
 }
 
-#[derive(Debug)]
-struct WorktreeIdMap(HashMap<String, ProjectCache>);
-
-#[derive(Debug)]
-struct ProjectCache {
-    name: String,
-    worktree_ids_reported: HashSet<WorktreeId>,
-}
-
-impl ProjectCache {
-    fn new(name: String) -> Self {
-        Self {
-            name,
-            worktree_ids_reported: HashSet::default(),
-        }
-    }
-}
-
 #[cfg(debug_assertions)]
 const MAX_QUEUE_LEN: usize = 5;
 
@@ -91,15 +74,23 @@ static ZED_CLIENT_CHECKSUM_SEED: LazyLock<Option<Vec<u8>>> = LazyLock::new(|| {
         })
 });
 
+static DOTNET_PROJECT_FILES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
+    Regex::new(r"^(global\.json|Directory\.Build\.props|.*\.(csproj|fsproj|vbproj|sln))$").unwrap()
+});
+
 pub fn os_name() -> String {
     #[cfg(target_os = "macos")]
     {
         "macOS".to_string()
     }
-    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+    #[cfg(target_os = "linux")]
     {
         format!("Linux {}", gpui::guess_compositor())
     }
+    #[cfg(target_os = "freebsd")]
+    {
+        format!("FreeBSD {}", gpui::guess_compositor())
+    }
 
     #[cfg(target_os = "windows")]
     {
@@ -133,22 +124,22 @@ pub fn os_version() -> String {
             file
         } else if let Ok(file) = std::fs::read_to_string(&Path::new("/usr/lib/os-release")) {
             file
+        } else if let Ok(file) = std::fs::read_to_string(&Path::new("/var/run/os-release")) {
+            file
         } else {
-            log::error!("Failed to load /etc/os-release, /usr/lib/os-release");
+            log::error!(
+                "Failed to load /etc/os-release, /usr/lib/os-release, or /var/run/os-release"
+            );
             "".to_string()
         };
-        let mut name = "unknown".to_string();
-        let mut version = "unknown".to_string();
+        let mut name = "unknown";
+        let mut version = "unknown";
 
         for line in content.lines() {
-            if line.starts_with("ID=") {
-                name = line.trim_start_matches("ID=").trim_matches('"').to_string();
-            }
-            if line.starts_with("VERSION_ID=") {
-                version = line
-                    .trim_start_matches("VERSION_ID=")
-                    .trim_matches('"')
-                    .to_string();
+            match line.split_once('=') {
+                Some(("ID", val)) => name = val.trim_matches('"'),
+                Some(("VERSION_ID", val)) => version = val.trim_matches('"'),
+                _ => {}
             }
         }
 
@@ -198,20 +189,7 @@ impl Telemetry {
             first_event_date_time: None,
             event_coalescer: EventCoalescer::new(clock.clone()),
             max_queue_size: MAX_QUEUE_LEN,
-            worktree_id_map: WorktreeIdMap(HashMap::from_iter([
-                (
-                    "pnpm-lock.yaml".to_string(),
-                    ProjectCache::new("pnpm".to_string()),
-                ),
-                (
-                    "yarn.lock".to_string(),
-                    ProjectCache::new("yarn".to_string()),
-                ),
-                (
-                    "package.json".to_string(),
-                    ProjectCache::new("node".to_string()),
-                ),
-            ])),
+            worktrees_with_project_type_events_sent: HashSet::new(),
 
             os_version: None,
             os_name: os_name(),
@@ -222,7 +200,7 @@ impl Telemetry {
         cx.background_spawn({
             let state = state.clone();
             let os_version = os_version();
-            state.lock().os_version = Some(os_version.clone());
+            state.lock().os_version = Some(os_version);
             async move {
                 if let Some(tempfile) = File::create(Self::log_file_path()).log_err() {
                     state.lock().log_file = Some(tempfile);
@@ -369,55 +347,74 @@ impl Telemetry {
             telemetry::event!(
                 "Editor Edited",
                 duration = duration,
-                environment = environment.to_string(),
+                environment = environment,
                 is_via_ssh = is_via_ssh
             );
         }
     }
 
-    pub fn report_discovered_project_events(
+    pub fn report_discovered_project_type_events(
         self: &Arc<Self>,
         worktree_id: WorktreeId,
         updated_entries_set: &UpdatedEntriesSet,
     ) {
-        let project_type_names: Vec<String> = {
-            let mut state = self.state.lock();
-            state
-                .worktree_id_map
-                .0
-                .iter_mut()
-                .filter_map(|(project_file_name, project_type_telemetry)| {
-                    if project_type_telemetry
-                        .worktree_ids_reported
-                        .contains(&worktree_id)
-                    {
-                        return None;
-                    }
+        let Some(project_type_names) = self.detect_project_types(worktree_id, updated_entries_set)
+        else {
+            return;
+        };
 
-                    let project_file_found = updated_entries_set.iter().any(|(path, _, _)| {
-                        path.as_ref()
-                            .file_name()
-                            .and_then(|name| name.to_str())
-                            .map(|name_str| name_str == project_file_name)
-                            .unwrap_or(false)
-                    });
+        for project_type_name in project_type_names {
+            telemetry::event!("Project Opened", project_type = project_type_name);
+        }
+    }
 
-                    if !project_file_found {
-                        return None;
-                    }
+    fn detect_project_types(
+        self: &Arc<Self>,
+        worktree_id: WorktreeId,
+        updated_entries_set: &UpdatedEntriesSet,
+    ) -> Option<Vec<String>> {
+        let mut state = self.state.lock();
 
-                    project_type_telemetry
-                        .worktree_ids_reported
-                        .insert(worktree_id);
+        if state
+            .worktrees_with_project_type_events_sent
+            .contains(&worktree_id)
+        {
+            return None;
+        }
 
-                    Some(project_type_telemetry.name.clone())
-                })
-                .collect()
-        };
+        let mut project_types: HashSet<&str> = HashSet::new();
 
-        for project_type_name in project_type_names {
-            telemetry::event!("Project Opened", project_type = project_type_name);
+        for (path, _, _) in updated_entries_set.iter() {
+            let Some(file_name) = path.file_name().and_then(|f| f.to_str()) else {
+                continue;
+            };
+
+            let project_type = if file_name == "pnpm-lock.yaml" {
+                Some("pnpm")
+            } else if file_name == "yarn.lock" {
+                Some("yarn")
+            } else if file_name == "package.json" {
+                Some("node")
+            } else if DOTNET_PROJECT_FILES_REGEX.is_match(file_name) {
+                Some("dotnet")
+            } else {
+                None
+            };
+
+            if let Some(project_type) = project_type {
+                project_types.insert(project_type);
+            };
         }
+
+        if !project_types.is_empty() {
+            state
+                .worktrees_with_project_type_events_sent
+                .insert(worktree_id);
+        }
+
+        let mut project_types: Vec<_> = project_types.into_iter().map(String::from).collect();
+        project_types.sort();
+        Some(project_types)
     }
 
     fn report_event(self: &Arc<Self>, event: Event) {
@@ -431,9 +428,8 @@ impl Telemetry {
 
         if state.flush_events_task.is_none() {
             let this = self.clone();
-            let executor = self.executor.clone();
             state.flush_events_task = Some(self.executor.spawn(async move {
-                executor.timer(FLUSH_INTERVAL).await;
+                this.executor.timer(FLUSH_INTERVAL).await;
                 this.flush_events().detach();
             }));
         }
@@ -484,12 +480,12 @@ impl Telemetry {
         self: &Arc<Self>,
         // We take in the JSON bytes buffer so we can reuse the existing allocation.
         mut json_bytes: Vec<u8>,
-        event_request: EventRequestBody,
+        event_request: &EventRequestBody,
     ) -> Result<Request<AsyncBody>> {
         json_bytes.clear();
-        serde_json::to_writer(&mut json_bytes, &event_request)?;
+        serde_json::to_writer(&mut json_bytes, event_request)?;
 
-        let checksum = calculate_json_checksum(&json_bytes).unwrap_or("".to_string());
+        let checksum = calculate_json_checksum(&json_bytes).unwrap_or_default();
 
         Ok(Request::builder()
             .method(Method::POST)
@@ -506,7 +502,7 @@ impl Telemetry {
     pub fn flush_events(self: &Arc<Self>) -> Task<()> {
         let mut state = self.state.lock();
         state.first_event_date_time = None;
-        let mut events = mem::take(&mut state.events_queue);
+        let events = mem::take(&mut state.events_queue);
         state.flush_events_task.take();
         drop(state);
         if events.is_empty() {
@@ -519,7 +515,7 @@ impl Telemetry {
                 let mut json_bytes = Vec::new();
 
                 if let Some(file) = &mut this.state.lock().log_file {
-                    for event in &mut events {
+                    for event in &events {
                         json_bytes.clear();
                         serde_json::to_writer(&mut json_bytes, event)?;
                         file.write_all(&json_bytes)?;
@@ -546,7 +542,7 @@ impl Telemetry {
                     }
                 };
 
-                let request = this.build_request(json_bytes, request_body)?;
+                let request = this.build_request(json_bytes, &request_body)?;
                 let response = this.http_client.send(request).await?;
                 if response.status() != 200 {
                     log::error!("Failed to send events: HTTP {:?}", response.status());
@@ -583,7 +579,9 @@ mod tests {
     use clock::FakeSystemClock;
     use gpui::TestAppContext;
     use http_client::FakeHttpClient;
+    use std::collections::HashMap;
     use telemetry_events::FlexibleEvent;
+    use worktree::{PathChange, ProjectEntryId, WorktreeId};
 
     #[gpui::test]
     fn test_telemetry_flush_on_max_queue_size(cx: &mut TestAppContext) {
@@ -701,6 +699,115 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    fn test_project_discovery_does_not_double_report(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+
+        let clock = Arc::new(FakeSystemClock::new());
+        let http = FakeHttpClient::with_200_response();
+        let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
+        let worktree_id = 1;
+
+        // Scan of empty worktree finds nothing
+        test_project_discovery_helper(telemetry.clone(), vec![], Some(vec![]), worktree_id);
+
+        // Files added, second scan of worktree 1 finds project type
+        test_project_discovery_helper(
+            telemetry.clone(),
+            vec!["package.json"],
+            Some(vec!["node"]),
+            worktree_id,
+        );
+
+        // Third scan of worktree does not double report, as we already reported
+        test_project_discovery_helper(telemetry.clone(), vec!["package.json"], None, worktree_id);
+    }
+
+    #[gpui::test]
+    fn test_pnpm_project_discovery(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+
+        let clock = Arc::new(FakeSystemClock::new());
+        let http = FakeHttpClient::with_200_response();
+        let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
+
+        test_project_discovery_helper(
+            telemetry.clone(),
+            vec!["package.json", "pnpm-lock.yaml"],
+            Some(vec!["node", "pnpm"]),
+            1,
+        );
+    }
+
+    #[gpui::test]
+    fn test_yarn_project_discovery(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+
+        let clock = Arc::new(FakeSystemClock::new());
+        let http = FakeHttpClient::with_200_response();
+        let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
+
+        test_project_discovery_helper(
+            telemetry.clone(),
+            vec!["package.json", "yarn.lock"],
+            Some(vec!["node", "yarn"]),
+            1,
+        );
+    }
+
+    #[gpui::test]
+    fn test_dotnet_project_discovery(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+
+        let clock = Arc::new(FakeSystemClock::new());
+        let http = FakeHttpClient::with_200_response();
+        let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
+
+        // Using different worktrees, as production code blocks from reporting a
+        // project type for the same worktree multiple times
+
+        test_project_discovery_helper(
+            telemetry.clone().clone(),
+            vec!["global.json"],
+            Some(vec!["dotnet"]),
+            1,
+        );
+        test_project_discovery_helper(
+            telemetry.clone(),
+            vec!["Directory.Build.props"],
+            Some(vec!["dotnet"]),
+            2,
+        );
+        test_project_discovery_helper(
+            telemetry.clone(),
+            vec!["file.csproj"],
+            Some(vec!["dotnet"]),
+            3,
+        );
+        test_project_discovery_helper(
+            telemetry.clone(),
+            vec!["file.fsproj"],
+            Some(vec!["dotnet"]),
+            4,
+        );
+        test_project_discovery_helper(
+            telemetry.clone(),
+            vec!["file.vbproj"],
+            Some(vec!["dotnet"]),
+            5,
+        );
+        test_project_discovery_helper(telemetry.clone(), vec!["file.sln"], Some(vec!["dotnet"]), 6);
+
+        // Each worktree should only send a single project type event, even when
+        // encountering multiple files associated with that project type
+        test_project_discovery_helper(
+            telemetry,
+            vec!["global.json", "Directory.Build.props"],
+            Some(vec!["dotnet"]),
+            7,
+        );
+    }
+
     // TODO:
     // Test settings
     // Update FakeHTTPClient to keep track of the number of requests and assert on it
@@ -717,4 +824,32 @@ mod tests {
             && telemetry.state.lock().flush_events_task.is_none()
             && telemetry.state.lock().first_event_date_time.is_none()
     }
+
+    fn test_project_discovery_helper(
+        telemetry: Arc<Telemetry>,
+        file_paths: Vec<&str>,
+        expected_project_types: Option<Vec<&str>>,
+        worktree_id_num: usize,
+    ) {
+        let worktree_id = WorktreeId::from_usize(worktree_id_num);
+        let entries: Vec<_> = file_paths
+            .into_iter()
+            .enumerate()
+            .map(|(i, path)| {
+                (
+                    Arc::from(std::path::Path::new(path)),
+                    ProjectEntryId::from_proto(i as u64 + 1),
+                    PathChange::Added,
+                )
+            })
+            .collect();
+        let updated_entries: UpdatedEntriesSet = Arc::from(entries.as_slice());
+
+        let detected_project_types = telemetry.detect_project_types(worktree_id, &updated_entries);
+
+        let expected_project_types =
+            expected_project_types.map(|types| types.iter().map(|&t| t.to_string()).collect());
+
+        assert_eq!(detected_project_types, expected_project_types);
+    }
 }

crates/client/src/test.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{Client, Connection, Credentials, EstablishConnectionError, UserStore};
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use chrono::Duration;
 use futures::{StreamExt, stream::BoxStream};
 use gpui::{AppContext as _, BackgroundExecutor, Entity, TestAppContext};
@@ -45,7 +45,7 @@ impl FakeServer {
                 move |cx| {
                     let state = state.clone();
                     cx.spawn(async move |_| {
-                        let state = state.upgrade().ok_or_else(|| anyhow!("server dropped"))?;
+                        let state = state.upgrade().context("server dropped")?;
                         let mut state = state.lock();
                         state.auth_count += 1;
                         let access_token = state.access_token.to_string();
@@ -64,8 +64,8 @@ impl FakeServer {
                     let state = state.clone();
                     let credentials = credentials.clone();
                     cx.spawn(async move |cx| {
-                        let state = state.upgrade().ok_or_else(|| anyhow!("server dropped"))?;
-                        let peer = peer.upgrade().ok_or_else(|| anyhow!("server dropped"))?;
+                        let state = state.upgrade().context("server dropped")?;
+                        let peer = peer.upgrade().context("server dropped")?;
                         if state.lock().forbid_connections {
                             Err(EstablishConnectionError::Other(anyhow!(
                                 "server is forbidding connections"
@@ -155,7 +155,7 @@ impl FakeServer {
                 .expect("not connected")
                 .next()
                 .await
-                .ok_or_else(|| anyhow!("other half hung up"))?;
+                .context("other half hung up")?;
             self.executor.finish_waiting();
             let type_name = message.payload_type_name();
             let message = message.into_any();

crates/client/src/user.rs 🔗

@@ -2,16 +2,25 @@ use super::{Client, Status, TypedEnvelope, proto};
 use anyhow::{Context as _, Result, anyhow};
 use chrono::{DateTime, Utc};
 use collections::{HashMap, HashSet, hash_map::Entry};
+use derive_more::Deref;
 use feature_flags::FeatureFlagAppExt;
 use futures::{Future, StreamExt, channel::mpsc};
 use gpui::{
     App, AsyncApp, Context, Entity, EventEmitter, SharedString, SharedUri, Task, WeakEntity,
 };
+use http_client::http::{HeaderMap, HeaderValue};
 use postage::{sink::Sink, watch};
 use rpc::proto::{RequestMessage, UsersResponse};
-use std::sync::{Arc, Weak};
+use std::{
+    str::FromStr as _,
+    sync::{Arc, Weak},
+};
 use text::ReplicaId;
 use util::{TryFutureExt as _, maybe};
+use zed_llm_client::{
+    EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME,
+    MODEL_REQUESTS_USAGE_AMOUNT_HEADER_NAME, MODEL_REQUESTS_USAGE_LIMIT_HEADER_NAME, UsageLimit,
+};
 
 pub type UserId = u64;
 
@@ -49,7 +58,6 @@ pub struct User {
     pub github_login: String,
     pub avatar_uri: SharedUri,
     pub name: Option<String>,
-    pub email: Option<String>,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq)]
@@ -58,6 +66,8 @@ pub struct Collaborator {
     pub replica_id: ReplicaId,
     pub user_id: UserId,
     pub is_host: bool,
+    pub committer_name: Option<String>,
+    pub committer_email: Option<String>,
 }
 
 impl PartialOrd for User {
@@ -103,11 +113,11 @@ pub struct UserStore {
     current_plan: Option<proto::Plan>,
     subscription_period: Option<(DateTime<Utc>, DateTime<Utc>)>,
     trial_started_at: Option<DateTime<Utc>>,
-    model_request_usage_amount: Option<u32>,
-    model_request_usage_limit: Option<proto::UsageLimit>,
-    edit_predictions_usage_amount: Option<u32>,
-    edit_predictions_usage_limit: Option<proto::UsageLimit>,
+    model_request_usage: Option<ModelRequestUsage>,
+    edit_prediction_usage: Option<EditPredictionUsage>,
     is_usage_based_billing_enabled: Option<bool>,
+    account_too_young: Option<bool>,
+    has_overdue_invoices: Option<bool>,
     current_user: watch::Receiver<Option<Arc<User>>>,
     accepted_tos_at: Option<Option<DateTime<Utc>>>,
     contacts: Vec<Arc<Contact>>,
@@ -152,6 +162,18 @@ enum UpdateContacts {
     Clear(postage::barrier::Sender),
 }
 
+#[derive(Debug, Clone, Copy, Deref)]
+pub struct ModelRequestUsage(pub RequestUsage);
+
+#[derive(Debug, Clone, Copy, Deref)]
+pub struct EditPredictionUsage(pub RequestUsage);
+
+#[derive(Debug, Clone, Copy)]
+pub struct RequestUsage {
+    pub limit: UsageLimit,
+    pub amount: i32,
+}
+
 impl UserStore {
     pub fn new(client: Arc<Client>, cx: &Context<Self>) -> Self {
         let (mut current_user_tx, current_user_rx) = watch::channel();
@@ -169,11 +191,11 @@ impl UserStore {
             current_plan: None,
             subscription_period: None,
             trial_started_at: None,
-            model_request_usage_amount: None,
-            model_request_usage_limit: None,
-            edit_predictions_usage_amount: None,
-            edit_predictions_usage_limit: None,
+            model_request_usage: None,
+            edit_prediction_usage: None,
             is_usage_based_billing_enabled: None,
+            account_too_young: None,
+            has_overdue_invoices: None,
             accepted_tos_at: None,
             contacts: Default::default(),
             incoming_contact_requests: Default::default(),
@@ -320,7 +342,7 @@ impl UserStore {
         message: TypedEnvelope<proto::UpdateContacts>,
         mut cx: AsyncApp,
     ) -> Result<()> {
-        this.update(&mut cx, |this, _| {
+        this.read_with(&mut cx, |this, _| {
             this.update_contacts_tx
                 .unbounded_send(UpdateContacts::Update(message.payload))
                 .unwrap();
@@ -347,12 +369,23 @@ impl UserStore {
                 .trial_started_at
                 .and_then(|trial_started_at| DateTime::from_timestamp(trial_started_at as i64, 0));
             this.is_usage_based_billing_enabled = message.payload.is_usage_based_billing_enabled;
+            this.account_too_young = message.payload.account_too_young;
+            this.has_overdue_invoices = message.payload.has_overdue_invoices;
 
             if let Some(usage) = message.payload.usage {
-                this.model_request_usage_amount = Some(usage.model_requests_usage_amount);
-                this.model_request_usage_limit = usage.model_requests_usage_limit;
-                this.edit_predictions_usage_amount = Some(usage.edit_predictions_usage_amount);
-                this.edit_predictions_usage_limit = usage.edit_predictions_usage_limit;
+                // limits are always present even though they are wrapped in Option
+                this.model_request_usage = usage
+                    .model_requests_usage_limit
+                    .and_then(|limit| {
+                        RequestUsage::from_proto(usage.model_requests_usage_amount, limit)
+                    })
+                    .map(ModelRequestUsage);
+                this.edit_prediction_usage = usage
+                    .edit_predictions_usage_limit
+                    .and_then(|limit| {
+                        RequestUsage::from_proto(usage.model_requests_usage_amount, limit)
+                    })
+                    .map(EditPredictionUsage);
             }
 
             cx.notify();
@@ -360,6 +393,20 @@ impl UserStore {
         Ok(())
     }
 
+    pub fn update_model_request_usage(&mut self, usage: ModelRequestUsage, cx: &mut Context<Self>) {
+        self.model_request_usage = Some(usage);
+        cx.notify();
+    }
+
+    pub fn update_edit_prediction_usage(
+        &mut self,
+        usage: EditPredictionUsage,
+        cx: &mut Context<Self>,
+    ) {
+        self.edit_prediction_usage = Some(usage);
+        cx.notify();
+    }
+
     fn update_contacts(&mut self, message: UpdateContacts, cx: &Context<Self>) -> Task<Result<()>> {
         match message {
             UpdateContacts::Wait(barrier) => {
@@ -388,9 +435,7 @@ impl UserStore {
                     // Users are fetched in parallel above and cached in call to get_users
                     // No need to parallelize here
                     let mut updated_contacts = Vec::new();
-                    let this = this
-                        .upgrade()
-                        .ok_or_else(|| anyhow!("can't upgrade user store handle"))?;
+                    let this = this.upgrade().context("can't upgrade user store handle")?;
                     for contact in message.contacts {
                         updated_contacts
                             .push(Arc::new(Contact::from_proto(contact, &this, cx).await?));
@@ -574,7 +619,7 @@ impl UserStore {
         let client = self.client.upgrade();
         cx.spawn(async move |_, _| {
             client
-                .ok_or_else(|| anyhow!("can't upgrade client reference"))?
+                .context("can't upgrade client reference")?
                 .request(proto::RespondToContactRequest {
                     requester_id,
                     response: proto::ContactRequestResponse::Dismiss as i32,
@@ -596,7 +641,7 @@ impl UserStore {
 
         cx.spawn(async move |this, cx| {
             let response = client
-                .ok_or_else(|| anyhow!("can't upgrade client reference"))?
+                .context("can't upgrade client reference")?
                 .request(request)
                 .await;
             this.update(cx, |this, cx| {
@@ -656,14 +701,14 @@ impl UserStore {
                 .await?;
             }
 
-            this.update(cx, |this, _| {
+            this.read_with(cx, |this, _| {
                 user_ids
                     .iter()
                     .map(|user_id| {
                         this.users
                             .get(user_id)
                             .cloned()
-                            .ok_or_else(|| anyhow!("user {} not found", user_id))
+                            .with_context(|| format!("user {user_id} not found"))
                     })
                     .collect()
             })?
@@ -699,11 +744,11 @@ impl UserStore {
         let load_users = self.get_users(vec![user_id], cx);
         cx.spawn(async move |this, cx| {
             load_users.await?;
-            this.update(cx, |this, _| {
+            this.read_with(cx, |this, _| {
                 this.users
                     .get(&user_id)
                     .cloned()
-                    .ok_or_else(|| anyhow!("server responded with no users"))
+                    .context("server responded with no users")
             })?
         })
     }
@@ -734,24 +779,26 @@ impl UserStore {
         self.is_usage_based_billing_enabled
     }
 
-    pub fn model_request_usage_amount(&self) -> Option<u32> {
-        self.model_request_usage_amount
+    pub fn model_request_usage(&self) -> Option<ModelRequestUsage> {
+        self.model_request_usage
     }
 
-    pub fn model_request_usage_limit(&self) -> Option<proto::UsageLimit> {
-        self.model_request_usage_limit.clone()
+    pub fn edit_prediction_usage(&self) -> Option<EditPredictionUsage> {
+        self.edit_prediction_usage
     }
 
-    pub fn edit_predictions_usage_amount(&self) -> Option<u32> {
-        self.edit_predictions_usage_amount
+    pub fn watch_current_user(&self) -> watch::Receiver<Option<Arc<User>>> {
+        self.current_user.clone()
     }
 
-    pub fn edit_predictions_usage_limit(&self) -> Option<proto::UsageLimit> {
-        self.edit_predictions_usage_limit.clone()
+    /// Returns whether the user's account is too new to use the service.
+    pub fn account_too_young(&self) -> bool {
+        self.account_too_young.unwrap_or(false)
     }
 
-    pub fn watch_current_user(&self) -> watch::Receiver<Option<Arc<User>>> {
-        self.current_user.clone()
+    /// Returns whether the current user has overdue invoices and usage should be blocked.
+    pub fn has_overdue_invoices(&self) -> bool {
+        self.has_overdue_invoices.unwrap_or(false)
     }
 
     pub fn current_user_has_accepted_terms(&self) -> Option<bool> {
@@ -765,20 +812,17 @@ impl UserStore {
         };
 
         let client = self.client.clone();
-        cx.spawn(async move |this, cx| {
-            if let Some(client) = client.upgrade() {
-                let response = client
-                    .request(proto::AcceptTermsOfService {})
-                    .await
-                    .context("error accepting tos")?;
-
-                this.update(cx, |this, cx| {
-                    this.set_current_user_accepted_tos_at(Some(response.accepted_tos_at));
-                    cx.emit(Event::PrivateUserInfoUpdated);
-                })
-            } else {
-                Err(anyhow!("client not found"))
-            }
+        cx.spawn(async move |this, cx| -> anyhow::Result<()> {
+            let client = client.upgrade().context("client not found")?;
+            let response = client
+                .request(proto::AcceptTermsOfService {})
+                .await
+                .context("error accepting tos")?;
+            this.update(cx, |this, cx| {
+                this.set_current_user_accepted_tos_at(Some(response.accepted_tos_at));
+                cx.emit(Event::PrivateUserInfoUpdated);
+            })?;
+            Ok(())
         })
     }
 
@@ -870,7 +914,6 @@ impl User {
             github_login: message.github_login,
             avatar_uri: message.avatar_url.into(),
             name: message.name,
-            email: message.email,
         })
     }
 }
@@ -897,10 +940,72 @@ impl Contact {
 impl Collaborator {
     pub fn from_proto(message: proto::Collaborator) -> Result<Self> {
         Ok(Self {
-            peer_id: message.peer_id.ok_or_else(|| anyhow!("invalid peer id"))?,
+            peer_id: message.peer_id.context("invalid peer id")?,
             replica_id: message.replica_id as ReplicaId,
             user_id: message.user_id as UserId,
             is_host: message.is_host,
+            committer_name: message.committer_name,
+            committer_email: message.committer_email,
+        })
+    }
+}
+
+impl RequestUsage {
+    pub fn over_limit(&self) -> bool {
+        match self.limit {
+            UsageLimit::Limited(limit) => self.amount >= limit,
+            UsageLimit::Unlimited => false,
+        }
+    }
+
+    pub fn from_proto(amount: u32, limit: proto::UsageLimit) -> Option<Self> {
+        let limit = match limit.variant? {
+            proto::usage_limit::Variant::Limited(limited) => {
+                UsageLimit::Limited(limited.limit as i32)
+            }
+            proto::usage_limit::Variant::Unlimited(_) => UsageLimit::Unlimited,
+        };
+        Some(RequestUsage {
+            limit,
+            amount: amount as i32,
         })
     }
+
+    fn from_headers(
+        limit_name: &str,
+        amount_name: &str,
+        headers: &HeaderMap<HeaderValue>,
+    ) -> Result<Self> {
+        let limit = headers
+            .get(limit_name)
+            .with_context(|| format!("missing {limit_name:?} header"))?;
+        let limit = UsageLimit::from_str(limit.to_str()?)?;
+
+        let amount = headers
+            .get(amount_name)
+            .with_context(|| format!("missing {amount_name:?} header"))?;
+        let amount = amount.to_str()?.parse::<i32>()?;
+
+        Ok(Self { limit, amount })
+    }
+}
+
+impl ModelRequestUsage {
+    pub fn from_headers(headers: &HeaderMap<HeaderValue>) -> Result<Self> {
+        Ok(Self(RequestUsage::from_headers(
+            MODEL_REQUESTS_USAGE_LIMIT_HEADER_NAME,
+            MODEL_REQUESTS_USAGE_AMOUNT_HEADER_NAME,
+            headers,
+        )?))
+    }
+}
+
+impl EditPredictionUsage {
+    pub fn from_headers(headers: &HeaderMap<HeaderValue>) -> Result<Self> {
+        Ok(Self(RequestUsage::from_headers(
+            EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME,
+            EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME,
+            headers,
+        )?))
+    }
 }

crates/collab/Cargo.toml 🔗

@@ -20,6 +20,7 @@ test-support = ["sqlite"]
 [dependencies]
 anyhow.workspace = true
 async-stripe.workspace = true
+async-trait.workspace = true
 async-tungstenite.workspace = true
 aws-config = { version = "1.1.5" }
 aws-sdk-s3 = { version = "1.15.0" }
@@ -76,10 +77,9 @@ workspace-hack.workspace = true
 zed_llm_client.workspace = true
 
 [dev-dependencies]
-assistant_context_editor.workspace = true
-assistant_settings.workspace = true
+agent_settings.workspace = true
+assistant_context.workspace = true
 assistant_slash_command.workspace = true
-assistant_tool.workspace = true
 async-trait.workspace = true
 audio.workspace = true
 buffer_diff.workspace = true
@@ -92,9 +92,9 @@ command_palette_hooks.workspace = true
 context_server.workspace = true
 ctor.workspace = true
 dap = { workspace = true, features = ["test-support"] }
+dap_adapters = { workspace = true, features = ["test-support"] }
 debugger_ui = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
-env_logger.workspace = true
 extension.workspace = true
 file_finder.workspace = true
 fs = { workspace = true, features = ["test-support"] }
@@ -132,6 +132,7 @@ unindent.workspace = true
 util.workspace = true
 workspace = { workspace = true, features = ["test-support"] }
 worktree = { workspace = true, features = ["test-support"] }
+zlog.workspace = true
 
 [package.metadata.cargo-machete]
 ignored = ["async-stripe"]

crates/collab/README.md 🔗

@@ -57,7 +57,7 @@ We run two instances of collab:
 
 Both of these run on the Kubernetes cluster hosted in Digital Ocean.
 
-Deployment is triggered by pushing to the `collab-staging` (or `collab-production`) tag in Github. The best way to do this is:
+Deployment is triggered by pushing to the `collab-staging` (or `collab-production`) tag in GitHub. The best way to do this is:
 
 - `./script/deploy-collab staging`
 - `./script/deploy-collab production`

crates/collab/k8s/environments/production.sh 🔗

@@ -2,5 +2,5 @@ ZED_ENVIRONMENT=production
 RUST_LOG=info
 INVITE_LINK_PREFIX=https://zed.dev/invites/
 AUTO_JOIN_CHANNEL_ID=283
-DATABASE_MAX_CONNECTIONS=85
+DATABASE_MAX_CONNECTIONS=250
 LLM_DATABASE_MAX_CONNECTIONS=25

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -185,7 +185,9 @@ CREATE TABLE "project_collaborators" (
     "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
     "user_id" INTEGER NOT NULL,
     "replica_id" INTEGER NOT NULL,
-    "is_host" BOOLEAN NOT NULL
+    "is_host" BOOLEAN NOT NULL,
+    "committer_name" VARCHAR,
+    "committer_email" VARCHAR
 );
 
 CREATE INDEX "index_project_collaborators_on_project_id" ON "project_collaborators" ("project_id");
@@ -266,11 +268,14 @@ CREATE TABLE "channels" (
     "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
     "visibility" VARCHAR NOT NULL,
     "parent_path" TEXT NOT NULL,
-    "requires_zed_cla" BOOLEAN NOT NULL DEFAULT FALSE
+    "requires_zed_cla" BOOLEAN NOT NULL DEFAULT FALSE,
+    "channel_order" INTEGER NOT NULL DEFAULT 1
 );
 
 CREATE INDEX "index_channels_on_parent_path" ON "channels" ("parent_path");
 
+CREATE INDEX "index_channels_on_parent_path_and_order" ON "channels" ("parent_path", "channel_order");
+
 CREATE TABLE IF NOT EXISTS "channel_chat_participants" (
     "id" INTEGER PRIMARY KEY AUTOINCREMENT,
     "user_id" INTEGER NOT NULL REFERENCES users (id),
@@ -460,6 +465,7 @@ CREATE TABLE extension_versions (
     provides_slash_commands BOOLEAN NOT NULL DEFAULT FALSE,
     provides_indexed_docs_providers BOOLEAN NOT NULL DEFAULT FALSE,
     provides_snippets BOOLEAN NOT NULL DEFAULT FALSE,
+    provides_debug_adapters BOOLEAN NOT NULL DEFAULT FALSE,
     PRIMARY KEY (extension_id, version)
 );
 

crates/collab/migrations/20250530175450_add_channel_order.sql 🔗

@@ -0,0 +1,16 @@
+-- Add channel_order column to channels table with default value
+ALTER TABLE channels ADD COLUMN channel_order INTEGER NOT NULL DEFAULT 1;
+
+-- Update channel_order for existing channels using ROW_NUMBER for deterministic ordering
+UPDATE channels
+SET channel_order = (
+    SELECT ROW_NUMBER() OVER (
+        PARTITION BY parent_path
+        ORDER BY name, id
+    )
+    FROM channels c2
+    WHERE c2.id = channels.id
+);
+
+-- Create index for efficient ordering queries
+CREATE INDEX "index_channels_on_parent_path_and_order" ON "channels" ("parent_path", "channel_order");

crates/collab/src/api.rs 🔗

@@ -5,12 +5,13 @@ pub mod extensions;
 pub mod ips_file;
 pub mod slack;
 
+use crate::db::Database;
 use crate::{
     AppState, Error, Result, auth,
     db::{User, UserId},
     rpc,
 };
-use anyhow::anyhow;
+use anyhow::Context as _;
 use axum::{
     Extension, Json, Router,
     body::Body,
@@ -96,7 +97,8 @@ impl std::fmt::Display for SystemIdHeader {
 
 pub fn routes(rpc_server: Arc<rpc::Server>) -> Router<(), Body> {
     Router::new()
-        .route("/user", get(get_authenticated_user))
+        .route("/user", get(update_or_create_authenticated_user))
+        .route("/users/look_up", get(look_up_user))
         .route("/users/:id/access_tokens", post(create_access_token))
         .route("/rpc_server_snapshot", get(get_rpc_server_snapshot))
         .merge(billing::router())
@@ -155,7 +157,7 @@ struct AuthenticatedUserResponse {
     feature_flags: Vec<String>,
 }
 
-async fn get_authenticated_user(
+async fn update_or_create_authenticated_user(
     Query(params): Query<AuthenticatedUserParams>,
     Extension(app): Extension<Arc<AppState>>,
 ) -> Result<Json<AuthenticatedUserResponse>> {
@@ -163,7 +165,7 @@ async fn get_authenticated_user(
 
     let user = app
         .db
-        .get_or_create_user_by_github_account(
+        .update_or_create_user_by_github_account(
             &params.github_login,
             params.github_user_id,
             params.github_email.as_deref(),
@@ -181,6 +183,87 @@ async fn get_authenticated_user(
     }))
 }
 
+#[derive(Debug, Deserialize)]
+struct LookUpUserParams {
+    identifier: String,
+}
+
+#[derive(Debug, Serialize)]
+struct LookUpUserResponse {
+    user: Option<User>,
+}
+
+async fn look_up_user(
+    Query(params): Query<LookUpUserParams>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<Json<LookUpUserResponse>> {
+    let user = resolve_identifier_to_user(&app.db, &params.identifier).await?;
+    let user = if let Some(user) = user {
+        match user {
+            UserOrId::User(user) => Some(user),
+            UserOrId::Id(id) => app.db.get_user_by_id(id).await?,
+        }
+    } else {
+        None
+    };
+
+    Ok(Json(LookUpUserResponse { user }))
+}
+
+enum UserOrId {
+    User(User),
+    Id(UserId),
+}
+
+async fn resolve_identifier_to_user(
+    db: &Arc<Database>,
+    identifier: &str,
+) -> Result<Option<UserOrId>> {
+    if let Some(identifier) = identifier.parse::<i32>().ok() {
+        let user = db.get_user_by_id(UserId(identifier)).await?;
+
+        return Ok(user.map(UserOrId::User));
+    }
+
+    if identifier.starts_with("cus_") {
+        let billing_customer = db
+            .get_billing_customer_by_stripe_customer_id(&identifier)
+            .await?;
+
+        return Ok(billing_customer.map(|billing_customer| UserOrId::Id(billing_customer.user_id)));
+    }
+
+    if identifier.starts_with("sub_") {
+        let billing_subscription = db
+            .get_billing_subscription_by_stripe_subscription_id(&identifier)
+            .await?;
+
+        if let Some(billing_subscription) = billing_subscription {
+            let billing_customer = db
+                .get_billing_customer_by_id(billing_subscription.billing_customer_id)
+                .await?;
+
+            return Ok(
+                billing_customer.map(|billing_customer| UserOrId::Id(billing_customer.user_id))
+            );
+        } else {
+            return Ok(None);
+        }
+    }
+
+    if identifier.contains('@') {
+        let user = db.get_user_by_email(identifier).await?;
+
+        return Ok(user.map(UserOrId::User));
+    }
+
+    if let Some(user) = db.get_user_by_github_login(identifier).await? {
+        return Ok(Some(UserOrId::User(user)));
+    }
+
+    Ok(None)
+}
+
 #[derive(Deserialize, Debug)]
 struct CreateUserParams {
     github_user_id: i32,
@@ -220,7 +303,7 @@ async fn create_access_token(
         .db
         .get_user_by_id(user_id)
         .await?
-        .ok_or_else(|| anyhow!("user not found"))?;
+        .context("user not found")?;
 
     let mut impersonated_user_id = None;
     if let Some(impersonate) = params.impersonate {

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

@@ -1,4 +1,4 @@
-use anyhow::{Context, anyhow, bail};
+use anyhow::{Context as _, bail};
 use axum::{
     Extension, Json, Router,
     extract::{self, Query},
@@ -17,9 +17,8 @@ use stripe::{
     CreateBillingPortalSessionFlowDataAfterCompletionRedirect,
     CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm,
     CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems,
-    CreateBillingPortalSessionFlowDataType, CreateCustomer, Customer, CustomerId, EventObject,
-    EventType, Expandable, ListEvents, PaymentMethod, Subscription, SubscriptionId,
-    SubscriptionStatus,
+    CreateBillingPortalSessionFlowDataType, CustomerId, EventObject, EventType, ListEvents,
+    PaymentMethod, Subscription, SubscriptionId, SubscriptionStatus,
 };
 use util::{ResultExt, maybe};
 
@@ -28,11 +27,13 @@ use crate::db::billing_subscription::{
     StripeCancellationReason, StripeSubscriptionStatus, SubscriptionKind,
 };
 use crate::llm::db::subscription_usage_meter::CompletionMode;
-use crate::llm::{
-    AGENT_EXTENDED_TRIAL_FEATURE_FLAG, DEFAULT_MAX_MONTHLY_SPEND, FREE_TIER_MONTHLY_SPENDING_LIMIT,
-};
+use crate::llm::{AGENT_EXTENDED_TRIAL_FEATURE_FLAG, DEFAULT_MAX_MONTHLY_SPEND};
 use crate::rpc::{ResultExt as _, Server};
-use crate::{AppState, Cents, Error, Result};
+use crate::stripe_client::{
+    StripeCancellationDetailsReason, StripeClient, StripeCustomerId, StripeSubscription,
+    StripeSubscriptionId, UpdateCustomerParams,
+};
+use crate::{AppState, Error, Result};
 use crate::{db::UserId, llm::db::LlmDatabase};
 use crate::{
     db::{
@@ -58,10 +59,9 @@ pub fn router() -> Router {
             post(manage_billing_subscription),
         )
         .route(
-            "/billing/subscriptions/migrate",
-            post(migrate_to_new_billing),
+            "/billing/subscriptions/sync",
+            post(sync_billing_subscription),
         )
-        .route("/billing/monthly_spend", get(get_monthly_spend))
         .route("/billing/usage", get(get_current_usage))
 }
 
@@ -86,7 +86,7 @@ async fn get_billing_preferences(
         .db
         .get_user_by_github_user_id(params.github_user_id)
         .await?
-        .ok_or_else(|| anyhow!("user not found"))?;
+        .context("user not found")?;
 
     let billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?;
     let preferences = app.db.get_billing_preferences(user.id).await?;
@@ -135,7 +135,7 @@ async fn update_billing_preferences(
         .db
         .get_user_by_github_user_id(body.github_user_id)
         .await?
-        .ok_or_else(|| anyhow!("user not found"))?;
+        .context("user not found")?;
 
     let billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?;
 
@@ -219,12 +219,19 @@ struct BillingSubscriptionJson {
     id: BillingSubscriptionId,
     name: String,
     status: StripeSubscriptionStatus,
+    period: Option<BillingSubscriptionPeriodJson>,
     trial_end_at: Option<String>,
     cancel_at: Option<String>,
     /// Whether this subscription can be canceled.
     is_cancelable: bool,
 }
 
+#[derive(Debug, Serialize)]
+struct BillingSubscriptionPeriodJson {
+    start_at: String,
+    end_at: String,
+}
+
 #[derive(Debug, Serialize)]
 struct ListBillingSubscriptionsResponse {
     subscriptions: Vec<BillingSubscriptionJson>,
@@ -238,7 +245,7 @@ async fn list_billing_subscriptions(
         .db
         .get_user_by_github_user_id(params.github_user_id)
         .await?
-        .ok_or_else(|| anyhow!("user not found"))?;
+        .context("user not found")?;
 
     let subscriptions = app.db.get_billing_subscriptions(user.id).await?;
 
@@ -254,6 +261,15 @@ async fn list_billing_subscriptions(
                     None => "Zed LLM Usage".to_string(),
                 },
                 status: subscription.stripe_subscription_status,
+                period: maybe!({
+                    let start_at = subscription.current_period_start_at()?;
+                    let end_at = subscription.current_period_end_at()?;
+
+                    Some(BillingSubscriptionPeriodJson {
+                        start_at: start_at.to_rfc3339_opts(SecondsFormat::Millis, true),
+                        end_at: end_at.to_rfc3339_opts(SecondsFormat::Millis, true),
+                    })
+                }),
                 trial_end_at: if subscription.kind == Some(SubscriptionKind::ZedProTrial) {
                     maybe!({
                         let end_at = subscription.stripe_current_period_end?;
@@ -269,25 +285,25 @@ async fn list_billing_subscriptions(
                         .and_utc()
                         .to_rfc3339_opts(SecondsFormat::Millis, true)
                 }),
-                is_cancelable: subscription.stripe_subscription_status.is_cancelable()
+                is_cancelable: subscription.kind != Some(SubscriptionKind::ZedFree)
+                    && subscription.stripe_subscription_status.is_cancelable()
                     && subscription.stripe_cancel_at.is_none(),
             })
             .collect(),
     }))
 }
 
-#[derive(Debug, Clone, Copy, Deserialize)]
+#[derive(Debug, PartialEq, Clone, Copy, Deserialize)]
 #[serde(rename_all = "snake_case")]
 enum ProductCode {
     ZedPro,
     ZedProTrial,
-    ZedFree,
 }
 
 #[derive(Debug, Deserialize)]
 struct CreateBillingSubscriptionBody {
     github_user_id: i32,
-    product: Option<ProductCode>,
+    product: ProductCode,
 }
 
 #[derive(Debug, Serialize)]
@@ -304,15 +320,8 @@ async fn create_billing_subscription(
         .db
         .get_user_by_github_user_id(body.github_user_id)
         .await?
-        .ok_or_else(|| anyhow!("user not found"))?;
+        .context("user not found")?;
 
-    let Some(stripe_client) = app.stripe_client.clone() else {
-        log::error!("failed to retrieve Stripe client");
-        Err(Error::http(
-            StatusCode::NOT_IMPLEMENTED,
-            "not supported".into(),
-        ))?
-    };
     let Some(stripe_billing) = app.stripe_billing.clone() else {
         log::error!("failed to retrieve Stripe billing object");
         Err(Error::http(
@@ -321,11 +330,16 @@ async fn create_billing_subscription(
         ))?
     };
 
-    if app.db.has_active_billing_subscription(user.id).await? {
-        return Err(Error::http(
-            StatusCode::CONFLICT,
-            "user already has an active subscription".into(),
-        ));
+    if let Some(existing_subscription) = app.db.get_active_billing_subscription(user.id).await? {
+        let is_checkout_allowed = body.product == ProductCode::ZedProTrial
+            && existing_subscription.kind == Some(SubscriptionKind::ZedFree);
+
+        if !is_checkout_allowed {
+            return Err(Error::http(
+                StatusCode::CONFLICT,
+                "user already has an active subscription".into(),
+            ));
+        }
     }
 
     let existing_billing_customer = app.db.get_billing_customer_by_user_id(user.id).await?;
@@ -339,38 +353,21 @@ async fn create_billing_subscription(
     }
 
     let customer_id = if let Some(existing_customer) = &existing_billing_customer {
-        CustomerId::from_str(&existing_customer.stripe_customer_id)
-            .context("failed to parse customer ID")?
-    } else {
-        let existing_customer = if let Some(email) = user.email_address.as_deref() {
-            let customers = Customer::list(
-                &stripe_client,
-                &stripe::ListCustomers {
-                    email: Some(email),
-                    ..Default::default()
-                },
-            )
-            .await?;
-
-            customers.data.first().cloned()
-        } else {
-            None
-        };
-
-        if let Some(existing_customer) = existing_customer {
-            existing_customer.id
-        } else {
-            let customer = Customer::create(
-                &stripe_client,
-                CreateCustomer {
-                    email: user.email_address.as_deref(),
-                    ..Default::default()
-                },
-            )
-            .await?;
-
-            customer.id
+        let customer_id = StripeCustomerId(existing_customer.stripe_customer_id.clone().into());
+        if let Some(email) = user.email_address.as_deref() {
+            stripe_billing
+                .client()
+                .update_customer(&customer_id, UpdateCustomerParams { email: Some(email) })
+                .await
+                // Update of email address is best-effort - continue checkout even if it fails
+                .context("error updating stripe customer email address")
+                .log_err();
         }
+        customer_id
+    } else {
+        stripe_billing
+            .find_or_create_customer_by_email(user.email_address.as_deref())
+            .await?
     };
 
     let success_url = format!(
@@ -379,12 +376,12 @@ async fn create_billing_subscription(
     );
 
     let checkout_session_url = match body.product {
-        Some(ProductCode::ZedPro) => {
+        ProductCode::ZedPro => {
             stripe_billing
-                .checkout_with_zed_pro(customer_id, &user.github_login, &success_url)
+                .checkout_with_zed_pro(&customer_id, &user.github_login, &success_url)
                 .await?
         }
-        Some(ProductCode::ZedProTrial) => {
+        ProductCode::ZedProTrial => {
             if let Some(existing_billing_customer) = &existing_billing_customer {
                 if existing_billing_customer.trial_started_at.is_some() {
                     return Err(Error::http(
@@ -398,24 +395,13 @@ async fn create_billing_subscription(
 
             stripe_billing
                 .checkout_with_zed_pro_trial(
-                    customer_id,
+                    &customer_id,
                     &user.github_login,
                     feature_flags,
                     &success_url,
                 )
                 .await?
         }
-        Some(ProductCode::ZedFree) => {
-            stripe_billing
-                .checkout_with_zed_free(customer_id, &user.github_login, &success_url)
-                .await?
-        }
-        None => {
-            return Err(Error::http(
-                StatusCode::BAD_REQUEST,
-                "No product selected".into(),
-            ));
-        }
     };
 
     Ok(Json(CreateBillingSubscriptionResponse {
@@ -463,9 +449,9 @@ async fn manage_billing_subscription(
         .db
         .get_user_by_github_user_id(body.github_user_id)
         .await?
-        .ok_or_else(|| anyhow!("user not found"))?;
+        .context("user not found")?;
 
-    let Some(stripe_client) = app.stripe_client.clone() else {
+    let Some(stripe_client) = app.real_stripe_client.clone() else {
         log::error!("failed to retrieve Stripe client");
         Err(Error::http(
             StatusCode::NOT_IMPLEMENTED,
@@ -485,7 +471,7 @@ async fn manage_billing_subscription(
         .db
         .get_billing_customer_by_user_id(user.id)
         .await?
-        .ok_or_else(|| anyhow!("billing customer not found"))?;
+        .context("billing customer not found")?;
     let customer_id = CustomerId::from_str(&customer.stripe_customer_id)
         .context("failed to parse customer ID")?;
 
@@ -493,7 +479,7 @@ async fn manage_billing_subscription(
         .db
         .get_billing_subscription_by_id(body.subscription_id)
         .await?
-        .ok_or_else(|| anyhow!("subscription not found"))?;
+        .context("subscription not found")?;
     let subscription_id = SubscriptionId::from_str(&subscription.stripe_subscription_id)
         .context("failed to parse subscription ID")?;
 
@@ -531,8 +517,10 @@ async fn manage_billing_subscription(
     let flow = match body.intent {
         ManageSubscriptionIntent::ManageSubscription => None,
         ManageSubscriptionIntent::UpgradeToPro => {
-            let zed_pro_price_id = stripe_billing.zed_pro_price_id().await?;
-            let zed_free_price_id = stripe_billing.zed_free_price_id().await?;
+            let zed_pro_price_id: stripe::PriceId =
+                stripe_billing.zed_pro_price_id().await?.try_into()?;
+            let zed_free_price_id: stripe::PriceId =
+                stripe_billing.zed_free_price_id().await?.try_into()?;
 
             let stripe_subscription =
                 Subscription::retrieve(&stripe_client, &subscription_id, &[]).await?;
@@ -590,7 +578,7 @@ async fn manage_billing_subscription(
                         None
                     }
                 })
-                .ok_or_else(|| anyhow!("No subscription item to update"))?;
+                .context("No subscription item to update")?;
 
             Some(CreateBillingPortalSessionFlowData {
                 type_: CreateBillingPortalSessionFlowDataType::SubscriptionUpdateConfirm,
@@ -625,23 +613,32 @@ async fn manage_billing_subscription(
             }),
             ..Default::default()
         }),
-        ManageSubscriptionIntent::Cancel => Some(CreateBillingPortalSessionFlowData {
-            type_: CreateBillingPortalSessionFlowDataType::SubscriptionCancel,
-            after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion {
-                type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect,
-                redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect {
-                    return_url: format!("{}/account", app.config.zed_dot_dev_url()),
+        ManageSubscriptionIntent::Cancel => {
+            if subscription.kind == Some(SubscriptionKind::ZedFree) {
+                return Err(Error::http(
+                    StatusCode::BAD_REQUEST,
+                    "free subscription cannot be canceled".into(),
+                ));
+            }
+
+            Some(CreateBillingPortalSessionFlowData {
+                type_: CreateBillingPortalSessionFlowDataType::SubscriptionCancel,
+                after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion {
+                    type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect,
+                    redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect {
+                        return_url: format!("{}/account", app.config.zed_dot_dev_url()),
+                    }),
+                    ..Default::default()
                 }),
+                subscription_cancel: Some(
+                    stripe::CreateBillingPortalSessionFlowDataSubscriptionCancel {
+                        subscription: subscription.stripe_subscription_id,
+                        retention: None,
+                    },
+                ),
                 ..Default::default()
-            }),
-            subscription_cancel: Some(
-                stripe::CreateBillingPortalSessionFlowDataSubscriptionCancel {
-                    subscription: subscription.stripe_subscription_id,
-                    retention: None,
-                },
-            ),
-            ..Default::default()
-        }),
+            })
+        }
         ManageSubscriptionIntent::StopCancellation => unreachable!(),
     };
 
@@ -658,20 +655,19 @@ async fn manage_billing_subscription(
 }
 
 #[derive(Debug, Deserialize)]
-struct MigrateToNewBillingBody {
+struct SyncBillingSubscriptionBody {
     github_user_id: i32,
 }
 
 #[derive(Debug, Serialize)]
-struct MigrateToNewBillingResponse {
-    /// The ID of the subscription that was canceled.
-    canceled_subscription_id: Option<String>,
+struct SyncBillingSubscriptionResponse {
+    stripe_customer_id: String,
 }
 
-async fn migrate_to_new_billing(
+async fn sync_billing_subscription(
     Extension(app): Extension<Arc<AppState>>,
-    extract::Json(body): extract::Json<MigrateToNewBillingBody>,
-) -> Result<Json<MigrateToNewBillingResponse>> {
+    extract::Json(body): extract::Json<SyncBillingSubscriptionBody>,
+) -> Result<Json<SyncBillingSubscriptionResponse>> {
     let Some(stripe_client) = app.stripe_client.clone() else {
         log::error!("failed to retrieve Stripe client");
         Err(Error::http(
@@ -684,56 +680,34 @@ async fn migrate_to_new_billing(
         .db
         .get_user_by_github_user_id(body.github_user_id)
         .await?
-        .ok_or_else(|| anyhow!("user not found"))?;
+        .context("user not found")?;
 
-    let old_billing_subscriptions_by_user = app
+    let billing_customer = app
         .db
-        .get_active_billing_subscriptions(HashSet::from_iter([user.id]))
-        .await?;
-
-    let canceled_subscription_id = if let Some((_billing_customer, billing_subscription)) =
-        old_billing_subscriptions_by_user.get(&user.id)
-    {
-        let stripe_subscription_id = billing_subscription
-            .stripe_subscription_id
-            .parse::<stripe::SubscriptionId>()
-            .context("failed to parse Stripe subscription ID from database")?;
+        .get_billing_customer_by_user_id(user.id)
+        .await?
+        .context("billing customer not found")?;
+    let stripe_customer_id = StripeCustomerId(billing_customer.stripe_customer_id.clone().into());
 
-        Subscription::cancel(
-            &stripe_client,
-            &stripe_subscription_id,
-            stripe::CancelSubscription {
-                invoice_now: Some(true),
-                ..Default::default()
-            },
-        )
+    let subscriptions = stripe_client
+        .list_subscriptions_for_customer(&stripe_customer_id)
         .await?;
 
-        Some(stripe_subscription_id)
-    } else {
-        None
-    };
-
-    let all_feature_flags = app.db.list_feature_flags().await?;
-    let user_feature_flags = app.db.get_user_flags(user.id).await?;
-
-    for feature_flag in ["new-billing", "assistant2"] {
-        let already_in_feature_flag = user_feature_flags.iter().any(|flag| flag == feature_flag);
-        if already_in_feature_flag {
-            continue;
-        }
-
-        let feature_flag = all_feature_flags
-            .iter()
-            .find(|flag| flag.flag == feature_flag)
-            .context("failed to find feature flag: {feature_flag:?}")?;
+    for subscription in subscriptions {
+        let subscription_id = subscription.id.clone();
 
-        app.db.add_user_flag(user.id, feature_flag.id).await?;
+        sync_subscription(&app, &stripe_client, subscription)
+            .await
+            .with_context(|| {
+                format!(
+                    "failed to sync subscription {subscription_id} for user {}",
+                    user.id,
+                )
+            })?;
     }
 
-    Ok(Json(MigrateToNewBillingResponse {
-        canceled_subscription_id: canceled_subscription_id
-            .map(|subscription_id| subscription_id.to_string()),
+    Ok(Json(SyncBillingSubscriptionResponse {
+        stripe_customer_id: billing_customer.stripe_customer_id.clone(),
     }))
 }
 
@@ -767,6 +741,10 @@ const NUMBER_OF_ALREADY_PROCESSED_PAGES_BEFORE_WE_STOP: usize = 4;
 /// Polls the Stripe events API periodically to reconcile the records in our
 /// database with the data in Stripe.
 pub fn poll_stripe_events_periodically(app: Arc<AppState>, rpc_server: Arc<Server>) {
+    let Some(real_stripe_client) = app.real_stripe_client.clone() else {
+        log::warn!("failed to retrieve Stripe client");
+        return;
+    };
     let Some(stripe_client) = app.stripe_client.clone() else {
         log::warn!("failed to retrieve Stripe client");
         return;
@@ -777,7 +755,7 @@ pub fn poll_stripe_events_periodically(app: Arc<AppState>, rpc_server: Arc<Serve
         let executor = executor.clone();
         async move {
             loop {
-                poll_stripe_events(&app, &rpc_server, &stripe_client)
+                poll_stripe_events(&app, &rpc_server, &stripe_client, &real_stripe_client)
                     .await
                     .log_err();
 
@@ -790,7 +768,8 @@ pub fn poll_stripe_events_periodically(app: Arc<AppState>, rpc_server: Arc<Serve
 async fn poll_stripe_events(
     app: &Arc<AppState>,
     rpc_server: &Arc<Server>,
-    stripe_client: &stripe::Client,
+    stripe_client: &Arc<dyn StripeClient>,
+    real_stripe_client: &stripe::Client,
 ) -> anyhow::Result<()> {
     fn event_type_to_string(event_type: EventType) -> String {
         // Calling `to_string` on `stripe::EventType` members gives us a quoted string,
@@ -822,7 +801,7 @@ async fn poll_stripe_events(
     params.types = Some(event_types.clone());
     params.limit = Some(EVENTS_LIMIT_PER_PAGE);
 
-    let mut event_pages = stripe::Event::list(&stripe_client, &params)
+    let mut event_pages = stripe::Event::list(&real_stripe_client, &params)
         .await?
         .paginate(params);
 
@@ -866,7 +845,7 @@ async fn poll_stripe_events(
                 break;
             } else {
                 log::info!("Stripe events: retrieving next page");
-                event_pages = event_pages.next(&stripe_client).await?;
+                event_pages = event_pages.next(&real_stripe_client).await?;
             }
         } else {
             break;
@@ -901,12 +880,12 @@ async fn poll_stripe_events(
                 .create_processed_stripe_event(&processed_event_params)
                 .await?;
 
-            return Ok(());
+            continue;
         }
 
         let process_result = match event.type_ {
             EventType::CustomerCreated | EventType::CustomerUpdated => {
-                handle_customer_event(app, stripe_client, event).await
+                handle_customer_event(app, real_stripe_client, event).await
             }
             EventType::CustomerSubscriptionCreated
             | EventType::CustomerSubscriptionUpdated
@@ -979,52 +958,29 @@ async fn handle_customer_event(
     Ok(())
 }
 
-async fn handle_customer_subscription_event(
+async fn sync_subscription(
     app: &Arc<AppState>,
-    rpc_server: &Arc<Server>,
-    stripe_client: &stripe::Client,
-    event: stripe::Event,
-) -> anyhow::Result<()> {
-    let EventObject::Subscription(subscription) = event.data.object else {
-        bail!("unexpected event payload for {}", event.id);
+    stripe_client: &Arc<dyn StripeClient>,
+    subscription: StripeSubscription,
+) -> anyhow::Result<billing_customer::Model> {
+    let subscription_kind = if let Some(stripe_billing) = &app.stripe_billing {
+        stripe_billing
+            .determine_subscription_kind(&subscription)
+            .await
+    } else {
+        None
     };
 
-    log::info!("handling Stripe {} event: {}", event.type_, event.id);
-
-    let subscription_kind = maybe!(async {
-        let stripe_billing = app.stripe_billing.clone()?;
-
-        let zed_pro_price_id = stripe_billing.zed_pro_price_id().await.ok()?;
-        let zed_free_price_id = stripe_billing.zed_free_price_id().await.ok()?;
-
-        subscription.items.data.iter().find_map(|item| {
-            let price = item.price.as_ref()?;
-
-            if price.id == zed_pro_price_id {
-                Some(if subscription.status == SubscriptionStatus::Trialing {
-                    SubscriptionKind::ZedProTrial
-                } else {
-                    SubscriptionKind::ZedPro
-                })
-            } else if price.id == zed_free_price_id {
-                Some(SubscriptionKind::ZedFree)
-            } else {
-                None
-            }
-        })
-    })
-    .await;
-
     let billing_customer =
-        find_or_create_billing_customer(app, stripe_client, subscription.customer)
+        find_or_create_billing_customer(app, stripe_client.as_ref(), &subscription.customer)
             .await?
-            .ok_or_else(|| anyhow!("billing customer not found"))?;
+            .context("billing customer not found")?;
 
     if let Some(SubscriptionKind::ZedProTrial) = subscription_kind {
         if subscription.status == SubscriptionStatus::Trialing {
             let current_period_start =
                 DateTime::from_timestamp(subscription.current_period_start, 0)
-                    .ok_or_else(|| anyhow!("No trial subscription period start"))?;
+                    .context("No trial subscription period start")?;
 
             app.db
                 .update_billing_customer(
@@ -1044,7 +1000,7 @@ async fn handle_customer_subscription_event(
             .as_ref()
             .and_then(|details| details.reason)
             .map_or(false, |reason| {
-                reason == CancellationDetailsReason::PaymentFailed
+                reason == StripeCancellationDetailsReason::PaymentFailed
             });
 
     if was_canceled_due_to_payment_failure {
@@ -1061,7 +1017,7 @@ async fn handle_customer_subscription_event(
 
     if let Some(existing_subscription) = app
         .db
-        .get_billing_subscription_by_stripe_subscription_id(&subscription.id)
+        .get_billing_subscription_by_stripe_subscription_id(subscription.id.0.as_ref())
         .await?
     {
         app.db
@@ -1094,31 +1050,44 @@ async fn handle_customer_subscription_event(
             )
             .await?;
     } else {
-        // If the user already has an active billing subscription, ignore the
-        // event and return an `Ok` to signal that it was processed
-        // successfully.
-        //
-        // There is the possibility that this could cause us to not create a
-        // subscription in the following scenario:
-        //
-        //   1. User has an active subscription A
-        //   2. User cancels subscription A
-        //   3. User creates a new subscription B
-        //   4. We process the new subscription B before the cancellation of subscription A
-        //   5. User ends up with no subscriptions
-        //
-        // In theory this situation shouldn't arise as we try to process the events in the order they occur.
-        if app
+        if let Some(existing_subscription) = app
             .db
-            .has_active_billing_subscription(billing_customer.user_id)
+            .get_active_billing_subscription(billing_customer.user_id)
             .await?
         {
-            log::info!(
-                "user {user_id} already has an active subscription, skipping creation of subscription {subscription_id}",
-                user_id = billing_customer.user_id,
-                subscription_id = subscription.id
-            );
-            return Ok(());
+            if existing_subscription.kind == Some(SubscriptionKind::ZedFree)
+                && subscription_kind == Some(SubscriptionKind::ZedProTrial)
+            {
+                let stripe_subscription_id = StripeSubscriptionId(
+                    existing_subscription.stripe_subscription_id.clone().into(),
+                );
+
+                stripe_client
+                    .cancel_subscription(&stripe_subscription_id)
+                    .await?;
+            } else {
+                // If the user already has an active billing subscription, ignore the
+                // event and return an `Ok` to signal that it was processed
+                // successfully.
+                //
+                // There is the possibility that this could cause us to not create a
+                // subscription in the following scenario:
+                //
+                //   1. User has an active subscription A
+                //   2. User cancels subscription A
+                //   3. User creates a new subscription B
+                //   4. We process the new subscription B before the cancellation of subscription A
+                //   5. User ends up with no subscriptions
+                //
+                // In theory this situation shouldn't arise as we try to process the events in the order they occur.
+
+                log::info!(
+                    "user {user_id} already has an active subscription, skipping creation of subscription {subscription_id}",
+                    user_id = billing_customer.user_id,
+                    subscription_id = subscription.id
+                );
+                return Ok(billing_customer);
+            }
         }
 
         app.db
@@ -1137,6 +1106,42 @@ async fn handle_customer_subscription_event(
             .await?;
     }
 
+    if let Some(stripe_billing) = app.stripe_billing.as_ref() {
+        if subscription.status == SubscriptionStatus::Canceled
+            || subscription.status == SubscriptionStatus::Paused
+        {
+            let already_has_active_billing_subscription = app
+                .db
+                .has_active_billing_subscription(billing_customer.user_id)
+                .await?;
+            if !already_has_active_billing_subscription {
+                let stripe_customer_id =
+                    StripeCustomerId(billing_customer.stripe_customer_id.clone().into());
+
+                stripe_billing
+                    .subscribe_to_zed_free(stripe_customer_id)
+                    .await?;
+            }
+        }
+    }
+
+    Ok(billing_customer)
+}
+
+async fn handle_customer_subscription_event(
+    app: &Arc<AppState>,
+    rpc_server: &Arc<Server>,
+    stripe_client: &Arc<dyn StripeClient>,
+    event: stripe::Event,
+) -> anyhow::Result<()> {
+    let EventObject::Subscription(subscription) = event.data.object else {
+        bail!("unexpected event payload for {}", event.id);
+    };
+
+    log::info!("handling Stripe {} event: {}", event.type_, event.id);
+
+    let billing_customer = sync_subscription(app, stripe_client, subscription.into()).await?;
+
     // When the user's subscription changes, push down any changes to their plan.
     rpc_server
         .update_plan_for_user(billing_customer.user_id)
@@ -1152,54 +1157,6 @@ async fn handle_customer_subscription_event(
     Ok(())
 }
 
-#[derive(Debug, Deserialize)]
-struct GetMonthlySpendParams {
-    github_user_id: i32,
-}
-
-#[derive(Debug, Serialize)]
-struct GetMonthlySpendResponse {
-    monthly_free_tier_spend_in_cents: u32,
-    monthly_free_tier_allowance_in_cents: u32,
-    monthly_spend_in_cents: u32,
-}
-
-async fn get_monthly_spend(
-    Extension(app): Extension<Arc<AppState>>,
-    Query(params): Query<GetMonthlySpendParams>,
-) -> Result<Json<GetMonthlySpendResponse>> {
-    let user = app
-        .db
-        .get_user_by_github_user_id(params.github_user_id)
-        .await?
-        .ok_or_else(|| anyhow!("user not found"))?;
-
-    let Some(llm_db) = app.llm_db.clone() else {
-        return Err(Error::http(
-            StatusCode::NOT_IMPLEMENTED,
-            "LLM database not available".into(),
-        ));
-    };
-
-    let free_tier = user
-        .custom_llm_monthly_allowance_in_cents
-        .map(|allowance| Cents(allowance as u32))
-        .unwrap_or(FREE_TIER_MONTHLY_SPENDING_LIMIT);
-
-    let spending_for_month = llm_db
-        .get_user_spending_for_month(user.id, Utc::now())
-        .await?;
-
-    let free_tier_spend = Cents::min(spending_for_month, free_tier);
-    let monthly_spend = spending_for_month.saturating_sub(free_tier);
-
-    Ok(Json(GetMonthlySpendResponse {
-        monthly_free_tier_spend_in_cents: free_tier_spend.0,
-        monthly_free_tier_allowance_in_cents: free_tier.0,
-        monthly_spend_in_cents: monthly_spend.0,
-    }))
-}
-
 #[derive(Debug, Deserialize)]
 struct GetCurrentUsageParams {
     github_user_id: i32,
@@ -1240,7 +1197,7 @@ async fn get_current_usage(
         .db
         .get_user_by_github_user_id(params.github_user_id)
         .await?
-        .ok_or_else(|| anyhow!("user not found"))?;
+        .context("user not found")?;
 
     let feature_flags = app.db.get_user_flags(user.id).await?;
     let has_extended_trial = feature_flags
@@ -1273,15 +1230,10 @@ async fn get_current_usage(
         .get_subscription_usage_for_period(user.id, period_start_at, period_end_at)
         .await?;
 
-    let plan = usage
-        .as_ref()
-        .map(|usage| usage.plan.into())
-        .unwrap_or_else(|| {
-            subscription
-                .kind
-                .map(Into::into)
-                .unwrap_or(zed_llm_client::Plan::ZedFree)
-        });
+    let plan = subscription
+        .kind
+        .map(Into::into)
+        .unwrap_or(zed_llm_client::Plan::ZedFree);
 
     let model_requests_limit = match plan.model_requests_limit() {
         zed_llm_client::UsageLimit::Limited(limit) => {
@@ -1382,32 +1334,22 @@ impl From<CancellationDetailsReason> for StripeCancellationReason {
 }
 
 /// Finds or creates a billing customer using the provided customer.
-async fn find_or_create_billing_customer(
+pub async fn find_or_create_billing_customer(
     app: &Arc<AppState>,
-    stripe_client: &stripe::Client,
-    customer_or_id: Expandable<Customer>,
+    stripe_client: &dyn StripeClient,
+    customer_id: &StripeCustomerId,
 ) -> anyhow::Result<Option<billing_customer::Model>> {
-    let customer_id = match &customer_or_id {
-        Expandable::Id(id) => id,
-        Expandable::Object(customer) => customer.id.as_ref(),
-    };
-
     // If we already have a billing customer record associated with the Stripe customer,
     // there's nothing more we need to do.
     if let Some(billing_customer) = app
         .db
-        .get_billing_customer_by_stripe_customer_id(customer_id)
+        .get_billing_customer_by_stripe_customer_id(customer_id.0.as_ref())
         .await?
     {
         return Ok(Some(billing_customer));
     }
 
-    // If all we have is a customer ID, resolve it to a full customer record by
-    // hitting the Stripe API.
-    let customer = match customer_or_id {
-        Expandable::Id(id) => Customer::retrieve(stripe_client, &id, &[]).await?,
-        Expandable::Object(customer) => *customer,
-    };
+    let customer = stripe_client.get_customer(customer_id).await?;
 
     let Some(email) = customer.email else {
         return Ok(None);
@@ -1484,6 +1426,18 @@ async fn sync_model_request_usage_with_stripe(
         .get_active_zed_pro_billing_subscriptions(user_ids)
         .await?;
 
+    let claude_sonnet_4 = stripe_billing
+        .find_price_by_lookup_key("claude-sonnet-4-requests")
+        .await?;
+    let claude_sonnet_4_max = stripe_billing
+        .find_price_by_lookup_key("claude-sonnet-4-requests-max")
+        .await?;
+    let claude_opus_4 = stripe_billing
+        .find_price_by_lookup_key("claude-opus-4-requests")
+        .await?;
+    let claude_opus_4_max = stripe_billing
+        .find_price_by_lookup_key("claude-opus-4-requests-max")
+        .await?;
     let claude_3_5_sonnet = stripe_billing
         .find_price_by_lookup_key("claude-3-5-sonnet-requests")
         .await?;
@@ -1505,18 +1459,22 @@ async fn sync_model_request_usage_with_stripe(
                 );
             };
 
-            let stripe_customer_id = billing_customer
-                .stripe_customer_id
-                .parse::<stripe::CustomerId>()
-                .context("failed to parse Stripe customer ID from database")?;
-            let stripe_subscription_id = billing_subscription
-                .stripe_subscription_id
-                .parse::<stripe::SubscriptionId>()
-                .context("failed to parse Stripe subscription ID from database")?;
+            let stripe_customer_id =
+                StripeCustomerId(billing_customer.stripe_customer_id.clone().into());
+            let stripe_subscription_id =
+                StripeSubscriptionId(billing_subscription.stripe_subscription_id.clone().into());
 
             let model = llm_db.model_by_id(usage_meter.model_id)?;
 
             let (price, meter_event_name) = match model.name.as_str() {
+                "claude-opus-4" => match usage_meter.mode {
+                    CompletionMode::Normal => (&claude_opus_4, "claude_opus_4/requests"),
+                    CompletionMode::Max => (&claude_opus_4_max, "claude_opus_4/requests/max"),
+                },
+                "claude-sonnet-4" => match usage_meter.mode {
+                    CompletionMode::Normal => (&claude_sonnet_4, "claude_sonnet_4/requests"),
+                    CompletionMode::Max => (&claude_sonnet_4_max, "claude_sonnet_4/requests/max"),
+                },
                 "claude-3-5-sonnet" => (&claude_3_5_sonnet, "claude_3_5_sonnet/requests"),
                 "claude-3-7-sonnet" => match usage_meter.mode {
                     CompletionMode::Normal => (&claude_3_7_sonnet, "claude_3_7_sonnet/requests"),

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

@@ -1,6 +1,5 @@
 use std::sync::{Arc, OnceLock};
 
-use anyhow::anyhow;
 use axum::{
     Extension, Json, Router,
     extract::{self, Query},
@@ -39,7 +38,7 @@ impl CheckIsContributorParams {
             return Ok(ContributorSelector::GitHubLogin { github_login });
         }
 
-        Err(anyhow!(
+        Err(anyhow::anyhow!(
             "must be one of `github_user_id` or `github_login`."
         ))?
     }

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

@@ -1,6 +1,6 @@
 use crate::db::ExtensionVersionConstraints;
 use crate::{AppState, Error, Result, db::NewExtensionVersion};
-use anyhow::{Context as _, anyhow};
+use anyhow::Context as _;
 use aws_sdk_s3::presigning::PresigningConfig;
 use axum::{
     Extension, Json, Router,
@@ -66,7 +66,7 @@ async fn get_extensions(
             params.filter.as_deref(),
             provides_filter.as_ref(),
             params.max_schema_version,
-            500,
+            1_000,
         )
         .await?;
 
@@ -181,7 +181,7 @@ async fn download_latest_extension(
         .db
         .get_extension(&params.extension_id, constraints.as_ref())
         .await?
-        .ok_or_else(|| anyhow!("unknown extension"))?;
+        .context("unknown extension")?;
     download_extension(
         Extension(app),
         Path(DownloadExtensionParams {
@@ -238,7 +238,7 @@ async fn download_extension(
         ))
         .presigned(PresigningConfig::expires_in(EXTENSION_DOWNLOAD_URL_LIFETIME).unwrap())
         .await
-        .map_err(|e| anyhow!("failed to create presigned extension download url {e}"))?;
+        .context("creating presigned extension download url")?;
 
     Ok(Redirect::temporary(url.uri()))
 }
@@ -374,7 +374,7 @@ async fn fetch_extension_manifest(
     blob_store_bucket: &String,
     extension_id: &str,
     version: &str,
-) -> Result<NewExtensionVersion, anyhow::Error> {
+) -> anyhow::Result<NewExtensionVersion> {
     let object = blob_store_client
         .get_object()
         .bucket(blob_store_bucket)
@@ -397,8 +397,8 @@ async fn fetch_extension_manifest(
                 String::from_utf8_lossy(&manifest_bytes)
             )
         })?;
-    let published_at = object.last_modified.ok_or_else(|| {
-        anyhow!("missing last modified timestamp for extension {extension_id} version {version}")
+    let published_at = object.last_modified.with_context(|| {
+        format!("missing last modified timestamp for extension {extension_id} version {version}")
     })?;
     let published_at = time::OffsetDateTime::from_unix_timestamp_nanos(published_at.as_nanos())?;
     let published_at = PrimitiveDateTime::new(published_at.date(), published_at.time());

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

@@ -1,3 +1,4 @@
+use anyhow::Context as _;
 use collections::HashMap;
 
 use semantic_version::SemanticVersion;
@@ -13,18 +14,12 @@ pub struct IpsFile {
 impl IpsFile {
     pub fn parse(bytes: &[u8]) -> anyhow::Result<IpsFile> {
         let mut split = bytes.splitn(2, |&b| b == b'\n');
-        let header_bytes = split
-            .next()
-            .ok_or_else(|| anyhow::anyhow!("No header found"))?;
-        let header: Header = serde_json::from_slice(header_bytes)
-            .map_err(|e| anyhow::anyhow!("Failed to parse header: {}", e))?;
+        let header_bytes = split.next().context("No header found")?;
+        let header: Header = serde_json::from_slice(header_bytes).context("parsing header")?;
 
-        let body_bytes = split
-            .next()
-            .ok_or_else(|| anyhow::anyhow!("No body found"))?;
+        let body_bytes = split.next().context("No body found")?;
 
-        let body: Body = serde_json::from_slice(body_bytes)
-            .map_err(|e| anyhow::anyhow!("Failed to parse body: {}", e))?;
+        let body: Body = serde_json::from_slice(body_bytes).context("parsing body")?;
         Ok(IpsFile { header, body })
     }
 

crates/collab/src/auth.rs 🔗

@@ -3,7 +3,7 @@ use crate::{
     db::{self, AccessTokenId, Database, UserId},
     rpc::Principal,
 };
-use anyhow::{Context as _, anyhow};
+use anyhow::Context as _;
 use axum::{
     http::{self, Request, StatusCode},
     middleware::Next,
@@ -85,14 +85,14 @@ pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl Into
                 .db
                 .get_user_by_id(user_id)
                 .await?
-                .ok_or_else(|| anyhow!("user {} not found", user_id))?;
+                .with_context(|| format!("user {user_id} not found"))?;
 
             if let Some(impersonator_id) = validate_result.impersonator_id {
                 let admin = state
                     .db
                     .get_user_by_id(impersonator_id)
                     .await?
-                    .ok_or_else(|| anyhow!("user {} not found", impersonator_id))?;
+                    .with_context(|| format!("user {impersonator_id} not found"))?;
                 req.extensions_mut()
                     .insert(Principal::Impersonated { user, admin });
             } else {
@@ -192,7 +192,7 @@ pub async fn verify_access_token(
     let db_token = db.get_access_token(token.id).await?;
     let token_user_id = db_token.impersonated_user_id.unwrap_or(db_token.user_id);
     if token_user_id != user_id {
-        return Err(anyhow!("no such access token"))?;
+        return Err(anyhow::anyhow!("no such access token"))?;
     }
     let t0 = Instant::now();
 

crates/collab/src/db.rs 🔗

@@ -5,7 +5,7 @@ mod tables;
 pub mod tests;
 
 use crate::{Error, Result, executor::Executor};
-use anyhow::anyhow;
+use anyhow::{Context as _, anyhow};
 use collections::{BTreeMap, BTreeSet, HashMap, HashSet};
 use dashmap::DashMap;
 use futures::StreamExt;
@@ -56,6 +56,12 @@ pub use sea_orm::ConnectOptions;
 pub use tables::user::Model as User;
 pub use tables::*;
 
+#[cfg(test)]
+pub struct DatabaseTestOptions {
+    pub runtime: tokio::runtime::Runtime,
+    pub query_failure_probability: parking_lot::Mutex<f64>,
+}
+
 /// Database gives you a handle that lets you access the database.
 /// It handles pooling internally.
 pub struct Database {
@@ -68,7 +74,7 @@ pub struct Database {
     notification_kinds_by_id: HashMap<NotificationKindId, &'static str>,
     notification_kinds_by_name: HashMap<String, NotificationKindId>,
     #[cfg(test)]
-    runtime: Option<tokio::runtime::Runtime>,
+    test_options: Option<DatabaseTestOptions>,
 }
 
 // The `Database` type has so many methods that its impl blocks are split into
@@ -87,7 +93,7 @@ impl Database {
             notification_kinds_by_name: HashMap::default(),
             executor,
             #[cfg(test)]
-            runtime: None,
+            test_options: None,
         })
     }
 
@@ -320,11 +326,9 @@ impl Database {
 
         let mut tx = Arc::new(Some(tx));
         let result = f(TransactionHandle(tx.clone())).await;
-        let Some(tx) = Arc::get_mut(&mut tx).and_then(|tx| tx.take()) else {
-            return Err(anyhow!(
-                "couldn't complete transaction because it's still in use"
-            ))?;
-        };
+        let tx = Arc::get_mut(&mut tx)
+            .and_then(|tx| tx.take())
+            .context("couldn't complete transaction because it's still in use")?;
 
         Ok((tx, result))
     }
@@ -344,11 +348,9 @@ impl Database {
 
         let mut tx = Arc::new(Some(tx));
         let result = f(TransactionHandle(tx.clone())).await;
-        let Some(tx) = Arc::get_mut(&mut tx).and_then(|tx| tx.take()) else {
-            return Err(anyhow!(
-                "couldn't complete transaction because it's still in use"
-            ))?;
-        };
+        let tx = Arc::get_mut(&mut tx)
+            .and_then(|tx| tx.take())
+            .context("couldn't complete transaction because it's still in use")?;
 
         Ok((tx, result))
     }
@@ -359,11 +361,16 @@ impl Database {
     {
         #[cfg(test)]
         {
+            let test_options = self.test_options.as_ref().unwrap();
             if let Executor::Deterministic(executor) = &self.executor {
                 executor.simulate_random_delay().await;
+                let fail_probability = *test_options.query_failure_probability.lock();
+                if executor.rng().gen_bool(fail_probability) {
+                    return Err(anyhow!("simulated query failure"))?;
+                }
             }
 
-            self.runtime.as_ref().unwrap().block_on(future)
+            test_options.runtime.block_on(future)
         }
 
         #[cfg(not(test))]
@@ -543,7 +550,7 @@ pub struct MembershipUpdated {
 
 /// The result of setting a member's role.
 #[derive(Debug)]
-#[allow(clippy::large_enum_variant)]
+
 pub enum SetMemberRoleResult {
     InviteUpdated(Channel),
     MembershipUpdated(MembershipUpdated),
@@ -575,6 +582,7 @@ pub struct Channel {
     pub visibility: ChannelVisibility,
     /// parent_path is the channel ids from the root to this one (not including this one)
     pub parent_path: Vec<ChannelId>,
+    pub channel_order: i32,
 }
 
 impl Channel {
@@ -584,6 +592,7 @@ impl Channel {
             visibility: value.visibility,
             name: value.clone().name,
             parent_path: value.ancestors().collect(),
+            channel_order: value.channel_order,
         }
     }
 
@@ -593,8 +602,13 @@ impl Channel {
             name: self.name.clone(),
             visibility: self.visibility.into(),
             parent_path: self.parent_path.iter().map(|c| c.to_proto()).collect(),
+            channel_order: self.channel_order,
         }
     }
+
+    pub fn root_id(&self) -> ChannelId {
+        self.parent_path.first().copied().unwrap_or(self.id)
+    }
 }
 
 #[derive(Debug, PartialEq, Eq, Hash)]
@@ -737,6 +751,8 @@ pub struct ProjectCollaborator {
     pub user_id: UserId,
     pub replica_id: ReplicaId,
     pub is_host: bool,
+    pub committer_name: Option<String>,
+    pub committer_email: Option<String>,
 }
 
 impl ProjectCollaborator {
@@ -746,6 +762,8 @@ impl ProjectCollaborator {
             replica_id: self.replica_id.0 as u32,
             user_id: self.user_id.to_proto(),
             is_host: self.is_host,
+            committer_name: self.committer_name.clone(),
+            committer_email: self.committer_email.clone(),
         }
     }
 }
@@ -853,9 +871,7 @@ fn db_status_to_proto(
                 )
             }
             _ => {
-                return Err(anyhow!(
-                    "Unexpected combination of status fields: {entry:?}"
-                ));
+                anyhow::bail!("Unexpected combination of status fields: {entry:?}");
             }
         };
     Ok(proto::StatusEntry {

crates/collab/src/db/queries/access_tokens.rs 🔗

@@ -1,4 +1,5 @@
 use super::*;
+use anyhow::Context as _;
 use sea_orm::sea_query::Query;
 
 impl Database {
@@ -51,7 +52,7 @@ impl Database {
             Ok(access_token::Entity::find_by_id(access_token_id)
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such access token"))?)
+                .context("no such access token")?)
         })
         .await
     }

crates/collab/src/db/queries/billing_customers.rs 🔗

@@ -20,7 +20,7 @@ impl Database {
         &self,
         params: &CreateBillingCustomerParams,
     ) -> Result<billing_customer::Model> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let customer = billing_customer::Entity::insert(billing_customer::ActiveModel {
                 user_id: ActiveValue::set(params.user_id),
                 stripe_customer_id: ActiveValue::set(params.stripe_customer_id.clone()),
@@ -40,7 +40,7 @@ impl Database {
         id: BillingCustomerId,
         params: &UpdateBillingCustomerParams,
     ) -> Result<()> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             billing_customer::Entity::update(billing_customer::ActiveModel {
                 id: ActiveValue::set(id),
                 user_id: params.user_id.clone(),
@@ -57,12 +57,25 @@ impl Database {
         .await
     }
 
+    pub async fn get_billing_customer_by_id(
+        &self,
+        id: BillingCustomerId,
+    ) -> Result<Option<billing_customer::Model>> {
+        self.weak_transaction(|tx| async move {
+            Ok(billing_customer::Entity::find()
+                .filter(billing_customer::Column::Id.eq(id))
+                .one(&*tx)
+                .await?)
+        })
+        .await
+    }
+
     /// Returns the billing customer for the user with the specified ID.
     pub async fn get_billing_customer_by_user_id(
         &self,
         user_id: UserId,
     ) -> Result<Option<billing_customer::Model>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             Ok(billing_customer::Entity::find()
                 .filter(billing_customer::Column::UserId.eq(user_id))
                 .one(&*tx)
@@ -76,7 +89,7 @@ impl Database {
         &self,
         stripe_customer_id: &str,
     ) -> Result<Option<billing_customer::Model>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             Ok(billing_customer::Entity::find()
                 .filter(billing_customer::Column::StripeCustomerId.eq(stripe_customer_id))
                 .one(&*tx)

crates/collab/src/db/queries/billing_preferences.rs 🔗

@@ -1,3 +1,5 @@
+use anyhow::Context as _;
+
 use super::*;
 
 #[derive(Debug)]
@@ -20,7 +22,7 @@ impl Database {
         &self,
         user_id: UserId,
     ) -> Result<Option<billing_preference::Model>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             Ok(billing_preference::Entity::find()
                 .filter(billing_preference::Column::UserId.eq(user_id))
                 .one(&*tx)
@@ -35,7 +37,7 @@ impl Database {
         user_id: UserId,
         params: &CreateBillingPreferencesParams,
     ) -> Result<billing_preference::Model> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let preferences = billing_preference::Entity::insert(billing_preference::ActiveModel {
                 user_id: ActiveValue::set(user_id),
                 max_monthly_llm_usage_spending_in_cents: ActiveValue::set(
@@ -63,7 +65,7 @@ impl Database {
         user_id: UserId,
         params: &UpdateBillingPreferencesParams,
     ) -> Result<billing_preference::Model> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let preferences = billing_preference::Entity::update_many()
                 .set(billing_preference::ActiveModel {
                     max_monthly_llm_usage_spending_in_cents: params
@@ -82,7 +84,7 @@ impl Database {
             Ok(preferences
                 .into_iter()
                 .next()
-                .ok_or_else(|| anyhow!("billing preferences not found"))?)
+                .context("billing preferences not found")?)
         })
         .await
     }

crates/collab/src/db/queries/billing_subscriptions.rs 🔗

@@ -1,3 +1,5 @@
+use anyhow::Context as _;
+
 use crate::db::billing_subscription::{
     StripeCancellationReason, StripeSubscriptionStatus, SubscriptionKind,
 };
@@ -32,9 +34,9 @@ impl Database {
     pub async fn create_billing_subscription(
         &self,
         params: &CreateBillingSubscriptionParams,
-    ) -> Result<()> {
-        self.transaction(|tx| async move {
-            billing_subscription::Entity::insert(billing_subscription::ActiveModel {
+    ) -> Result<billing_subscription::Model> {
+        self.weak_transaction(|tx| async move {
+            let id = billing_subscription::Entity::insert(billing_subscription::ActiveModel {
                 billing_customer_id: ActiveValue::set(params.billing_customer_id),
                 kind: ActiveValue::set(params.kind),
                 stripe_subscription_id: ActiveValue::set(params.stripe_subscription_id.clone()),
@@ -44,10 +46,14 @@ impl Database {
                 stripe_current_period_end: ActiveValue::set(params.stripe_current_period_end),
                 ..Default::default()
             })
-            .exec_without_returning(&*tx)
-            .await?;
+            .exec(&*tx)
+            .await?
+            .last_insert_id;
 
-            Ok(())
+            Ok(billing_subscription::Entity::find_by_id(id)
+                .one(&*tx)
+                .await?
+                .context("failed to retrieve inserted billing subscription")?)
         })
         .await
     }
@@ -58,7 +64,7 @@ impl Database {
         id: BillingSubscriptionId,
         params: &UpdateBillingSubscriptionParams,
     ) -> Result<()> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             billing_subscription::Entity::update(billing_subscription::ActiveModel {
                 id: ActiveValue::set(id),
                 billing_customer_id: params.billing_customer_id.clone(),
@@ -84,7 +90,7 @@ impl Database {
         &self,
         id: BillingSubscriptionId,
     ) -> Result<Option<billing_subscription::Model>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             Ok(billing_subscription::Entity::find_by_id(id)
                 .one(&*tx)
                 .await?)
@@ -97,7 +103,7 @@ impl Database {
         &self,
         stripe_subscription_id: &str,
     ) -> Result<Option<billing_subscription::Model>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             Ok(billing_subscription::Entity::find()
                 .filter(
                     billing_subscription::Column::StripeSubscriptionId.eq(stripe_subscription_id),
@@ -112,7 +118,7 @@ impl Database {
         &self,
         user_id: UserId,
     ) -> Result<Option<billing_subscription::Model>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             Ok(billing_subscription::Entity::find()
                 .inner_join(billing_customer::Entity)
                 .filter(billing_customer::Column::UserId.eq(user_id))
@@ -146,7 +152,7 @@ impl Database {
         &self,
         user_id: UserId,
     ) -> Result<Vec<billing_subscription::Model>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let subscriptions = billing_subscription::Entity::find()
                 .inner_join(billing_customer::Entity)
                 .filter(billing_customer::Column::UserId.eq(user_id))
@@ -163,7 +169,7 @@ impl Database {
         &self,
         user_ids: HashSet<UserId>,
     ) -> Result<HashMap<UserId, (billing_customer::Model, billing_subscription::Model)>> {
-        self.transaction(|tx| {
+        self.weak_transaction(|tx| {
             let user_ids = user_ids.clone();
             async move {
                 let mut rows = billing_subscription::Entity::find()
@@ -195,7 +201,7 @@ impl Database {
         &self,
         user_ids: HashSet<UserId>,
     ) -> Result<HashMap<UserId, (billing_customer::Model, billing_subscription::Model)>> {
-        self.transaction(|tx| {
+        self.weak_transaction(|tx| {
             let user_ids = user_ids.clone();
             async move {
                 let mut rows = billing_subscription::Entity::find()
@@ -230,13 +236,15 @@ impl Database {
 
     /// Returns the count of the active billing subscriptions for the user with the specified ID.
     pub async fn count_active_billing_subscriptions(&self, user_id: UserId) -> Result<usize> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let count = billing_subscription::Entity::find()
                 .inner_join(billing_customer::Entity)
                 .filter(
                     billing_customer::Column::UserId.eq(user_id).and(
                         billing_subscription::Column::StripeSubscriptionStatus
-                            .eq(StripeSubscriptionStatus::Active),
+                            .eq(StripeSubscriptionStatus::Active)
+                            .or(billing_subscription::Column::StripeSubscriptionStatus
+                                .eq(StripeSubscriptionStatus::Trialing)),
                     ),
                 )
                 .count(&*tx)

crates/collab/src/db/queries/buffers.rs 🔗

@@ -1,4 +1,5 @@
 use super::*;
+use anyhow::Context as _;
 use prost::Message;
 use text::{EditOperation, UndoOperation};
 
@@ -117,6 +118,8 @@ impl Database {
                         user_id: collaborator.user_id.to_proto(),
                         replica_id: collaborator.replica_id.0 as u32,
                         is_host: false,
+                        committer_name: None,
+                        committer_email: None,
                     })
                     .collect(),
             })
@@ -224,6 +227,8 @@ impl Database {
                                 user_id: collaborator.user_id.to_proto(),
                                 replica_id: collaborator.replica_id.0 as u32,
                                 is_host: false,
+                                committer_name: None,
+                                committer_email: None,
                             })
                             .collect(),
                     },
@@ -260,6 +265,8 @@ impl Database {
                         replica_id: db_collaborator.replica_id.0 as u32,
                         user_id: db_collaborator.user_id.to_proto(),
                         is_host: false,
+                        committer_name: None,
+                        committer_email: None,
                     })
                 } else {
                     collaborator_ids_to_remove.push(db_collaborator.id);
@@ -389,6 +396,8 @@ impl Database {
                 replica_id: row.replica_id.0 as u32,
                 user_id: row.user_id.to_proto(),
                 is_host: false,
+                committer_name: None,
+                committer_email: None,
             });
         }
 
@@ -467,7 +476,7 @@ impl Database {
                 .filter(buffer::Column::ChannelId.eq(channel_id))
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such buffer"))?;
+                .context("no such buffer")?;
 
             let serialization_version = self
                 .get_buffer_operation_serialization_version(buffer.id, buffer.epoch, &tx)
@@ -606,7 +615,7 @@ impl Database {
             .into_values::<_, QueryOperationSerializationVersion>()
             .one(tx)
             .await?
-            .ok_or_else(|| anyhow!("missing buffer snapshot"))?)
+            .context("missing buffer snapshot")?)
     }
 
     pub async fn get_channel_buffer(
@@ -621,7 +630,7 @@ impl Database {
         .find_related(buffer::Entity)
         .one(tx)
         .await?
-        .ok_or_else(|| anyhow!("no such buffer"))?)
+        .context("no such buffer")?)
     }
 
     async fn get_buffer_state(
@@ -643,7 +652,7 @@ impl Database {
                 )
                 .one(tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such snapshot"))?;
+                .context("no such snapshot")?;
 
             let version = snapshot.operation_serialization_version;
             (snapshot.text, version)
@@ -839,7 +848,7 @@ fn operation_from_storage(
     _format_version: i32,
 ) -> Result<proto::operation::Variant, Error> {
     let operation =
-        storage::Operation::decode(row.value.as_slice()).map_err(|error| anyhow!("{}", error))?;
+        storage::Operation::decode(row.value.as_slice()).map_err(|error| anyhow!("{error}"))?;
     let version = version_from_storage(&operation.version);
     Ok(if operation.is_undo {
         proto::operation::Variant::Undo(proto::operation::Undo {

crates/collab/src/db/queries/channels.rs 🔗

@@ -1,9 +1,10 @@
 use super::*;
+use anyhow::Context as _;
 use rpc::{
     ErrorCode, ErrorCodeExt,
     proto::{ChannelBufferVersion, VectorClockEntry, channel_member::Kind},
 };
-use sea_orm::{DbBackend, TryGetableMany};
+use sea_orm::{ActiveValue, DbBackend, TryGetableMany};
 
 impl Database {
     #[cfg(test)]
@@ -58,16 +59,32 @@ impl Database {
                 parent = Some(parent_channel);
             }
 
+            let parent_path = parent
+                .as_ref()
+                .map_or(String::new(), |parent| parent.path());
+
+            // Find the maximum channel_order among siblings to set the new channel at the end
+            let max_order = if parent_path.is_empty() {
+                0
+            } else {
+                max_order(&parent_path, &tx).await?
+            };
+
+            log::info!(
+                "Creating channel '{}' with parent_path='{}', max_order={}, new_order={}",
+                name,
+                parent_path,
+                max_order,
+                max_order + 1
+            );
+
             let channel = channel::ActiveModel {
                 id: ActiveValue::NotSet,
                 name: ActiveValue::Set(name.to_string()),
                 visibility: ActiveValue::Set(ChannelVisibility::Members),
-                parent_path: ActiveValue::Set(
-                    parent
-                        .as_ref()
-                        .map_or(String::new(), |parent| parent.path()),
-                ),
+                parent_path: ActiveValue::Set(parent_path),
                 requires_zed_cla: ActiveValue::NotSet,
+                channel_order: ActiveValue::Set(max_order + 1),
             }
             .insert(&*tx)
             .await?;
@@ -484,8 +501,10 @@ impl Database {
 
     /// Returns all channels for the user with the given ID.
     pub async fn get_channels_for_user(&self, user_id: UserId) -> Result<ChannelsForUser> {
-        self.transaction(|tx| async move { self.get_user_channels(user_id, None, true, &tx).await })
-            .await
+        self.weak_transaction(
+            |tx| async move { self.get_user_channels(user_id, None, true, &tx).await },
+        )
+        .await
     }
 
     /// Returns all channels for the user with the given ID that are descendants
@@ -530,11 +549,7 @@ impl Database {
             .get_channel_descendants_excluding_self(channels.iter(), tx)
             .await?;
 
-        for channel in channels {
-            if let Err(ix) = descendants.binary_search_by_key(&channel.path(), |c| c.path()) {
-                descendants.insert(ix, channel);
-            }
-        }
+        descendants.extend(channels);
 
         let roles_by_channel_id = channel_memberships
             .iter()
@@ -647,11 +662,8 @@ impl Database {
                         .and(channel_member::Column::UserId.eq(for_user)),
                 )
                 .one(&*tx)
-                .await?;
-
-            let Some(membership) = membership else {
-                Err(anyhow!("no such member"))?
-            };
+                .await?
+                .context("no such member")?;
 
             let mut update = membership.into_active_model();
             update.role = ActiveValue::Set(role);
@@ -722,12 +734,11 @@ impl Database {
                     users.push(proto::User {
                         id: user.id.to_proto(),
                         avatar_url: format!(
-                            "https://github.com/{}.png?size=128",
-                            user.github_login
+                            "https://avatars.githubusercontent.com/u/{}?s=128&v=4",
+                            user.github_user_id
                         ),
                         github_login: user.github_login,
                         name: user.name,
-                        email: user.email_address,
                     })
                 }
                 proto::ChannelMember {
@@ -954,11 +965,14 @@ impl Database {
             }
 
             let root_id = channel.root_id();
+            let new_parent_path = new_parent.path();
             let old_path = format!("{}{}/", channel.parent_path, channel.id);
-            let new_path = format!("{}{}/", new_parent.path(), channel.id);
+            let new_path = format!("{}{}/", &new_parent_path, channel.id);
+            let new_order = max_order(&new_parent_path, &tx).await? + 1;
 
             let mut model = channel.into_active_model();
             model.parent_path = ActiveValue::Set(new_parent.path());
+            model.channel_order = ActiveValue::Set(new_order);
             let channel = model.update(&*tx).await?;
 
             let descendent_ids =
@@ -988,6 +1002,137 @@ impl Database {
         })
         .await
     }
+
+    pub async fn reorder_channel(
+        &self,
+        channel_id: ChannelId,
+        direction: proto::reorder_channel::Direction,
+        user_id: UserId,
+    ) -> Result<Vec<Channel>> {
+        self.transaction(|tx| async move {
+            let mut channel = self.get_channel_internal(channel_id, &tx).await?;
+
+            if channel.is_root() {
+                log::info!("Skipping reorder of root channel {}", channel.id,);
+                return Ok(vec![]);
+            }
+
+            log::info!(
+                "Reordering channel {} (parent_path: '{}', order: {})",
+                channel.id,
+                channel.parent_path,
+                channel.channel_order
+            );
+
+            // Check if user is admin of the channel
+            self.check_user_is_channel_admin(&channel, user_id, &tx)
+                .await?;
+
+            // Find the sibling channel to swap with
+            let sibling_channel = match direction {
+                proto::reorder_channel::Direction::Up => {
+                    log::info!(
+                        "Looking for sibling with parent_path='{}' and order < {}",
+                        channel.parent_path,
+                        channel.channel_order
+                    );
+                    // Find channel with highest order less than current
+                    channel::Entity::find()
+                        .filter(
+                            channel::Column::ParentPath
+                                .eq(&channel.parent_path)
+                                .and(channel::Column::ChannelOrder.lt(channel.channel_order)),
+                        )
+                        .order_by_desc(channel::Column::ChannelOrder)
+                        .one(&*tx)
+                        .await?
+                }
+                proto::reorder_channel::Direction::Down => {
+                    log::info!(
+                        "Looking for sibling with parent_path='{}' and order > {}",
+                        channel.parent_path,
+                        channel.channel_order
+                    );
+                    // Find channel with lowest order greater than current
+                    channel::Entity::find()
+                        .filter(
+                            channel::Column::ParentPath
+                                .eq(&channel.parent_path)
+                                .and(channel::Column::ChannelOrder.gt(channel.channel_order)),
+                        )
+                        .order_by_asc(channel::Column::ChannelOrder)
+                        .one(&*tx)
+                        .await?
+                }
+            };
+
+            let mut sibling_channel = match sibling_channel {
+                Some(sibling) => {
+                    log::info!(
+                        "Found sibling {} (parent_path: '{}', order: {})",
+                        sibling.id,
+                        sibling.parent_path,
+                        sibling.channel_order
+                    );
+                    sibling
+                }
+                None => {
+                    log::warn!("No sibling found to swap with");
+                    // No sibling to swap with
+                    return Ok(vec![]);
+                }
+            };
+
+            let current_order = channel.channel_order;
+            let sibling_order = sibling_channel.channel_order;
+
+            channel::ActiveModel {
+                id: ActiveValue::Unchanged(sibling_channel.id),
+                channel_order: ActiveValue::Set(current_order),
+                ..Default::default()
+            }
+            .update(&*tx)
+            .await?;
+            sibling_channel.channel_order = current_order;
+
+            channel::ActiveModel {
+                id: ActiveValue::Unchanged(channel.id),
+                channel_order: ActiveValue::Set(sibling_order),
+                ..Default::default()
+            }
+            .update(&*tx)
+            .await?;
+            channel.channel_order = sibling_order;
+
+            log::info!(
+                "Reorder complete. Swapped channels {} and {}",
+                channel.id,
+                sibling_channel.id
+            );
+
+            let swapped_channels = vec![
+                Channel::from_model(channel),
+                Channel::from_model(sibling_channel),
+            ];
+
+            Ok(swapped_channels)
+        })
+        .await
+    }
+}
+
+async fn max_order(parent_path: &str, tx: &TransactionHandle) -> Result<i32> {
+    let max_order = channel::Entity::find()
+        .filter(channel::Column::ParentPath.eq(parent_path))
+        .select_only()
+        .column_as(channel::Column::ChannelOrder.max(), "max_order")
+        .into_tuple::<Option<i32>>()
+        .one(&**tx)
+        .await?
+        .flatten()
+        .unwrap_or(0);
+
+    Ok(max_order)
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]

crates/collab/src/db/queries/contacts.rs 🔗

@@ -1,3 +1,5 @@
+use anyhow::Context as _;
+
 use super::*;
 
 impl Database {
@@ -13,7 +15,7 @@ impl Database {
             user_b_busy: bool,
         }
 
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let user_a_participant = Alias::new("user_a_participant");
             let user_b_participant = Alias::new("user_b_participant");
             let mut db_contacts = contact::Entity::find()
@@ -89,7 +91,7 @@ impl Database {
 
     /// Returns whether the given user is a busy (on a call).
     pub async fn is_user_busy(&self, user_id: UserId) -> Result<bool> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let participant = room_participant::Entity::find()
                 .filter(room_participant::Column::UserId.eq(user_id))
                 .one(&*tx)
@@ -215,7 +217,7 @@ impl Database {
                 )
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such contact"))?;
+                .context("no such contact")?;
 
             contact::Entity::delete_by_id(contact.id).exec(&*tx).await?;
 

crates/collab/src/db/queries/contributors.rs 🔗

@@ -9,7 +9,7 @@ pub enum ContributorSelector {
 impl Database {
     /// Retrieves the GitHub logins of all users who have signed the CLA.
     pub async fn get_contributors(&self) -> Result<Vec<String>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
             enum QueryGithubLogin {
                 GithubLogin,
@@ -32,7 +32,7 @@ impl Database {
         &self,
         selector: &ContributorSelector,
     ) -> Result<Option<DateTime>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let condition = match selector {
                 ContributorSelector::GitHubUserId { github_user_id } => {
                     user::Column::GithubUserId.eq(*github_user_id)
@@ -69,9 +69,9 @@ impl Database {
         github_user_created_at: DateTimeUtc,
         initial_channel_id: Option<ChannelId>,
     ) -> Result<()> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let user = self
-                .get_or_create_user_by_github_account_tx(
+                .update_or_create_user_by_github_account_tx(
                     github_login,
                     github_user_id,
                     github_email,

crates/collab/src/db/queries/extensions.rs 🔗

@@ -1,5 +1,6 @@
 use std::str::FromStr;
 
+use anyhow::Context;
 use chrono::Utc;
 use sea_orm::sea_query::IntoCondition;
 use util::ResultExt;
@@ -14,7 +15,7 @@ impl Database {
         max_schema_version: i32,
         limit: usize,
     ) -> Result<Vec<ExtensionMetadata>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let mut condition = Condition::all()
                 .add(
                     extension::Column::LatestVersion
@@ -42,7 +43,7 @@ impl Database {
         ids: &[&str],
         constraints: Option<&ExtensionVersionConstraints>,
     ) -> Result<Vec<ExtensionMetadata>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let extensions = extension::Entity::find()
                 .filter(extension::Column::ExternalId.is_in(ids.iter().copied()))
                 .all(&*tx)
@@ -122,7 +123,7 @@ impl Database {
         &self,
         extension_id: &str,
     ) -> Result<Vec<ExtensionMetadata>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let condition = extension::Column::ExternalId
                 .eq(extension_id)
                 .into_condition();
@@ -161,12 +162,12 @@ impl Database {
         extension_id: &str,
         constraints: Option<&ExtensionVersionConstraints>,
     ) -> Result<Option<ExtensionMetadata>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let extension = extension::Entity::find()
                 .filter(extension::Column::ExternalId.eq(extension_id))
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such extension: {extension_id}"))?;
+                .with_context(|| format!("no such extension: {extension_id}"))?;
 
             let extensions = [extension];
             let mut versions = self
@@ -186,7 +187,7 @@ impl Database {
         extension_id: &str,
         version: &str,
     ) -> Result<Option<ExtensionMetadata>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let extension = extension::Entity::find()
                 .filter(extension::Column::ExternalId.eq(extension_id))
                 .filter(extension_version::Column::Version.eq(version))
@@ -203,7 +204,7 @@ impl Database {
     }
 
     pub async fn get_known_extension_versions(&self) -> Result<HashMap<String, Vec<String>>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let mut extension_external_ids_by_id = HashMap::default();
 
             let mut rows = extension::Entity::find().stream(&*tx).await?;
@@ -241,7 +242,7 @@ impl Database {
         &self,
         versions_by_extension_id: &HashMap<&str, Vec<NewExtensionVersion>>,
     ) -> Result<()> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             for (external_id, versions) in versions_by_extension_id {
                 if versions.is_empty() {
                     continue;
@@ -274,7 +275,7 @@ impl Database {
                         .filter(extension::Column::ExternalId.eq(*external_id))
                         .one(&*tx)
                         .await?
-                        .ok_or_else(|| anyhow!("failed to insert extension"))?
+                        .context("failed to insert extension")?
                 };
 
                 extension_version::Entity::insert_many(versions.iter().map(|version| {
@@ -320,6 +321,9 @@ impl Database {
                         provides_snippets: ActiveValue::Set(
                             version.provides.contains(&ExtensionProvides::Snippets),
                         ),
+                        provides_debug_adapters: ActiveValue::Set(
+                            version.provides.contains(&ExtensionProvides::DebugAdapters),
+                        ),
                         download_count: ActiveValue::NotSet,
                     }
                 }))
@@ -345,7 +349,7 @@ impl Database {
     }
 
     pub async fn record_extension_download(&self, extension: &str, version: &str) -> Result<bool> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
             enum QueryId {
                 Id,
@@ -430,6 +434,10 @@ fn apply_provides_filter(
         condition = condition.add(extension_version::Column::ProvidesSnippets.eq(true));
     }
 
+    if provides_filter.contains(&ExtensionProvides::DebugAdapters) {
+        condition = condition.add(extension_version::Column::ProvidesDebugAdapters.eq(true));
+    }
+
     condition
 }
 

crates/collab/src/db/queries/messages.rs 🔗

@@ -1,4 +1,5 @@
 use super::*;
+use anyhow::Context as _;
 use rpc::Notification;
 use sea_orm::{SelectColumns, TryInsertResult};
 use time::OffsetDateTime;
@@ -330,7 +331,7 @@ impl Database {
                         .filter(channel_message::Column::Nonce.eq(Uuid::from_u128(nonce)))
                         .one(&*tx)
                         .await?
-                        .ok_or_else(|| anyhow!("failed to insert message"))?
+                        .context("failed to insert message")?
                         .id;
                 }
             }

crates/collab/src/db/queries/notifications.rs 🔗

@@ -1,4 +1,5 @@
 use super::*;
+use anyhow::Context as _;
 use rpc::Notification;
 use util::ResultExt;
 
@@ -256,7 +257,7 @@ pub fn model_to_proto(this: &Database, row: notification::Model) -> Result<proto
     let kind = this
         .notification_kinds_by_id
         .get(&row.kind)
-        .ok_or_else(|| anyhow!("Unknown notification kind"))?;
+        .context("Unknown notification kind")?;
     Ok(proto::Notification {
         id: row.id.to_proto(),
         kind: kind.to_string(),
@@ -276,5 +277,5 @@ fn notification_kind_from_proto(
         .notification_kinds_by_name
         .get(&proto.kind)
         .copied()
-        .ok_or_else(|| anyhow!("invalid notification kind {:?}", proto.kind))?)
+        .with_context(|| format!("invalid notification kind {:?}", proto.kind))?)
 }

crates/collab/src/db/queries/processed_stripe_events.rs 🔗

@@ -13,7 +13,7 @@ impl Database {
         &self,
         params: &CreateProcessedStripeEventParams,
     ) -> Result<()> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             processed_stripe_event::Entity::insert(processed_stripe_event::ActiveModel {
                 stripe_event_id: ActiveValue::set(params.stripe_event_id.clone()),
                 stripe_event_type: ActiveValue::set(params.stripe_event_type.clone()),
@@ -35,7 +35,7 @@ impl Database {
         &self,
         event_id: &str,
     ) -> Result<Option<processed_stripe_event::Model>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             Ok(processed_stripe_event::Entity::find_by_id(event_id)
                 .one(&*tx)
                 .await?)
@@ -48,7 +48,7 @@ impl Database {
         &self,
         event_ids: &[&str],
     ) -> Result<Vec<processed_stripe_event::Model>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             Ok(processed_stripe_event::Entity::find()
                 .filter(
                     processed_stripe_event::Column::StripeEventId.is_in(event_ids.iter().copied()),

crates/collab/src/db/queries/projects.rs 🔗

@@ -49,7 +49,7 @@ impl Database {
                 )
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("could not find participant"))?;
+                .context("could not find participant")?;
             if participant.room_id != room_id {
                 return Err(anyhow!("shared project on unexpected room"))?;
             }
@@ -98,7 +98,9 @@ impl Database {
                 user_id: ActiveValue::set(participant.user_id),
                 replica_id: ActiveValue::set(ReplicaId(replica_id)),
                 is_host: ActiveValue::set(true),
-                ..Default::default()
+                id: ActiveValue::NotSet,
+                committer_name: ActiveValue::Set(None),
+                committer_email: ActiveValue::Set(None),
             }
             .insert(&*tx)
             .await?;
@@ -128,7 +130,7 @@ impl Database {
             let project = project::Entity::find_by_id(project_id)
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("project not found"))?;
+                .context("project not found")?;
             let room = if let Some(room_id) = project.room_id {
                 Some(self.get_room(room_id, &tx).await?)
             } else {
@@ -160,7 +162,7 @@ impl Database {
                 )
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such project"))?;
+                .context("no such project")?;
 
             self.update_project_worktrees(project.id, worktrees, &tx)
                 .await?;
@@ -242,7 +244,7 @@ impl Database {
                 )
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such project: {project_id}"))?;
+                .with_context(|| format!("no such project: {project_id}"))?;
 
             // Update metadata.
             worktree::Entity::update(worktree::ActiveModel {
@@ -624,16 +626,13 @@ impl Database {
         let project_id = ProjectId::from_proto(update.project_id);
         let worktree_id = update.worktree_id as i64;
         self.project_transaction(project_id, |tx| async move {
-            let summary = update
-                .summary
-                .as_ref()
-                .ok_or_else(|| anyhow!("invalid summary"))?;
+            let summary = update.summary.as_ref().context("invalid summary")?;
 
             // Ensure the update comes from the host.
             let project = project::Entity::find_by_id(project_id)
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such project"))?;
+                .context("no such project")?;
             if project.host_connection()? != connection {
                 return Err(anyhow!("can't update a project hosted by someone else"))?;
             }
@@ -677,16 +676,13 @@ impl Database {
     ) -> Result<TransactionGuard<Vec<ConnectionId>>> {
         let project_id = ProjectId::from_proto(update.project_id);
         self.project_transaction(project_id, |tx| async move {
-            let server = update
-                .server
-                .as_ref()
-                .ok_or_else(|| anyhow!("invalid language server"))?;
+            let server = update.server.as_ref().context("invalid language server")?;
 
             // Ensure the update comes from the host.
             let project = project::Entity::find_by_id(project_id)
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such project"))?;
+                .context("no such project")?;
             if project.host_connection()? != connection {
                 return Err(anyhow!("can't update a project hosted by someone else"))?;
             }
@@ -732,7 +728,7 @@ impl Database {
             let project = project::Entity::find_by_id(project_id)
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such project"))?;
+                .context("no such project")?;
             if project.host_connection()? != connection {
                 return Err(anyhow!("can't update a project hosted by someone else"))?;
             }
@@ -778,7 +774,7 @@ impl Database {
             Ok(project::Entity::find_by_id(id)
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such project"))?)
+                .context("no such project")?)
         })
         .await
     }
@@ -790,13 +786,27 @@ impl Database {
         project_id: ProjectId,
         connection: ConnectionId,
         user_id: UserId,
+        committer_name: Option<String>,
+        committer_email: Option<String>,
     ) -> Result<TransactionGuard<(Project, ReplicaId)>> {
-        self.project_transaction(project_id, |tx| async move {
-            let (project, role) = self
-                .access_project(project_id, connection, Capability::ReadOnly, &tx)
-                .await?;
-            self.join_project_internal(project, user_id, connection, role, &tx)
+        self.project_transaction(project_id, move |tx| {
+            let committer_name = committer_name.clone();
+            let committer_email = committer_email.clone();
+            async move {
+                let (project, role) = self
+                    .access_project(project_id, connection, Capability::ReadOnly, &tx)
+                    .await?;
+                self.join_project_internal(
+                    project,
+                    user_id,
+                    committer_name,
+                    committer_email,
+                    connection,
+                    role,
+                    &tx,
+                )
                 .await
+            }
         })
         .await
     }
@@ -805,6 +815,8 @@ impl Database {
         &self,
         project: project::Model,
         user_id: UserId,
+        committer_name: Option<String>,
+        committer_email: Option<String>,
         connection: ConnectionId,
         role: ChannelRole,
         tx: &DatabaseTransaction,
@@ -828,7 +840,9 @@ impl Database {
             user_id: ActiveValue::set(user_id),
             replica_id: ActiveValue::set(replica_id),
             is_host: ActiveValue::set(false),
-            ..Default::default()
+            id: ActiveValue::NotSet,
+            committer_name: ActiveValue::set(committer_name),
+            committer_email: ActiveValue::set(committer_email),
         }
         .insert(tx)
         .await?;
@@ -1032,6 +1046,8 @@ impl Database {
                     user_id: collaborator.user_id,
                     replica_id: collaborator.replica_id,
                     is_host: collaborator.is_host,
+                    committer_name: collaborator.committer_name,
+                    committer_email: collaborator.committer_email,
                 })
                 .collect(),
             worktrees,
@@ -1074,7 +1090,7 @@ impl Database {
             let project = project::Entity::find_by_id(project_id)
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such project"))?;
+                .context("no such project")?;
             let collaborators = project
                 .find_related(project_collaborator::Entity)
                 .all(&*tx)
@@ -1143,7 +1159,7 @@ impl Database {
                 )
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("failed to read project host"))?;
+                .context("failed to read project host")?;
 
             Ok(())
         })
@@ -1162,7 +1178,7 @@ impl Database {
         let project = project::Entity::find_by_id(project_id)
             .one(tx)
             .await?
-            .ok_or_else(|| anyhow!("no such project"))?;
+            .context("no such project")?;
 
         let role_from_room = if let Some(room_id) = project.room_id {
             room_participant::Entity::find()
@@ -1287,7 +1303,7 @@ impl Database {
         let project = project::Entity::find_by_id(project_id)
             .one(tx)
             .await?
-            .ok_or_else(|| anyhow!("no such project"))?;
+            .context("no such project")?;
 
         let mut collaborators = project_collaborator::Entity::find()
             .filter(project_collaborator::Column::ProjectId.eq(project_id))

crates/collab/src/db/queries/rooms.rs 🔗

@@ -80,7 +80,7 @@ impl Database {
         &self,
         user_id: UserId,
     ) -> Result<Option<proto::IncomingCall>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             let pending_participant = room_participant::Entity::find()
                 .filter(
                     room_participant::Column::UserId
@@ -161,7 +161,7 @@ impl Database {
                 )
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("user is not in the room"))?;
+                .context("user is not in the room")?;
 
             let called_user_role = match caller.role.unwrap_or(ChannelRole::Member) {
                 ChannelRole::Admin | ChannelRole::Member => ChannelRole::Member,
@@ -193,7 +193,7 @@ impl Database {
 
             let room = self.get_room(room_id, &tx).await?;
             let incoming_call = Self::build_incoming_call(&room, called_user_id)
-                .ok_or_else(|| anyhow!("failed to build incoming call"))?;
+                .context("failed to build incoming call")?;
             Ok((room, incoming_call))
         })
         .await
@@ -279,7 +279,7 @@ impl Database {
                 )
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("no call to cancel"))?;
+                .context("no call to cancel")?;
 
             room_participant::Entity::delete(participant.into_active_model())
                 .exec(&*tx)
@@ -310,7 +310,7 @@ impl Database {
                 .into_values::<_, QueryChannelId>()
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("no such room"))?;
+                .context("no such room")?;
 
             if channel_id.is_some() {
                 Err(anyhow!("tried to join channel call directly"))?
@@ -462,7 +462,7 @@ impl Database {
         }
 
         let (channel, room) = self.get_channel_room(room_id, tx).await?;
-        let channel = channel.ok_or_else(|| anyhow!("no channel for room"))?;
+        let channel = channel.context("no channel for room")?;
         Ok(JoinRoom {
             room,
             channel: Some(channel),
@@ -505,7 +505,7 @@ impl Database {
                 let project = project::Entity::find_by_id(project_id)
                     .one(&*tx)
                     .await?
-                    .ok_or_else(|| anyhow!("project does not exist"))?;
+                    .context("project does not exist")?;
                 if project.host_user_id != Some(user_id) {
                     return Err(anyhow!("no such project"))?;
                 }
@@ -519,7 +519,7 @@ impl Database {
                     .position(|collaborator| {
                         collaborator.user_id == user_id && collaborator.is_host
                     })
-                    .ok_or_else(|| anyhow!("host not found among collaborators"))?;
+                    .context("host not found among collaborators")?;
                 let host = collaborators.swap_remove(host_ix);
                 let old_connection_id = host.connection();
 
@@ -553,6 +553,8 @@ impl Database {
                             user_id: collaborator.user_id,
                             replica_id: collaborator.replica_id,
                             is_host: collaborator.is_host,
+                            committer_name: collaborator.committer_name.clone(),
+                            committer_email: collaborator.committer_email.clone(),
                         })
                         .collect(),
                     worktrees: reshared_project.worktrees.clone(),
@@ -857,6 +859,8 @@ impl Database {
                 user_id: collaborator.user_id,
                 replica_id: collaborator.replica_id,
                 is_host: collaborator.is_host,
+                committer_name: collaborator.committer_name,
+                committer_email: collaborator.committer_email,
             })
             .collect::<Vec<_>>();
 
@@ -1051,11 +1055,7 @@ impl Database {
             let tx = tx;
             let location_kind;
             let location_project_id;
-            match location
-                .variant
-                .as_ref()
-                .ok_or_else(|| anyhow!("invalid location"))?
-            {
+            match location.variant.as_ref().context("invalid location")? {
                 proto::participant_location::Variant::SharedProject(project) => {
                     location_kind = 0;
                     location_project_id = Some(ProjectId::from_proto(project.id));
@@ -1119,7 +1119,7 @@ impl Database {
                 )
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("only admins can set participant role"))?;
+                .context("only admins can set participant role")?;
 
             if role.requires_cla() {
                 self.check_user_has_signed_cla(user_id, room_id, &tx)
@@ -1156,7 +1156,7 @@ impl Database {
         let channel = room::Entity::find_by_id(room_id)
             .one(tx)
             .await?
-            .ok_or_else(|| anyhow!("could not find room"))?
+            .context("could not find room")?
             .find_related(channel::Entity)
             .one(tx)
             .await?;
@@ -1297,7 +1297,7 @@ impl Database {
         let db_room = room::Entity::find_by_id(room_id)
             .one(tx)
             .await?
-            .ok_or_else(|| anyhow!("could not find room"))?;
+            .context("could not find room")?;
 
         let mut db_participants = db_room
             .find_related(room_participant::Entity)

crates/collab/src/db/queries/servers.rs 🔗

@@ -66,6 +66,87 @@ impl Database {
         .await
     }
 
+    /// Delete all channel chat participants from previous servers
+    pub async fn delete_stale_channel_chat_participants(
+        &self,
+        environment: &str,
+        new_server_id: ServerId,
+    ) -> Result<()> {
+        self.transaction(|tx| async move {
+            let stale_server_epochs = self
+                .stale_server_ids(environment, new_server_id, &tx)
+                .await?;
+
+            channel_chat_participant::Entity::delete_many()
+                .filter(
+                    channel_chat_participant::Column::ConnectionServerId
+                        .is_in(stale_server_epochs.iter().copied()),
+                )
+                .exec(&*tx)
+                .await?;
+
+            Ok(())
+        })
+        .await
+    }
+
+    pub async fn clear_old_worktree_entries(&self, server_id: ServerId) -> Result<()> {
+        self.transaction(|tx| async move {
+            use sea_orm::Statement;
+            use sea_orm::sea_query::{Expr, Query};
+
+            loop {
+                let delete_query = Query::delete()
+                    .from_table(worktree_entry::Entity)
+                    .and_where(
+                        Expr::tuple([
+                            Expr::col((worktree_entry::Entity, worktree_entry::Column::ProjectId))
+                                .into(),
+                            Expr::col((worktree_entry::Entity, worktree_entry::Column::WorktreeId))
+                                .into(),
+                            Expr::col((worktree_entry::Entity, worktree_entry::Column::Id)).into(),
+                        ])
+                        .in_subquery(
+                            Query::select()
+                                .columns([
+                                    (worktree_entry::Entity, worktree_entry::Column::ProjectId),
+                                    (worktree_entry::Entity, worktree_entry::Column::WorktreeId),
+                                    (worktree_entry::Entity, worktree_entry::Column::Id),
+                                ])
+                                .from(worktree_entry::Entity)
+                                .inner_join(
+                                    project::Entity,
+                                    Expr::col((project::Entity, project::Column::Id)).equals((
+                                        worktree_entry::Entity,
+                                        worktree_entry::Column::ProjectId,
+                                    )),
+                                )
+                                .and_where(project::Column::HostConnectionServerId.ne(server_id))
+                                .limit(10000)
+                                .to_owned(),
+                        ),
+                    )
+                    .to_owned();
+
+                let statement = Statement::from_sql_and_values(
+                    tx.get_database_backend(),
+                    delete_query
+                        .to_string(sea_orm::sea_query::PostgresQueryBuilder)
+                        .as_str(),
+                    vec![],
+                );
+
+                let result = tx.execute(statement).await?;
+                if result.rows_affected() == 0 {
+                    break;
+                }
+            }
+
+            Ok(())
+        })
+        .await
+    }
+
     /// Deletes any stale servers in the environment that don't match the `new_server_id`.
     pub async fn delete_stale_servers(
         &self,
@@ -86,7 +167,7 @@ impl Database {
         .await
     }
 
-    async fn stale_server_ids(
+    pub async fn stale_server_ids(
         &self,
         environment: &str,
         new_server_id: ServerId,

crates/collab/src/db/queries/users.rs 🔗

@@ -1,3 +1,4 @@
+use anyhow::Context as _;
 use chrono::NaiveDateTime;
 
 use super::*;
@@ -110,7 +111,7 @@ impl Database {
         .await
     }
 
-    pub async fn get_or_create_user_by_github_account(
+    pub async fn update_or_create_user_by_github_account(
         &self,
         github_login: &str,
         github_user_id: i32,
@@ -120,7 +121,7 @@ impl Database {
         initial_channel_id: Option<ChannelId>,
     ) -> Result<User> {
         self.transaction(|tx| async move {
-            self.get_or_create_user_by_github_account_tx(
+            self.update_or_create_user_by_github_account_tx(
                 github_login,
                 github_user_id,
                 github_email,
@@ -134,7 +135,7 @@ impl Database {
         .await
     }
 
-    pub async fn get_or_create_user_by_github_account_tx(
+    pub async fn update_or_create_user_by_github_account_tx(
         &self,
         github_login: &str,
         github_user_id: i32,
@@ -247,7 +248,7 @@ impl Database {
                 .into_values::<_, QueryAs>()
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("could not find user"))?;
+                .context("could not find user")?;
             Ok(metrics_id.to_string())
         })
         .await
@@ -381,7 +382,7 @@ impl Database {
 
     /// Returns the active flags for the user.
     pub async fn get_user_flags(&self, user: UserId) -> Result<Vec<String>> {
-        self.transaction(|tx| async move {
+        self.weak_transaction(|tx| async move {
             #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
             enum QueryAs {
                 Flag,

crates/collab/src/db/tables/billing_subscription.rs 🔗

@@ -1,4 +1,5 @@
 use crate::db::{BillingCustomerId, BillingSubscriptionId};
+use crate::stripe_client;
 use chrono::{Datelike as _, NaiveDate, Utc};
 use sea_orm::entity::prelude::*;
 use serde::Serialize;
@@ -159,3 +160,17 @@ pub enum StripeCancellationReason {
     #[sea_orm(string_value = "payment_failed")]
     PaymentFailed,
 }
+
+impl From<stripe_client::StripeCancellationDetailsReason> for StripeCancellationReason {
+    fn from(value: stripe_client::StripeCancellationDetailsReason) -> Self {
+        match value {
+            stripe_client::StripeCancellationDetailsReason::CancellationRequested => {
+                Self::CancellationRequested
+            }
+            stripe_client::StripeCancellationDetailsReason::PaymentDisputed => {
+                Self::PaymentDisputed
+            }
+            stripe_client::StripeCancellationDetailsReason::PaymentFailed => Self::PaymentFailed,
+        }
+    }
+}

crates/collab/src/db/tables/channel.rs 🔗

@@ -10,6 +10,9 @@ pub struct Model {
     pub visibility: ChannelVisibility,
     pub parent_path: String,
     pub requires_zed_cla: bool,
+    /// The order of this channel relative to its siblings within the same parent.
+    /// Lower values appear first. Channels are sorted by parent_path first, then by channel_order.
+    pub channel_order: i32,
 }
 
 impl Model {

crates/collab/src/db/tables/extension_version.rs 🔗

@@ -27,6 +27,7 @@ pub struct Model {
     pub provides_slash_commands: bool,
     pub provides_indexed_docs_providers: bool,
     pub provides_snippets: bool,
+    pub provides_debug_adapters: bool,
 }
 
 impl Model {
@@ -68,6 +69,10 @@ impl Model {
             provides.insert(ExtensionProvides::Snippets);
         }
 
+        if self.provides_debug_adapters {
+            provides.insert(ExtensionProvides::DebugAdapters);
+        }
+
         provides
     }
 }

crates/collab/src/db/tables/project.rs 🔗

@@ -1,5 +1,5 @@
 use crate::db::{ProjectId, Result, RoomId, ServerId, UserId};
-use anyhow::anyhow;
+use anyhow::Context as _;
 use rpc::ConnectionId;
 use sea_orm::entity::prelude::*;
 
@@ -18,10 +18,10 @@ impl Model {
     pub fn host_connection(&self) -> Result<ConnectionId> {
         let host_connection_server_id = self
             .host_connection_server_id
-            .ok_or_else(|| anyhow!("empty host_connection_server_id"))?;
+            .context("empty host_connection_server_id")?;
         let host_connection_id = self
             .host_connection_id
-            .ok_or_else(|| anyhow!("empty host_connection_id"))?;
+            .context("empty host_connection_id")?;
         Ok(ConnectionId {
             owner_id: host_connection_server_id.0 as u32,
             id: host_connection_id as u32,

crates/collab/src/db/tables/user.rs 🔗

@@ -55,6 +55,11 @@ impl Model {
 
         account_created_at
     }
+
+    /// Returns the age of the user's account.
+    pub fn account_age(&self) -> chrono::Duration {
+        chrono::Utc::now().naive_utc() - self.account_created_at()
+    }
 }
 
 impl Related<super::access_token::Entity> for Entity {

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

@@ -30,7 +30,7 @@ pub struct TestDb {
 }
 
 impl TestDb {
-    pub fn sqlite(background: BackgroundExecutor) -> Self {
+    pub fn sqlite(executor: BackgroundExecutor) -> Self {
         let url = "sqlite::memory:";
         let runtime = tokio::runtime::Builder::new_current_thread()
             .enable_io()
@@ -41,7 +41,7 @@ impl TestDb {
         let mut db = runtime.block_on(async {
             let mut options = ConnectOptions::new(url);
             options.max_connections(5);
-            let mut db = Database::new(options, Executor::Deterministic(background))
+            let mut db = Database::new(options, Executor::Deterministic(executor.clone()))
                 .await
                 .unwrap();
             let sql = include_str!(concat!(
@@ -59,7 +59,10 @@ impl TestDb {
             db
         });
 
-        db.runtime = Some(runtime);
+        db.test_options = Some(DatabaseTestOptions {
+            runtime,
+            query_failure_probability: parking_lot::Mutex::new(0.0),
+        });
 
         Self {
             db: Some(Arc::new(db)),
@@ -67,7 +70,7 @@ impl TestDb {
         }
     }
 
-    pub fn postgres(background: BackgroundExecutor) -> Self {
+    pub fn postgres(executor: BackgroundExecutor) -> Self {
         static LOCK: Mutex<()> = Mutex::new(());
 
         let _guard = LOCK.lock();
@@ -90,7 +93,7 @@ impl TestDb {
             options
                 .max_connections(5)
                 .idle_timeout(Duration::from_secs(0));
-            let mut db = Database::new(options, Executor::Deterministic(background))
+            let mut db = Database::new(options, Executor::Deterministic(executor.clone()))
                 .await
                 .unwrap();
             let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations");
@@ -101,7 +104,10 @@ impl TestDb {
             db
         });
 
-        db.runtime = Some(runtime);
+        db.test_options = Some(DatabaseTestOptions {
+            runtime,
+            query_failure_probability: parking_lot::Mutex::new(0.0),
+        });
 
         Self {
             db: Some(Arc::new(db)),
@@ -112,6 +118,12 @@ impl TestDb {
     pub fn db(&self) -> &Arc<Database> {
         self.db.as_ref().unwrap()
     }
+
+    pub fn set_query_failure_probability(&self, probability: f64) {
+        let database = self.db.as_ref().unwrap();
+        let test_options = database.test_options.as_ref().unwrap();
+        *test_options.query_failure_probability.lock() = probability;
+    }
 }
 
 #[macro_export]
@@ -136,7 +148,7 @@ impl Drop for TestDb {
     fn drop(&mut self) {
         let db = self.db.take().unwrap();
         if let sea_orm::DatabaseBackend::Postgres = db.pool.get_database_backend() {
-            db.runtime.as_ref().unwrap().block_on(async {
+            db.test_options.as_ref().unwrap().runtime.block_on(async {
                 use util::ResultExt;
                 let query = "
                         SELECT pg_terminate_backend(pg_stat_activity.pid)
@@ -160,16 +172,40 @@ impl Drop for TestDb {
     }
 }
 
+#[track_caller]
+fn assert_channel_tree_matches(actual: Vec<Channel>, expected: Vec<Channel>) {
+    let expected_channels = expected.into_iter().collect::<HashSet<_>>();
+    let actual_channels = actual.into_iter().collect::<HashSet<_>>();
+    pretty_assertions::assert_eq!(expected_channels, actual_channels);
+}
+
 fn channel_tree(channels: &[(ChannelId, &[ChannelId], &'static str)]) -> Vec<Channel> {
-    channels
-        .iter()
-        .map(|(id, parent_path, name)| Channel {
+    use std::collections::HashMap;
+
+    let mut result = Vec::new();
+    let mut order_by_parent: HashMap<Vec<ChannelId>, i32> = HashMap::new();
+
+    for (id, parent_path, name) in channels {
+        let parent_key = parent_path.to_vec();
+        let order = if parent_key.is_empty() {
+            1
+        } else {
+            *order_by_parent
+                .entry(parent_key.clone())
+                .and_modify(|e| *e += 1)
+                .or_insert(1)
+        };
+
+        result.push(Channel {
             id: *id,
             name: name.to_string(),
             visibility: ChannelVisibility::Members,
-            parent_path: parent_path.to_vec(),
-        })
-        .collect()
+            parent_path: parent_key,
+            channel_order: order,
+        });
+    }
+
+    result
 }
 
 static GITHUB_USER_ID: AtomicI32 = AtomicI32::new(5);

crates/collab/src/db/tests/buffer_tests.rs 🔗

@@ -126,12 +126,16 @@ async fn test_channel_buffers(db: &Arc<Database>) {
                 peer_id: Some(rpc::proto::PeerId { id: 1, owner_id }),
                 replica_id: 0,
                 is_host: false,
+                committer_name: None,
+                committer_email: None,
             },
             rpc::proto::Collaborator {
                 user_id: b_id.to_proto(),
                 peer_id: Some(rpc::proto::PeerId { id: 2, owner_id }),
                 replica_id: 1,
                 is_host: false,
+                committer_name: None,
+                committer_email: None,
             }
         ]
     );

crates/collab/src/db/tests/channel_tests.rs 🔗

@@ -1,15 +1,15 @@
 use crate::{
     db::{
         Channel, ChannelId, ChannelRole, Database, NewUserParams, RoomId, UserId,
-        tests::{channel_tree, new_test_connection, new_test_user},
+        tests::{assert_channel_tree_matches, channel_tree, new_test_connection, new_test_user},
     },
     test_both_dbs,
 };
 use rpc::{
     ConnectionId,
-    proto::{self},
+    proto::{self, reorder_channel},
 };
-use std::sync::Arc;
+use std::{collections::HashSet, sync::Arc};
 
 test_both_dbs!(test_channels, test_channels_postgres, test_channels_sqlite);
 
@@ -59,28 +59,28 @@ async fn test_channels(db: &Arc<Database>) {
         .unwrap();
 
     let result = db.get_channels_for_user(a_id).await.unwrap();
-    assert_eq!(
+    assert_channel_tree_matches(
         result.channels,
         channel_tree(&[
             (zed_id, &[], "zed"),
             (crdb_id, &[zed_id], "crdb"),
-            (livestreaming_id, &[zed_id], "livestreaming",),
+            (livestreaming_id, &[zed_id], "livestreaming"),
             (replace_id, &[zed_id], "replace"),
             (rust_id, &[], "rust"),
             (cargo_id, &[rust_id], "cargo"),
-            (cargo_ra_id, &[rust_id, cargo_id], "cargo-ra",)
-        ],)
+            (cargo_ra_id, &[rust_id, cargo_id], "cargo-ra"),
+        ]),
     );
 
     let result = db.get_channels_for_user(b_id).await.unwrap();
-    assert_eq!(
+    assert_channel_tree_matches(
         result.channels,
         channel_tree(&[
             (zed_id, &[], "zed"),
             (crdb_id, &[zed_id], "crdb"),
-            (livestreaming_id, &[zed_id], "livestreaming",),
-            (replace_id, &[zed_id], "replace")
-        ],)
+            (livestreaming_id, &[zed_id], "livestreaming"),
+            (replace_id, &[zed_id], "replace"),
+        ]),
     );
 
     // Update member permissions
@@ -94,14 +94,14 @@ async fn test_channels(db: &Arc<Database>) {
     assert!(set_channel_admin.is_ok());
 
     let result = db.get_channels_for_user(b_id).await.unwrap();
-    assert_eq!(
+    assert_channel_tree_matches(
         result.channels,
         channel_tree(&[
             (zed_id, &[], "zed"),
             (crdb_id, &[zed_id], "crdb"),
-            (livestreaming_id, &[zed_id], "livestreaming",),
-            (replace_id, &[zed_id], "replace")
-        ],)
+            (livestreaming_id, &[zed_id], "livestreaming"),
+            (replace_id, &[zed_id], "replace"),
+        ]),
     );
 
     // Remove a single channel
@@ -313,8 +313,8 @@ async fn test_channel_renames(db: &Arc<Database>) {
 
 test_both_dbs!(
     test_db_channel_moving,
-    test_channels_moving_postgres,
-    test_channels_moving_sqlite
+    test_db_channel_moving_postgres,
+    test_db_channel_moving_sqlite
 );
 
 async fn test_db_channel_moving(db: &Arc<Database>) {
@@ -343,16 +343,14 @@ async fn test_db_channel_moving(db: &Arc<Database>) {
         .await
         .unwrap();
 
-    let livestreaming_dag_id = db
-        .create_sub_channel("livestreaming_dag", livestreaming_id, a_id)
+    let livestreaming_sub_id = db
+        .create_sub_channel("livestreaming_sub", livestreaming_id, a_id)
         .await
         .unwrap();
 
-    // ========================================================================
     // sanity check
-    // Initial DAG:
     //     /- gpui2
-    // zed -- crdb - livestreaming - livestreaming_dag
+    // zed -- crdb - livestreaming - livestreaming_sub
     let result = db.get_channels_for_user(a_id).await.unwrap();
     assert_channel_tree(
         result.channels,
@@ -360,10 +358,242 @@ async fn test_db_channel_moving(db: &Arc<Database>) {
             (zed_id, &[]),
             (crdb_id, &[zed_id]),
             (livestreaming_id, &[zed_id, crdb_id]),
-            (livestreaming_dag_id, &[zed_id, crdb_id, livestreaming_id]),
+            (livestreaming_sub_id, &[zed_id, crdb_id, livestreaming_id]),
             (gpui2_id, &[zed_id]),
         ],
     );
+
+    // Check that we can do a simple leaf -> leaf move
+    db.move_channel(livestreaming_sub_id, crdb_id, a_id)
+        .await
+        .unwrap();
+
+    //     /- gpui2
+    // zed -- crdb -- livestreaming
+    //             \- livestreaming_sub
+    let result = db.get_channels_for_user(a_id).await.unwrap();
+    assert_channel_tree(
+        result.channels,
+        &[
+            (zed_id, &[]),
+            (crdb_id, &[zed_id]),
+            (livestreaming_id, &[zed_id, crdb_id]),
+            (livestreaming_sub_id, &[zed_id, crdb_id]),
+            (gpui2_id, &[zed_id]),
+        ],
+    );
+
+    // Check that we can move a whole subtree at once
+    db.move_channel(crdb_id, gpui2_id, a_id).await.unwrap();
+
+    // zed -- gpui2 -- crdb -- livestreaming
+    //                      \- livestreaming_sub
+    let result = db.get_channels_for_user(a_id).await.unwrap();
+    assert_channel_tree(
+        result.channels,
+        &[
+            (zed_id, &[]),
+            (gpui2_id, &[zed_id]),
+            (crdb_id, &[zed_id, gpui2_id]),
+            (livestreaming_id, &[zed_id, gpui2_id, crdb_id]),
+            (livestreaming_sub_id, &[zed_id, gpui2_id, crdb_id]),
+        ],
+    );
+}
+
+test_both_dbs!(
+    test_channel_reordering,
+    test_channel_reordering_postgres,
+    test_channel_reordering_sqlite
+);
+
+async fn test_channel_reordering(db: &Arc<Database>) {
+    let admin_id = db
+        .create_user(
+            "admin@example.com",
+            None,
+            false,
+            NewUserParams {
+                github_login: "admin".into(),
+                github_user_id: 1,
+            },
+        )
+        .await
+        .unwrap()
+        .user_id;
+
+    let user_id = db
+        .create_user(
+            "user@example.com",
+            None,
+            false,
+            NewUserParams {
+                github_login: "user".into(),
+                github_user_id: 2,
+            },
+        )
+        .await
+        .unwrap()
+        .user_id;
+
+    // Create a root channel with some sub-channels
+    let root_id = db.create_root_channel("root", admin_id).await.unwrap();
+
+    // Invite user to root channel so they can see the sub-channels
+    db.invite_channel_member(root_id, user_id, admin_id, ChannelRole::Member)
+        .await
+        .unwrap();
+    db.respond_to_channel_invite(root_id, user_id, true)
+        .await
+        .unwrap();
+
+    let alpha_id = db
+        .create_sub_channel("alpha", root_id, admin_id)
+        .await
+        .unwrap();
+    let beta_id = db
+        .create_sub_channel("beta", root_id, admin_id)
+        .await
+        .unwrap();
+    let gamma_id = db
+        .create_sub_channel("gamma", root_id, admin_id)
+        .await
+        .unwrap();
+
+    // Initial order should be: root, alpha (order=1), beta (order=2), gamma (order=3)
+    let result = db.get_channels_for_user(admin_id).await.unwrap();
+    assert_channel_tree_order(
+        result.channels,
+        &[
+            (root_id, &[], 1),
+            (alpha_id, &[root_id], 1),
+            (beta_id, &[root_id], 2),
+            (gamma_id, &[root_id], 3),
+        ],
+    );
+
+    // Test moving beta up (should swap with alpha)
+    let updated_channels = db
+        .reorder_channel(beta_id, reorder_channel::Direction::Up, admin_id)
+        .await
+        .unwrap();
+
+    // Verify that beta and alpha were returned as updated
+    assert_eq!(updated_channels.len(), 2);
+    let updated_ids: std::collections::HashSet<_> = updated_channels.iter().map(|c| c.id).collect();
+    assert!(updated_ids.contains(&alpha_id));
+    assert!(updated_ids.contains(&beta_id));
+
+    // Now order should be: root, beta (order=1), alpha (order=2), gamma (order=3)
+    let result = db.get_channels_for_user(admin_id).await.unwrap();
+    assert_channel_tree_order(
+        result.channels,
+        &[
+            (root_id, &[], 1),
+            (beta_id, &[root_id], 1),
+            (alpha_id, &[root_id], 2),
+            (gamma_id, &[root_id], 3),
+        ],
+    );
+
+    // Test moving gamma down (should be no-op since it's already last)
+    let updated_channels = db
+        .reorder_channel(gamma_id, reorder_channel::Direction::Down, admin_id)
+        .await
+        .unwrap();
+
+    // Should return just nothing
+    assert_eq!(updated_channels.len(), 0);
+
+    // Test moving alpha down (should swap with gamma)
+    let updated_channels = db
+        .reorder_channel(alpha_id, reorder_channel::Direction::Down, admin_id)
+        .await
+        .unwrap();
+
+    // Verify that alpha and gamma were returned as updated
+    assert_eq!(updated_channels.len(), 2);
+    let updated_ids: std::collections::HashSet<_> = updated_channels.iter().map(|c| c.id).collect();
+    assert!(updated_ids.contains(&alpha_id));
+    assert!(updated_ids.contains(&gamma_id));
+
+    // Now order should be: root, beta (order=1), gamma (order=2), alpha (order=3)
+    let result = db.get_channels_for_user(admin_id).await.unwrap();
+    assert_channel_tree_order(
+        result.channels,
+        &[
+            (root_id, &[], 1),
+            (beta_id, &[root_id], 1),
+            (gamma_id, &[root_id], 2),
+            (alpha_id, &[root_id], 3),
+        ],
+    );
+
+    // Test that non-admin cannot reorder
+    let reorder_result = db
+        .reorder_channel(beta_id, reorder_channel::Direction::Up, user_id)
+        .await;
+    assert!(reorder_result.is_err());
+
+    // Test moving beta up (should be no-op since it's already first)
+    let updated_channels = db
+        .reorder_channel(beta_id, reorder_channel::Direction::Up, admin_id)
+        .await
+        .unwrap();
+
+    // Should return nothing
+    assert_eq!(updated_channels.len(), 0);
+
+    // Adding a channel to an existing ordering should add it to the end
+    let delta_id = db
+        .create_sub_channel("delta", root_id, admin_id)
+        .await
+        .unwrap();
+
+    let result = db.get_channels_for_user(admin_id).await.unwrap();
+    assert_channel_tree_order(
+        result.channels,
+        &[
+            (root_id, &[], 1),
+            (beta_id, &[root_id], 1),
+            (gamma_id, &[root_id], 2),
+            (alpha_id, &[root_id], 3),
+            (delta_id, &[root_id], 4),
+        ],
+    );
+
+    // And moving a channel into an existing ordering should add it to the end
+    let eta_id = db
+        .create_sub_channel("eta", delta_id, admin_id)
+        .await
+        .unwrap();
+
+    let result = db.get_channels_for_user(admin_id).await.unwrap();
+    assert_channel_tree_order(
+        result.channels,
+        &[
+            (root_id, &[], 1),
+            (beta_id, &[root_id], 1),
+            (gamma_id, &[root_id], 2),
+            (alpha_id, &[root_id], 3),
+            (delta_id, &[root_id], 4),
+            (eta_id, &[root_id, delta_id], 1),
+        ],
+    );
+
+    db.move_channel(eta_id, root_id, admin_id).await.unwrap();
+    let result = db.get_channels_for_user(admin_id).await.unwrap();
+    assert_channel_tree_order(
+        result.channels,
+        &[
+            (root_id, &[], 1),
+            (beta_id, &[root_id], 1),
+            (gamma_id, &[root_id], 2),
+            (alpha_id, &[root_id], 3),
+            (delta_id, &[root_id], 4),
+            (eta_id, &[root_id], 5),
+        ],
+    );
 }
 
 test_both_dbs!(
@@ -422,6 +652,20 @@ async fn test_db_channel_moving_bugs(db: &Arc<Database>) {
             (livestreaming_id, &[zed_id, projects_id]),
         ],
     );
+
+    // Can't un-root a root channel
+    db.move_channel(zed_id, livestreaming_id, user_id)
+        .await
+        .unwrap_err();
+    let result = db.get_channels_for_user(user_id).await.unwrap();
+    assert_channel_tree(
+        result.channels,
+        &[
+            (zed_id, &[]),
+            (projects_id, &[zed_id]),
+            (livestreaming_id, &[zed_id, projects_id]),
+        ],
+    );
 }
 
 test_both_dbs!(
@@ -745,10 +989,29 @@ fn assert_channel_tree(actual: Vec<Channel>, expected: &[(ChannelId, &[ChannelId
     let actual = actual
         .iter()
         .map(|channel| (channel.id, channel.parent_path.as_slice()))
-        .collect::<Vec<_>>();
-    pretty_assertions::assert_eq!(
-        actual,
-        expected.to_vec(),
-        "wrong channel ids and parent paths"
-    );
+        .collect::<HashSet<_>>();
+    let expected = expected
+        .iter()
+        .map(|(id, parents)| (*id, *parents))
+        .collect::<HashSet<_>>();
+    pretty_assertions::assert_eq!(actual, expected, "wrong channel ids and parent paths");
+}
+
+#[track_caller]
+fn assert_channel_tree_order(actual: Vec<Channel>, expected: &[(ChannelId, &[ChannelId], i32)]) {
+    let actual = actual
+        .iter()
+        .map(|channel| {
+            (
+                channel.id,
+                channel.parent_path.as_slice(),
+                channel.channel_order,
+            )
+        })
+        .collect::<HashSet<_>>();
+    let expected = expected
+        .iter()
+        .map(|(id, parents, order)| (*id, *parents, *order))
+        .collect::<HashSet<_>>();
+    pretty_assertions::assert_eq!(actual, expected, "wrong channel ids and parent paths");
 }

crates/collab/src/db/tests/db_tests.rs 🔗

@@ -72,12 +72,12 @@ async fn test_get_users(db: &Arc<Database>) {
 }
 
 test_both_dbs!(
-    test_get_or_create_user_by_github_account,
-    test_get_or_create_user_by_github_account_postgres,
-    test_get_or_create_user_by_github_account_sqlite
+    test_update_or_create_user_by_github_account,
+    test_update_or_create_user_by_github_account_postgres,
+    test_update_or_create_user_by_github_account_sqlite
 );
 
-async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
+async fn test_update_or_create_user_by_github_account(db: &Arc<Database>) {
     db.create_user(
         "user1@example.com",
         None,
@@ -104,7 +104,14 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
         .user_id;
 
     let user = db
-        .get_or_create_user_by_github_account("the-new-login2", 102, None, None, Utc::now(), None)
+        .update_or_create_user_by_github_account(
+            "the-new-login2",
+            102,
+            None,
+            None,
+            Utc::now(),
+            None,
+        )
         .await
         .unwrap();
     assert_eq!(user.id, user_id2);
@@ -112,7 +119,7 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
     assert_eq!(user.github_user_id, 102);
 
     let user = db
-        .get_or_create_user_by_github_account(
+        .update_or_create_user_by_github_account(
             "login3",
             103,
             Some("user3@example.com"),

crates/collab/src/db/tests/embedding_tests.rs 🔗

@@ -76,7 +76,10 @@ async fn test_purge_old_embeddings(cx: &mut gpui::TestAppContext) {
     db.purge_old_embeddings().await.unwrap();
 
     // Try to retrieve the purged embeddings
-    let retrieved_embeddings = db.get_embeddings(model, &[digest.clone()]).await.unwrap();
+    let retrieved_embeddings = db
+        .get_embeddings(model, std::slice::from_ref(&digest))
+        .await
+        .unwrap();
     assert!(
         retrieved_embeddings.is_empty(),
         "Old embeddings should have been purged"

crates/collab/src/env.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use std::fs;
 use std::path::Path;
 
@@ -6,8 +6,8 @@ pub fn get_dotenv_vars(current_dir: impl AsRef<Path>) -> Result<Vec<(String, Str
     let current_dir = current_dir.as_ref();
 
     let mut vars = Vec::new();
-    let env_content = fs::read_to_string(current_dir.join(".env.toml"))
-        .map_err(|_| anyhow!("no .env.toml file found"))?;
+    let env_content =
+        fs::read_to_string(current_dir.join(".env.toml")).context("no .env.toml file found")?;
 
     add_vars(env_content, &mut vars)?;
 

crates/collab/src/lib.rs 🔗

@@ -9,12 +9,13 @@ pub mod migrations;
 pub mod rpc;
 pub mod seed;
 pub mod stripe_billing;
+pub mod stripe_client;
 pub mod user_backfiller;
 
 #[cfg(test)]
 mod tests;
 
-use anyhow::anyhow;
+use anyhow::Context as _;
 use aws_config::{BehaviorVersion, Region};
 use axum::{
     http::{HeaderMap, StatusCode},
@@ -29,6 +30,7 @@ use std::{path::PathBuf, sync::Arc};
 use util::ResultExt;
 
 use crate::stripe_billing::StripeBilling;
+use crate::stripe_client::{RealStripeClient, StripeClient};
 
 pub type Result<T, E = Error> = std::result::Result<T, E>;
 
@@ -269,7 +271,10 @@ pub struct AppState {
     pub llm_db: Option<Arc<LlmDatabase>>,
     pub livekit_client: Option<Arc<dyn livekit_api::Client>>,
     pub blob_store_client: Option<aws_sdk_s3::Client>,
-    pub stripe_client: Option<Arc<stripe::Client>>,
+    /// This is a real instance of the Stripe client; we're working to replace references to this with the
+    /// [`StripeClient`] trait.
+    pub real_stripe_client: Option<Arc<stripe::Client>>,
+    pub stripe_client: Option<Arc<dyn StripeClient>>,
     pub stripe_billing: Option<Arc<StripeBilling>>,
     pub executor: Executor,
     pub kinesis_client: Option<::aws_sdk_kinesis::Client>,
@@ -322,7 +327,9 @@ impl AppState {
             stripe_billing: stripe_client
                 .clone()
                 .map(|stripe_client| Arc::new(StripeBilling::new(stripe_client))),
-            stripe_client,
+            real_stripe_client: stripe_client.clone(),
+            stripe_client: stripe_client
+                .map(|stripe_client| Arc::new(RealStripeClient::new(stripe_client)) as _),
             executor,
             kinesis_client: if config.kinesis_access_key.is_some() {
                 build_kinesis_client(&config).await.log_err()
@@ -339,7 +346,7 @@ fn build_stripe_client(config: &Config) -> anyhow::Result<stripe::Client> {
     let api_key = config
         .stripe_api_key
         .as_ref()
-        .ok_or_else(|| anyhow!("missing stripe_api_key"))?;
+        .context("missing stripe_api_key")?;
     Ok(stripe::Client::new(api_key))
 }
 
@@ -348,11 +355,11 @@ async fn build_blob_store_client(config: &Config) -> anyhow::Result<aws_sdk_s3::
         config
             .blob_store_access_key
             .clone()
-            .ok_or_else(|| anyhow!("missing blob_store_access_key"))?,
+            .context("missing blob_store_access_key")?,
         config
             .blob_store_secret_key
             .clone()
-            .ok_or_else(|| anyhow!("missing blob_store_secret_key"))?,
+            .context("missing blob_store_secret_key")?,
         None,
         None,
         "env",
@@ -363,13 +370,13 @@ async fn build_blob_store_client(config: &Config) -> anyhow::Result<aws_sdk_s3::
             config
                 .blob_store_url
                 .as_ref()
-                .ok_or_else(|| anyhow!("missing blob_store_url"))?,
+                .context("missing blob_store_url")?,
         )
         .region(Region::new(
             config
                 .blob_store_region
                 .clone()
-                .ok_or_else(|| anyhow!("missing blob_store_region"))?,
+                .context("missing blob_store_region")?,
         ))
         .credentials_provider(keys)
         .load()
@@ -383,11 +390,11 @@ async fn build_kinesis_client(config: &Config) -> anyhow::Result<aws_sdk_kinesis
         config
             .kinesis_access_key
             .clone()
-            .ok_or_else(|| anyhow!("missing kinesis_access_key"))?,
+            .context("missing kinesis_access_key")?,
         config
             .kinesis_secret_key
             .clone()
-            .ok_or_else(|| anyhow!("missing kinesis_secret_key"))?,
+            .context("missing kinesis_secret_key")?,
         None,
         None,
         "env",
@@ -398,7 +405,7 @@ async fn build_kinesis_client(config: &Config) -> anyhow::Result<aws_sdk_kinesis
             config
                 .kinesis_region
                 .clone()
-                .ok_or_else(|| anyhow!("missing kinesis_region"))?,
+                .context("missing kinesis_region")?,
         ))
         .credentials_provider(keys)
         .load()

crates/collab/src/llm.rs 🔗

@@ -7,9 +7,11 @@ pub use token::*;
 
 pub const AGENT_EXTENDED_TRIAL_FEATURE_FLAG: &str = "agent-extended-trial";
 
-/// The maximum monthly spending an individual user can reach on the free tier
-/// before they have to pay.
-pub const FREE_TIER_MONTHLY_SPENDING_LIMIT: Cents = Cents::from_dollars(10);
+/// The name of the feature flag that bypasses the account age check.
+pub const BYPASS_ACCOUNT_AGE_CHECK_FEATURE_FLAG: &str = "bypass-account-age-check";
+
+/// The minimum account age an account must have in order to use the LLM service.
+pub const MIN_ACCOUNT_AGE_FOR_LLM_USE: chrono::Duration = chrono::Duration::days(30);
 
 /// The default value to use for maximum spend per month if the user did not
 /// explicitly set a maximum spend.

crates/collab/src/llm/db.rs 🔗

@@ -19,7 +19,7 @@ use usage_measure::UsageMeasure;
 use std::future::Future;
 use std::sync::Arc;
 
-use anyhow::anyhow;
+use anyhow::Context;
 pub use sea_orm::ConnectOptions;
 use sea_orm::prelude::*;
 use sea_orm::{
@@ -93,7 +93,7 @@ impl LlmDatabase {
         Ok(self
             .models
             .get(&(provider, name.to_string()))
-            .ok_or_else(|| anyhow!("unknown model {provider:?}:{name}"))?)
+            .with_context(|| format!("unknown model {provider:?}:{name}"))?)
     }
 
     pub fn model_by_id(&self, id: ModelId) -> Result<&model::Model> {
@@ -101,7 +101,7 @@ impl LlmDatabase {
             .models
             .values()
             .find(|model| model.id == id)
-            .ok_or_else(|| anyhow!("no model for ID {id:?}"))?)
+            .with_context(|| format!("no model for ID {id:?}"))?)
     }
 
     pub fn options(&self) -> &ConnectOptions {
@@ -142,11 +142,9 @@ impl LlmDatabase {
 
         let mut tx = Arc::new(Some(tx));
         let result = f(TransactionHandle(tx.clone())).await;
-        let Some(tx) = Arc::get_mut(&mut tx).and_then(|tx| tx.take()) else {
-            return Err(anyhow!(
-                "couldn't complete transaction because it's still in use"
-            ))?;
-        };
+        let tx = Arc::get_mut(&mut tx)
+            .and_then(|tx| tx.take())
+            .context("couldn't complete transaction because it's still in use")?;
 
         Ok((tx, result))
     }

crates/collab/src/llm/db/queries/usages.rs 🔗

@@ -1,7 +1,3 @@
-use crate::db::UserId;
-use crate::llm::Cents;
-use chrono::Datelike;
-use futures::StreamExt as _;
 use std::str::FromStr;
 use strum::IntoEnumIterator as _;
 
@@ -45,68 +41,4 @@ impl LlmDatabase {
             .collect();
         Ok(())
     }
-
-    pub async fn get_user_spending_for_month(
-        &self,
-        user_id: UserId,
-        now: DateTimeUtc,
-    ) -> Result<Cents> {
-        self.transaction(|tx| async move {
-            let month = now.date_naive().month() as i32;
-            let year = now.date_naive().year();
-
-            let mut monthly_usages = monthly_usage::Entity::find()
-                .filter(
-                    monthly_usage::Column::UserId
-                        .eq(user_id)
-                        .and(monthly_usage::Column::Month.eq(month))
-                        .and(monthly_usage::Column::Year.eq(year)),
-                )
-                .stream(&*tx)
-                .await?;
-            let mut monthly_spending = Cents::ZERO;
-
-            while let Some(usage) = monthly_usages.next().await {
-                let usage = usage?;
-                let Ok(model) = self.model_by_id(usage.model_id) else {
-                    continue;
-                };
-
-                monthly_spending += calculate_spending(
-                    model,
-                    usage.input_tokens as usize,
-                    usage.cache_creation_input_tokens as usize,
-                    usage.cache_read_input_tokens as usize,
-                    usage.output_tokens as usize,
-                );
-            }
-
-            Ok(monthly_spending)
-        })
-        .await
-    }
-}
-
-fn calculate_spending(
-    model: &model::Model,
-    input_tokens_this_month: usize,
-    cache_creation_input_tokens_this_month: usize,
-    cache_read_input_tokens_this_month: usize,
-    output_tokens_this_month: usize,
-) -> Cents {
-    let input_token_cost =
-        input_tokens_this_month * model.price_per_million_input_tokens as usize / 1_000_000;
-    let cache_creation_input_token_cost = cache_creation_input_tokens_this_month
-        * model.price_per_million_cache_creation_input_tokens as usize
-        / 1_000_000;
-    let cache_read_input_token_cost = cache_read_input_tokens_this_month
-        * model.price_per_million_cache_read_input_tokens as usize
-        / 1_000_000;
-    let output_token_cost =
-        output_tokens_this_month * model.price_per_million_output_tokens as usize / 1_000_000;
-    let spending = input_token_cost
-        + cache_creation_input_token_cost
-        + cache_read_input_token_cost
-        + output_token_cost;
-    Cents::new(spending as u32)
 }

crates/collab/src/llm/db/tables/monthly_usage.rs 🔗

@@ -1,22 +0,0 @@
-use crate::{db::UserId, llm::db::ModelId};
-use sea_orm::entity::prelude::*;
-
-#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
-#[sea_orm(table_name = "monthly_usages")]
-pub struct Model {
-    #[sea_orm(primary_key)]
-    pub id: i32,
-    pub user_id: UserId,
-    pub model_id: ModelId,
-    pub month: i32,
-    pub year: i32,
-    pub input_tokens: i64,
-    pub cache_creation_input_tokens: i64,
-    pub cache_read_input_tokens: i64,
-    pub output_tokens: i64,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {}
-
-impl ActiveModelBehavior for ActiveModel {}

crates/collab/src/llm/token.rs 🔗

@@ -1,8 +1,8 @@
 use crate::db::billing_subscription::SubscriptionKind;
-use crate::db::{billing_subscription, user};
-use crate::llm::AGENT_EXTENDED_TRIAL_FEATURE_FLAG;
+use crate::db::{billing_customer, billing_subscription, user};
+use crate::llm::{AGENT_EXTENDED_TRIAL_FEATURE_FLAG, BYPASS_ACCOUNT_AGE_CHECK_FEATURE_FLAG};
 use crate::{Config, db::billing_preference};
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use chrono::{NaiveDateTime, Utc};
 use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
 use serde::{Deserialize, Serialize};
@@ -32,6 +32,8 @@ pub struct LlmTokenClaims {
     pub enable_model_request_overages: bool,
     pub model_request_overages_spend_limit_in_cents: u32,
     pub can_use_web_search_tool: bool,
+    #[serde(default)]
+    pub has_overdue_invoices: bool,
 }
 
 const LLM_TOKEN_LIFETIME: Duration = Duration::from_secs(60 * 60);
@@ -40,33 +42,31 @@ impl LlmTokenClaims {
     pub fn create(
         user: &user::Model,
         is_staff: bool,
+        billing_customer: billing_customer::Model,
         billing_preferences: Option<billing_preference::Model>,
         feature_flags: &Vec<String>,
-        subscription: Option<billing_subscription::Model>,
+        subscription: billing_subscription::Model,
         system_id: Option<String>,
         config: &Config,
     ) -> Result<String> {
         let secret = config
             .llm_api_secret
             .as_ref()
-            .ok_or_else(|| anyhow!("no LLM API secret"))?;
+            .context("no LLM API secret")?;
 
         let plan = if is_staff {
             Plan::ZedPro
         } else {
-            subscription
-                .as_ref()
-                .and_then(|subscription| subscription.kind)
-                .map_or(Plan::ZedFree, |kind| match kind {
-                    SubscriptionKind::ZedFree => Plan::ZedFree,
-                    SubscriptionKind::ZedPro => Plan::ZedPro,
-                    SubscriptionKind::ZedProTrial => Plan::ZedProTrial,
-                })
+            subscription.kind.map_or(Plan::ZedFree, |kind| match kind {
+                SubscriptionKind::ZedFree => Plan::ZedFree,
+                SubscriptionKind::ZedPro => Plan::ZedPro,
+                SubscriptionKind::ZedProTrial => Plan::ZedProTrial,
+            })
         };
         let subscription_period =
-            billing_subscription::Model::current_period(subscription, is_staff)
+            billing_subscription::Model::current_period(Some(subscription), is_staff)
                 .map(|(start, end)| (start.naive_utc(), end.naive_utc()))
-                .ok_or_else(|| anyhow!("A plan is required to use Zed's hosted models or edit predictions. Visit https://zed.dev/account to get started."))?;
+                .context("A plan is required to use Zed's hosted models or edit predictions. Visit https://zed.dev/account to get started.")?;
 
         let now = Utc::now();
         let claims = Self {
@@ -84,7 +84,7 @@ impl LlmTokenClaims {
                 .any(|flag| flag == "llm-closed-beta"),
             bypass_account_age_check: feature_flags
                 .iter()
-                .any(|flag| flag == "bypass-account-age-check"),
+                .any(|flag| flag == BYPASS_ACCOUNT_AGE_CHECK_FEATURE_FLAG),
             can_use_web_search_tool: true,
             use_llm_request_queue: feature_flags.iter().any(|flag| flag == "llm-request-queue"),
             plan,
@@ -102,6 +102,7 @@ impl LlmTokenClaims {
                 .map_or(0, |preferences| {
                     preferences.model_request_overages_spend_limit_in_cents as u32
                 }),
+            has_overdue_invoices: billing_customer.has_overdue_invoices,
         };
 
         Ok(jsonwebtoken::encode(
@@ -115,7 +116,7 @@ impl LlmTokenClaims {
         let secret = config
             .llm_api_secret
             .as_ref()
-            .ok_or_else(|| anyhow!("no LLM API secret"))?;
+            .context("no LLM API secret")?;
 
         match jsonwebtoken::decode::<Self>(
             token,

crates/collab/src/main.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::anyhow;
+use anyhow::{Context as _, anyhow};
 use axum::headers::HeaderMapExt;
 use axum::{
     Extension, Router,
@@ -36,6 +36,7 @@ use util::{ResultExt as _, maybe};
 const VERSION: &str = env!("CARGO_PKG_VERSION");
 const REVISION: Option<&'static str> = option_env!("GITHUB_SHA");
 
+#[expect(clippy::result_large_err)]
 #[tokio::main]
 async fn main() -> Result<()> {
     if let Err(error) = env::load_dotenv() {
@@ -137,11 +138,11 @@ async fn main() -> Result<()> {
                             .config
                             .llm_database_url
                             .as_ref()
-                            .ok_or_else(|| anyhow!("missing LLM_DATABASE_URL"))?;
+                            .context("missing LLM_DATABASE_URL")?;
                         let max_connections = state
                             .config
                             .llm_database_max_connections
-                            .ok_or_else(|| anyhow!("missing LLM_DATABASE_MAX_CONNECTIONS"))?;
+                            .context("missing LLM_DATABASE_MAX_CONNECTIONS")?;
 
                         let mut db_options = db::ConnectOptions::new(database_url);
                         db_options.max_connections(max_connections);
@@ -286,7 +287,7 @@ async fn setup_llm_database(config: &Config) -> Result<()> {
     let database_url = config
         .llm_database_url
         .as_ref()
-        .ok_or_else(|| anyhow!("missing LLM_DATABASE_URL"))?;
+        .context("missing LLM_DATABASE_URL")?;
 
     let db_options = db::ConnectOptions::new(database_url.clone());
     let db = LlmDatabase::new(db_options, Executor::Production).await?;

crates/collab/src/migrations.rs 🔗

@@ -30,12 +30,11 @@ pub async fn run_database_migrations(
     for migration in migrations {
         match applied_migrations.get(&migration.version) {
             Some(applied_migration) => {
-                if migration.checksum != applied_migration.checksum {
-                    Err(anyhow!(
-                        "checksum mismatch for applied migration {}",
-                        migration.description
-                    ))?;
-                }
+                anyhow::ensure!(
+                    migration.checksum == applied_migration.checksum,
+                    "checksum mismatch for applied migration {}",
+                    migration.description
+                );
             }
             None => {
                 let elapsed = connection.apply(&migration).await?;

crates/collab/src/rpc.rs 🔗

@@ -1,15 +1,20 @@
 mod connection_pool;
 
+use crate::api::billing::find_or_create_billing_customer;
 use crate::api::{CloudflareIpCountryHeader, SystemIdHeader};
 use crate::db::billing_subscription::SubscriptionKind;
 use crate::llm::db::LlmDatabase;
-use crate::llm::{AGENT_EXTENDED_TRIAL_FEATURE_FLAG, LlmTokenClaims};
+use crate::llm::{
+    AGENT_EXTENDED_TRIAL_FEATURE_FLAG, BYPASS_ACCOUNT_AGE_CHECK_FEATURE_FLAG, LlmTokenClaims,
+    MIN_ACCOUNT_AGE_FOR_LLM_USE,
+};
+use crate::stripe_client::StripeCustomerId;
 use crate::{
     AppState, Error, Result, auth,
     db::{
         self, BufferId, Capability, Channel, ChannelId, ChannelRole, ChannelsForUser,
         CreatedChannelMessage, Database, InviteMemberResult, MembershipUpdated, MessageId,
-        NotificationId, Project, ProjectId, RejoinedProject, RemoveChannelMemberResult, ReplicaId,
+        NotificationId, ProjectId, RejoinedProject, RemoveChannelMemberResult,
         RespondToChannelInvite, RoomId, ServerId, UpdatedChannelMessage, User, UserId,
     },
     executor::Executor,
@@ -63,7 +68,7 @@ use std::{
     rc::Rc,
     sync::{
         Arc, OnceLock,
-        atomic::{AtomicBool, Ordering::SeqCst},
+        atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst},
     },
     time::{Duration, Instant},
 };
@@ -84,10 +89,36 @@ pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(15);
 const MESSAGE_COUNT_PER_PAGE: usize = 100;
 const MAX_MESSAGE_LEN: usize = 1024;
 const NOTIFICATION_COUNT_PER_PAGE: usize = 50;
+const MAX_CONCURRENT_CONNECTIONS: usize = 512;
+
+static CONCURRENT_CONNECTIONS: AtomicUsize = AtomicUsize::new(0);
 
 type MessageHandler =
     Box<dyn Send + Sync + Fn(Box<dyn AnyTypedEnvelope>, Session) -> BoxFuture<'static, ()>>;
 
+pub struct ConnectionGuard;
+
+impl ConnectionGuard {
+    pub fn try_acquire() -> Result<Self, ()> {
+        let current_connections = CONCURRENT_CONNECTIONS.fetch_add(1, SeqCst);
+        if current_connections >= MAX_CONCURRENT_CONNECTIONS {
+            CONCURRENT_CONNECTIONS.fetch_sub(1, SeqCst);
+            tracing::error!(
+                "too many concurrent connections: {}",
+                current_connections + 1
+            );
+            return Err(());
+        }
+        Ok(ConnectionGuard)
+    }
+}
+
+impl Drop for ConnectionGuard {
+    fn drop(&mut self) {
+        CONCURRENT_CONNECTIONS.fetch_sub(1, SeqCst);
+    }
+}
+
 struct Response<R> {
     peer: Arc<Peer>,
     receipt: Receipt<R>,
@@ -109,6 +140,13 @@ pub enum Principal {
 }
 
 impl Principal {
+    fn user(&self) -> &User {
+        match self {
+            Principal::User(user) => user,
+            Principal::Impersonated { user, .. } => user,
+        }
+    }
+
     fn update_span(&self, span: &tracing::Span) {
         match &self {
             Principal::User(user) => {
@@ -141,7 +179,7 @@ struct Session {
 }
 
 impl Session {
-    async fn db(&self) -> tokio::sync::MutexGuard<DbHandle> {
+    async fn db(&self) -> tokio::sync::MutexGuard<'_, DbHandle> {
         #[cfg(test)]
         tokio::task::yield_now().await;
         let guard = self.db.lock().await;
@@ -285,6 +323,7 @@ impl Server {
             .add_request_handler(forward_read_only_project_request::<proto::SynchronizeBuffers>)
             .add_request_handler(forward_read_only_project_request::<proto::InlayHints>)
             .add_request_handler(forward_read_only_project_request::<proto::ResolveInlayHint>)
+            .add_request_handler(forward_read_only_project_request::<proto::GetColorPresentation>)
             .add_request_handler(forward_mutating_project_request::<proto::GetCodeLens>)
             .add_request_handler(forward_read_only_project_request::<proto::OpenBufferByPath>)
             .add_request_handler(forward_read_only_project_request::<proto::GitGetBranches>)
@@ -303,6 +342,7 @@ impl Server {
             .add_request_handler(
                 forward_read_only_project_request::<proto::LanguageServerIdForName>,
             )
+            .add_request_handler(forward_read_only_project_request::<proto::GetDocumentDiagnostics>)
             .add_request_handler(
                 forward_mutating_project_request::<proto::RegisterBufferWithLanguageServers>,
             )
@@ -345,6 +385,9 @@ impl Server {
             .add_message_handler(broadcast_project_message_from_host::<proto::BufferReloaded>)
             .add_message_handler(broadcast_project_message_from_host::<proto::BufferSaved>)
             .add_message_handler(broadcast_project_message_from_host::<proto::UpdateDiffBases>)
+            .add_message_handler(
+                broadcast_project_message_from_host::<proto::PullWorkspaceDiagnostics>,
+            )
             .add_request_handler(get_users)
             .add_request_handler(fuzzy_search_users)
             .add_request_handler(request_contact)
@@ -375,6 +418,7 @@ impl Server {
             .add_request_handler(get_notifications)
             .add_request_handler(mark_notification_as_read)
             .add_request_handler(move_channel)
+            .add_request_handler(reorder_channel)
             .add_request_handler(follow)
             .add_message_handler(unfollow)
             .add_message_handler(update_followers)
@@ -424,6 +468,16 @@ impl Server {
                 tracing::info!("waiting for cleanup timeout");
                 timeout.await;
                 tracing::info!("cleanup timeout expired, retrieving stale rooms");
+
+                app_state
+                    .db
+                    .delete_stale_channel_chat_participants(
+                        &app_state.config.zed_environment,
+                        server_id,
+                    )
+                    .await
+                    .trace_err();
+
                 if let Some((room_ids, channel_ids)) = app_state
                     .db
                     .stale_server_resource_ids(&app_state.config.zed_environment, server_id)
@@ -545,6 +599,21 @@ impl Server {
                     }
                 }
 
+                app_state
+                    .db
+                    .delete_stale_channel_chat_participants(
+                        &app_state.config.zed_environment,
+                        server_id,
+                    )
+                    .await
+                    .trace_err();
+
+                app_state
+                    .db
+                    .clear_old_worktree_entries(server_id)
+                    .await
+                    .trace_err();
+
                 app_state
                     .db
                     .delete_stale_servers(&app_state.config.zed_environment, server_id)
@@ -663,7 +732,7 @@ impl Server {
                     Err(error) => {
                         let proto_err = match &error {
                             Error::Internal(err) => err.to_proto(),
-                            _ => ErrorCode::Internal.message(format!("{}", error)).to_proto(),
+                            _ => ErrorCode::Internal.message(format!("{error}")).to_proto(),
                         };
                         peer.respond_with_error(receipt, proto_err)?;
                         Err(error)
@@ -683,6 +752,7 @@ impl Server {
         system_id: Option<String>,
         send_connection_id: Option<oneshot::Sender<ConnectionId>>,
         executor: Executor,
+        connection_guard: Option<ConnectionGuard>,
     ) -> impl Future<Output = ()> + use<> {
         let this = self.clone();
         let span = info_span!("handle connection", %address,
@@ -703,6 +773,7 @@ impl Server {
                 tracing::error!("server is tearing down");
                 return
             }
+
             let (connection_id, handle_io, mut incoming_rx) = this
                 .peer
                 .add_connection(connection, {
@@ -740,10 +811,11 @@ impl Server {
                 supermaven_client,
             };
 
-            if let Err(error) = this.send_initial_client_update(connection_id, &principal, zed_version, send_connection_id, &session).await {
+            if let Err(error) = this.send_initial_client_update(connection_id, zed_version, send_connection_id, &session).await {
                 tracing::error!(?error, "failed to send initial client update");
                 return;
             }
+            drop(connection_guard);
 
             let handle_io = handle_io.fuse();
             futures::pin_mut!(handle_io);
@@ -824,7 +896,6 @@ impl Server {
     async fn send_initial_client_update(
         &self,
         connection_id: ConnectionId,
-        principal: &Principal,
         zed_version: ZedVersion,
         mut send_connection_id: Option<oneshot::Sender<ConnectionId>>,
         session: &Session,
@@ -840,7 +911,7 @@ impl Server {
             let _ = send_connection_id.send(connection_id);
         }
 
-        match principal {
+        match &session.principal {
             Principal::User(user) | Principal::Impersonated { user, admin: _ } => {
                 if !user.connected_once {
                     self.peer.send(connection_id, proto::ShowContacts {})?;
@@ -850,7 +921,7 @@ impl Server {
                         .await?;
                 }
 
-                update_user_plan(user.id, session).await?;
+                update_user_plan(session).await?;
 
                 let contacts = self.app_state.db.get_contacts(user.id).await?;
 
@@ -937,13 +1008,13 @@ impl Server {
             .db
             .get_user_by_id(user_id)
             .await?
-            .ok_or_else(|| anyhow!("user not found"))?;
+            .context("user not found")?;
 
         let update_user_plan = make_update_user_plan_message(
+            &user,
+            user.admin,
             &self.app_state.db,
             self.app_state.llm_db.clone(),
-            user_id,
-            user.admin,
         )
         .await?;
 
@@ -966,7 +1037,7 @@ impl Server {
         }
     }
 
-    pub async fn snapshot(self: &Arc<Self>) -> ServerSnapshot {
+    pub async fn snapshot(self: &Arc<Self>) -> ServerSnapshot<'_> {
         ServerSnapshot {
             connection_pool: ConnectionPoolGuard {
                 guard: self.connection_pool.lock(),
@@ -1116,6 +1187,19 @@ pub async fn handle_websocket_request(
     }
 
     let socket_address = socket_address.to_string();
+
+    // Acquire connection guard before WebSocket upgrade
+    let connection_guard = match ConnectionGuard::try_acquire() {
+        Ok(guard) => guard,
+        Err(()) => {
+            return (
+                StatusCode::SERVICE_UNAVAILABLE,
+                "Too many concurrent connections",
+            )
+                .into_response();
+        }
+    };
+
     ws.on_upgrade(move |socket| {
         let socket = socket
             .map_ok(to_tungstenite_message)
@@ -1133,6 +1217,7 @@ pub async fn handle_websocket_request(
                     system_id_header.map(|header| header.to_string()),
                     None,
                     Executor::Production,
+                    Some(connection_guard),
                 )
                 .await;
         }
@@ -1168,7 +1253,7 @@ pub async fn handle_metrics(Extension(server): Extension<Arc<Server>>) -> Result
     let metric_families = prometheus::gather();
     let encoded_metrics = encoder
         .encode_to_string(&metric_families)
-        .map_err(|err| anyhow!("{}", err))?;
+        .map_err(|err| anyhow!("{err}"))?;
     Ok(encoded_metrics)
 }
 
@@ -1684,7 +1769,7 @@ async fn decline_call(message: proto::DeclineCall, session: Session) -> Result<(
             .await
             .decline_call(Some(room_id), session.user_id())
             .await?
-            .ok_or_else(|| anyhow!("failed to decline call"))?;
+            .context("declining call")?;
         room_updated(&room, &session.peer);
     }
 
@@ -1714,9 +1799,7 @@ async fn update_participant_location(
     session: Session,
 ) -> Result<()> {
     let room_id = RoomId::from_proto(request.room_id);
-    let location = request
-        .location
-        .ok_or_else(|| anyhow!("invalid location"))?;
+    let location = request.location.context("invalid location")?;
 
     let db = session.db().await;
     let room = db
@@ -1808,28 +1891,16 @@ async fn join_project(
 
     let db = session.db().await;
     let (project, replica_id) = &mut *db
-        .join_project(project_id, session.connection_id, session.user_id())
+        .join_project(
+            project_id,
+            session.connection_id,
+            session.user_id(),
+            request.committer_name.clone(),
+            request.committer_email.clone(),
+        )
         .await?;
     drop(db);
     tracing::info!(%project_id, "join remote project");
-    join_project_internal(response, session, project, replica_id)
-}
-
-trait JoinProjectInternalResponse {
-    fn send(self, result: proto::JoinProjectResponse) -> Result<()>;
-}
-impl JoinProjectInternalResponse for Response<proto::JoinProject> {
-    fn send(self, result: proto::JoinProjectResponse) -> Result<()> {
-        Response::<proto::JoinProject>::send(self, result)
-    }
-}
-
-fn join_project_internal(
-    response: impl JoinProjectInternalResponse,
-    session: Session,
-    project: &mut Project,
-    replica_id: &ReplicaId,
-) -> Result<()> {
     let collaborators = project
         .collaborators
         .iter()
@@ -1857,6 +1928,8 @@ fn join_project_internal(
             replica_id: replica_id.0 as u32,
             user_id: guest_user_id.to_proto(),
             is_host: false,
+            committer_name: request.committer_name.clone(),
+            committer_email: request.committer_email.clone(),
         }),
     };
 
@@ -1935,6 +2008,7 @@ fn join_project_internal(
             session.connection_id,
             proto::UpdateLanguageServer {
                 project_id: project_id.to_proto(),
+                server_name: Some(language_server.name.clone()),
                 language_server_id: language_server.id,
                 variant: Some(
                     proto::update_language_server::Variant::DiskBasedDiagnosticsUpdated(
@@ -2245,7 +2319,7 @@ async fn create_buffer_for_peer(
             session.connection_id,
         )
         .await?;
-    let peer_id = request.peer_id.ok_or_else(|| anyhow!("invalid peer id"))?;
+    let peer_id = request.peer_id.context("invalid peer id")?;
     session
         .peer
         .forward_send(session.connection_id, peer_id.into(), request)?;
@@ -2376,10 +2450,7 @@ async fn follow(
 ) -> Result<()> {
     let room_id = RoomId::from_proto(request.room_id);
     let project_id = request.project_id.map(ProjectId::from_proto);
-    let leader_id = request
-        .leader_id
-        .ok_or_else(|| anyhow!("invalid leader id"))?
-        .into();
+    let leader_id = request.leader_id.context("invalid leader id")?.into();
     let follower_id = session.connection_id;
 
     session
@@ -2410,10 +2481,7 @@ async fn follow(
 async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> {
     let room_id = RoomId::from_proto(request.room_id);
     let project_id = request.project_id.map(ProjectId::from_proto);
-    let leader_id = request
-        .leader_id
-        .ok_or_else(|| anyhow!("invalid leader id"))?
-        .into();
+    let leader_id = request.leader_id.context("invalid leader id")?.into();
     let follower_id = session.connection_id;
 
     session
@@ -2491,7 +2559,6 @@ async fn get_users(
             id: user.id.to_proto(),
             avatar_url: format!("https://github.com/{}.png?size=128", user.github_login),
             github_login: user.github_login,
-            email: user.email_address,
             name: user.name,
         })
         .collect();
@@ -2525,7 +2592,6 @@ async fn fuzzy_search_users(
             avatar_url: format!("https://github.com/{}.png?size=128", user.github_login),
             github_login: user.github_login,
             name: user.name,
-            email: user.email_address,
         })
         .collect();
     response.send(proto::UsersResponse { users })?;
@@ -2714,25 +2780,25 @@ async fn current_plan(db: &Arc<Database>, user_id: UserId, is_staff: bool) -> Re
 }
 
 async fn make_update_user_plan_message(
+    user: &User,
+    is_staff: bool,
     db: &Arc<Database>,
     llm_db: Option<Arc<LlmDatabase>>,
-    user_id: UserId,
-    is_staff: bool,
 ) -> Result<proto::UpdateUserPlan> {
-    let feature_flags = db.get_user_flags(user_id).await?;
-    let plan = current_plan(db, user_id, is_staff).await?;
-    let billing_customer = db.get_billing_customer_by_user_id(user_id).await?;
-    let billing_preferences = db.get_billing_preferences(user_id).await?;
+    let feature_flags = db.get_user_flags(user.id).await?;
+    let plan = current_plan(db, user.id, is_staff).await?;
+    let billing_customer = db.get_billing_customer_by_user_id(user.id).await?;
+    let billing_preferences = db.get_billing_preferences(user.id).await?;
 
     let (subscription_period, usage) = if let Some(llm_db) = llm_db {
-        let subscription = db.get_active_billing_subscription(user_id).await?;
+        let subscription = db.get_active_billing_subscription(user.id).await?;
 
         let subscription_period =
             crate::db::billing_subscription::Model::current_period(subscription, is_staff);
 
         let usage = if let Some((period_start_at, period_end_at)) = subscription_period {
             llm_db
-                .get_subscription_usage_for_period(user_id, period_start_at, period_end_at)
+                .get_subscription_usage_for_period(user.id, period_start_at, period_end_at)
                 .await?
         } else {
             None
@@ -2743,9 +2809,17 @@ async fn make_update_user_plan_message(
         (None, None)
     };
 
+    let bypass_account_age_check = feature_flags
+        .iter()
+        .any(|flag| flag == BYPASS_ACCOUNT_AGE_CHECK_FEATURE_FLAG);
+    let account_too_young = !matches!(plan, proto::Plan::ZedPro)
+        && !bypass_account_age_check
+        && user.account_age() < MIN_ACCOUNT_AGE_FOR_LLM_USE;
+
     Ok(proto::UpdateUserPlan {
         plan: plan.into(),
         trial_started_at: billing_customer
+            .as_ref()
             .and_then(|billing_customer| billing_customer.trial_started_at)
             .map(|trial_started_at| trial_started_at.and_utc().timestamp() as u64),
         is_usage_based_billing_enabled: if is_staff {
@@ -2759,6 +2833,9 @@ async fn make_update_user_plan_message(
                 ended_at: ended_at.timestamp() as u64,
             }
         }),
+        account_too_young: Some(account_too_young),
+        has_overdue_invoices: billing_customer
+            .map(|billing_customer| billing_customer.has_overdue_invoices),
         usage: usage.map(|usage| {
             let plan = match plan {
                 proto::Plan::Free => zed_llm_client::Plan::ZedFree,
@@ -2815,14 +2892,14 @@ async fn make_update_user_plan_message(
     })
 }
 
-async fn update_user_plan(user_id: UserId, session: &Session) -> Result<()> {
+async fn update_user_plan(session: &Session) -> Result<()> {
     let db = session.db().await;
 
     let update_user_plan = make_update_user_plan_message(
+        session.principal.user(),
+        session.is_staff(),
         &db.0,
         session.app_state.llm_db.clone(),
-        user_id,
-        session.is_staff(),
     )
     .await?;
 
@@ -3188,6 +3265,51 @@ async fn move_channel(
     Ok(())
 }
 
+async fn reorder_channel(
+    request: proto::ReorderChannel,
+    response: Response<proto::ReorderChannel>,
+    session: Session,
+) -> Result<()> {
+    let channel_id = ChannelId::from_proto(request.channel_id);
+    let direction = request.direction();
+
+    let updated_channels = session
+        .db()
+        .await
+        .reorder_channel(channel_id, direction, session.user_id())
+        .await?;
+
+    if let Some(root_id) = updated_channels.first().map(|channel| channel.root_id()) {
+        let connection_pool = session.connection_pool().await;
+        for (connection_id, role) in connection_pool.channel_connection_ids(root_id) {
+            let channels = updated_channels
+                .iter()
+                .filter_map(|channel| {
+                    if role.can_see_channel(channel.visibility) {
+                        Some(channel.to_proto())
+                    } else {
+                        None
+                    }
+                })
+                .collect::<Vec<_>>();
+
+            if channels.is_empty() {
+                continue;
+            }
+
+            let update = proto::UpdateChannels {
+                channels,
+                ..Default::default()
+            };
+
+            session.peer.send(connection_id, update.clone())?;
+        }
+    }
+
+    response.send(Ack {})?;
+    Ok(())
+}
+
 /// Get the list of channel members
 async fn get_channel_members(
     request: proto::GetChannelMembers,
@@ -3357,9 +3479,7 @@ async fn join_channel_internal(
     };
 
     channel_updated(
-        &joined_room
-            .channel
-            .ok_or_else(|| anyhow!("channel not returned"))?,
+        &joined_room.channel.context("channel not returned")?,
         &joined_room.room,
         &session.peer,
         &*session.connection_pool().await,
@@ -3567,9 +3687,7 @@ async fn send_channel_message(
     // TODO: adjust mentions if body is trimmed
 
     let timestamp = OffsetDateTime::now_utc();
-    let nonce = request
-        .nonce
-        .ok_or_else(|| anyhow!("nonce can't be blank"))?;
+    let nonce = request.nonce.context("nonce can't be blank")?;
 
     let channel_id = ChannelId::from_proto(request.channel_id);
     let CreatedChannelMessage {
@@ -3709,10 +3827,7 @@ async fn update_channel_message(
         )
         .await?;
 
-    let nonce = request
-        .nonce
-        .clone()
-        .ok_or_else(|| anyhow!("nonce can't be blank"))?;
+    let nonce = request.nonce.clone().context("nonce can't be blank")?;
 
     let message = proto::ChannelMessage {
         sender_id: session.user_id().to_proto(),
@@ -3817,14 +3932,12 @@ async fn get_supermaven_api_key(
         return Err(anyhow!("supermaven not enabled for this account"))?;
     }
 
-    let email = session
-        .email()
-        .ok_or_else(|| anyhow!("user must have an email"))?;
+    let email = session.email().context("user must have an email")?;
 
     let supermaven_admin_api = session
         .supermaven_client
         .as_ref()
-        .ok_or_else(|| anyhow!("supermaven not configured"))?;
+        .context("supermaven not configured")?;
 
     let result = supermaven_admin_api
         .try_get_or_create_user(CreateExternalUserRequest { id: user_id, email })
@@ -3972,7 +4085,7 @@ async fn get_private_user_info(
     let user = db
         .get_user_by_id(session.user_id())
         .await?
-        .ok_or_else(|| anyhow!("user not found"))?;
+        .context("user not found")?;
     let flags = db.get_user_flags(session.user_id()).await?;
 
     response.send(proto::GetPrivateUserInfoResponse {
@@ -4002,9 +4115,6 @@ async fn accept_terms_of_service(
     Ok(())
 }
 
-/// The minimum account age an account must have in order to use the LLM service.
-pub const MIN_ACCOUNT_AGE_FOR_LLM_USE: chrono::Duration = chrono::Duration::days(30);
-
 async fn get_llm_api_token(
     _request: proto::GetLlmToken,
     response: Response<proto::GetLlmToken>,
@@ -4018,18 +4128,67 @@ async fn get_llm_api_token(
     let user = db
         .get_user_by_id(user_id)
         .await?
-        .ok_or_else(|| anyhow!("user {} not found", user_id))?;
+        .with_context(|| format!("user {user_id} not found"))?;
 
     if user.accepted_tos_at.is_none() {
         Err(anyhow!("terms of service not accepted"))?
     }
 
-    let billing_subscription = db.get_active_billing_subscription(user.id).await?;
+    let stripe_client = session
+        .app_state
+        .stripe_client
+        .as_ref()
+        .context("failed to retrieve Stripe client")?;
+
+    let stripe_billing = session
+        .app_state
+        .stripe_billing
+        .as_ref()
+        .context("failed to retrieve Stripe billing object")?;
+
+    let billing_customer = if let Some(billing_customer) =
+        db.get_billing_customer_by_user_id(user.id).await?
+    {
+        billing_customer
+    } else {
+        let customer_id = stripe_billing
+            .find_or_create_customer_by_email(user.email_address.as_deref())
+            .await?;
+
+        find_or_create_billing_customer(&session.app_state, stripe_client.as_ref(), &customer_id)
+            .await?
+            .context("billing customer not found")?
+    };
+
+    let billing_subscription =
+        if let Some(billing_subscription) = db.get_active_billing_subscription(user.id).await? {
+            billing_subscription
+        } else {
+            let stripe_customer_id =
+                StripeCustomerId(billing_customer.stripe_customer_id.clone().into());
+
+            let stripe_subscription = stripe_billing
+                .subscribe_to_zed_free(stripe_customer_id)
+                .await?;
+
+            db.create_billing_subscription(&db::CreateBillingSubscriptionParams {
+                billing_customer_id: billing_customer.id,
+                kind: Some(SubscriptionKind::ZedFree),
+                stripe_subscription_id: stripe_subscription.id.to_string(),
+                stripe_subscription_status: stripe_subscription.status.into(),
+                stripe_cancellation_reason: None,
+                stripe_current_period_start: Some(stripe_subscription.current_period_start),
+                stripe_current_period_end: Some(stripe_subscription.current_period_end),
+            })
+            .await?
+        };
+
     let billing_preferences = db.get_billing_preferences(user.id).await?;
 
     let token = LlmTokenClaims::create(
         &user,
         session.is_staff(),
+        billing_customer,
         billing_preferences,
         &flags,
         billing_subscription,

crates/collab/src/rpc/connection_pool.rs 🔗

@@ -1,5 +1,5 @@
 use crate::db::{ChannelId, ChannelRole, UserId};
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use collections::{BTreeMap, HashMap, HashSet};
 use rpc::ConnectionId;
 use semantic_version::SemanticVersion;
@@ -77,7 +77,7 @@ impl ConnectionPool {
         let connection = self
             .connections
             .get_mut(&connection_id)
-            .ok_or_else(|| anyhow!("no such connection"))?;
+            .context("no such connection")?;
 
         let user_id = connection.user_id;
 

crates/collab/src/seed.rs 🔗

@@ -127,7 +127,7 @@ pub async fn seed(config: &Config, db: &Database, force: bool) -> anyhow::Result
         log::info!("Seeding {:?} from GitHub", github_user.login);
 
         let user = db
-            .get_or_create_user_by_github_account(
+            .update_or_create_user_by_github_account(
                 &github_user.login,
                 github_user.id,
                 github_user.email.as_deref(),

crates/collab/src/stripe_billing.rs 🔗

@@ -1,58 +1,75 @@
 use std::sync::Arc;
 
-use crate::Result;
-use crate::llm::AGENT_EXTENDED_TRIAL_FEATURE_FLAG;
 use anyhow::{Context as _, anyhow};
 use chrono::Utc;
 use collections::HashMap;
-use serde::{Deserialize, Serialize};
-use stripe::PriceId;
+use stripe::SubscriptionStatus;
 use tokio::sync::RwLock;
 use uuid::Uuid;
 
+use crate::Result;
+use crate::db::billing_subscription::SubscriptionKind;
+use crate::llm::AGENT_EXTENDED_TRIAL_FEATURE_FLAG;
+use crate::stripe_client::{
+    RealStripeClient, StripeBillingAddressCollection, StripeCheckoutSessionMode,
+    StripeCheckoutSessionPaymentMethodCollection, StripeClient,
+    StripeCreateCheckoutSessionLineItems, StripeCreateCheckoutSessionParams,
+    StripeCreateCheckoutSessionSubscriptionData, StripeCreateMeterEventParams,
+    StripeCreateMeterEventPayload, StripeCreateSubscriptionItems, StripeCreateSubscriptionParams,
+    StripeCustomerId, StripeCustomerUpdate, StripeCustomerUpdateAddress, StripeCustomerUpdateName,
+    StripeMeter, StripePrice, StripePriceId, StripeSubscription, StripeSubscriptionId,
+    StripeSubscriptionTrialSettings, StripeSubscriptionTrialSettingsEndBehavior,
+    StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, UpdateSubscriptionItems,
+    UpdateSubscriptionParams,
+};
+
 pub struct StripeBilling {
     state: RwLock<StripeBillingState>,
-    client: Arc<stripe::Client>,
+    client: Arc<dyn StripeClient>,
 }
 
 #[derive(Default)]
 struct StripeBillingState {
     meters_by_event_name: HashMap<String, StripeMeter>,
-    price_ids_by_meter_id: HashMap<String, stripe::PriceId>,
-    prices_by_lookup_key: HashMap<String, stripe::Price>,
+    price_ids_by_meter_id: HashMap<String, StripePriceId>,
+    prices_by_lookup_key: HashMap<String, StripePrice>,
 }
 
 impl StripeBilling {
     pub fn new(client: Arc<stripe::Client>) -> Self {
+        Self {
+            client: Arc::new(RealStripeClient::new(client.clone())),
+            state: RwLock::default(),
+        }
+    }
+
+    #[cfg(test)]
+    pub fn test(client: Arc<crate::stripe_client::FakeStripeClient>) -> Self {
         Self {
             client,
             state: RwLock::default(),
         }
     }
 
+    pub fn client(&self) -> &Arc<dyn StripeClient> {
+        &self.client
+    }
+
     pub async fn initialize(&self) -> Result<()> {
         log::info!("StripeBilling: initializing");
 
         let mut state = self.state.write().await;
 
-        let (meters, prices) = futures::try_join!(
-            StripeMeter::list(&self.client),
-            stripe::Price::list(
-                &self.client,
-                &stripe::ListPrices {
-                    limit: Some(100),
-                    ..Default::default()
-                }
-            )
-        )?;
+        let (meters, prices) =
+            futures::try_join!(self.client.list_meters(), self.client.list_prices())?;
 
-        for meter in meters.data {
+        for meter in meters {
             state
                 .meters_by_event_name
                 .insert(meter.event_name.clone(), meter);
         }
 
-        for price in prices.data {
+        for price in prices {
             if let Some(lookup_key) = price.lookup_key.clone() {
                 state.prices_by_lookup_key.insert(lookup_key, price.clone());
             }
@@ -69,15 +86,15 @@ impl StripeBilling {
         Ok(())
     }
 
-    pub async fn zed_pro_price_id(&self) -> Result<PriceId> {
+    pub async fn zed_pro_price_id(&self) -> Result<StripePriceId> {
         self.find_price_id_by_lookup_key("zed-pro").await
     }
 
-    pub async fn zed_free_price_id(&self) -> Result<PriceId> {
+    pub async fn zed_free_price_id(&self) -> Result<StripePriceId> {
         self.find_price_id_by_lookup_key("zed-free").await
     }
 
-    pub async fn find_price_id_by_lookup_key(&self, lookup_key: &str) -> Result<PriceId> {
+    pub async fn find_price_id_by_lookup_key(&self, lookup_key: &str) -> Result<StripePriceId> {
         self.state
             .read()
             .await
@@ -87,7 +104,7 @@ impl StripeBilling {
             .ok_or_else(|| crate::Error::Internal(anyhow!("no price ID found for {lookup_key:?}")))
     }
 
-    pub async fn find_price_by_lookup_key(&self, lookup_key: &str) -> Result<stripe::Price> {
+    pub async fn find_price_by_lookup_key(&self, lookup_key: &str) -> Result<StripePrice> {
         self.state
             .read()
             .await
@@ -97,13 +114,68 @@ impl StripeBilling {
             .ok_or_else(|| crate::Error::Internal(anyhow!("no price found for {lookup_key:?}")))
     }
 
+    pub async fn determine_subscription_kind(
+        &self,
+        subscription: &StripeSubscription,
+    ) -> Option<SubscriptionKind> {
+        let zed_pro_price_id = self.zed_pro_price_id().await.ok()?;
+        let zed_free_price_id = self.zed_free_price_id().await.ok()?;
+
+        subscription.items.iter().find_map(|item| {
+            let price = item.price.as_ref()?;
+
+            if price.id == zed_pro_price_id {
+                Some(if subscription.status == SubscriptionStatus::Trialing {
+                    SubscriptionKind::ZedProTrial
+                } else {
+                    SubscriptionKind::ZedPro
+                })
+            } else if price.id == zed_free_price_id {
+                Some(SubscriptionKind::ZedFree)
+            } else {
+                None
+            }
+        })
+    }
+
+    /// Returns the Stripe customer associated with the provided email address, or creates a new customer, if one does
+    /// not already exist.
+    ///
+    /// Always returns a new Stripe customer if the email address is `None`.
+    pub async fn find_or_create_customer_by_email(
+        &self,
+        email_address: Option<&str>,
+    ) -> Result<StripeCustomerId> {
+        let existing_customer = if let Some(email) = email_address {
+            let customers = self.client.list_customers_by_email(email).await?;
+
+            customers.first().cloned()
+        } else {
+            None
+        };
+
+        let customer_id = if let Some(existing_customer) = existing_customer {
+            existing_customer.id
+        } else {
+            let customer = self
+                .client
+                .create_customer(crate::stripe_client::CreateCustomerParams {
+                    email: email_address,
+                })
+                .await?;
+
+            customer.id
+        };
+
+        Ok(customer_id)
+    }
+
     pub async fn subscribe_to_price(
         &self,
-        subscription_id: &stripe::SubscriptionId,
-        price: &stripe::Price,
+        subscription_id: &StripeSubscriptionId,
+        price: &StripePrice,
     ) -> Result<()> {
-        let subscription =
-            stripe::Subscription::retrieve(&self.client, &subscription_id, &[]).await?;
+        let subscription = self.client.get_subscription(subscription_id).await?;
 
         if subscription_contains_price(&subscription, &price.id) {
             return Ok(());
@@ -114,39 +186,36 @@ impl StripeBilling {
         let price_per_unit = price.unit_amount.unwrap_or_default();
         let _units_for_billing_threshold = BILLING_THRESHOLD_IN_CENTS / price_per_unit;
 
-        stripe::Subscription::update(
-            &self.client,
-            subscription_id,
-            stripe::UpdateSubscription {
-                items: Some(vec![stripe::UpdateSubscriptionItems {
-                    price: Some(price.id.to_string()),
-                    ..Default::default()
-                }]),
-                trial_settings: Some(stripe::UpdateSubscriptionTrialSettings {
-                    end_behavior: stripe::UpdateSubscriptionTrialSettingsEndBehavior {
-                        missing_payment_method: stripe::UpdateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel,
-                    },
-                }),
-                ..Default::default()
-            },
-        )
-        .await?;
+        self.client
+            .update_subscription(
+                subscription_id,
+                UpdateSubscriptionParams {
+                    items: Some(vec![UpdateSubscriptionItems {
+                        price: Some(price.id.clone()),
+                    }]),
+                    trial_settings: Some(StripeSubscriptionTrialSettings {
+                        end_behavior: StripeSubscriptionTrialSettingsEndBehavior {
+                            missing_payment_method: StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel
+                        },
+                    }),
+                },
+            )
+            .await?;
 
         Ok(())
     }
 
     pub async fn bill_model_request_usage(
         &self,
-        customer_id: &stripe::CustomerId,
+        customer_id: &StripeCustomerId,
         event_name: &str,
         requests: i32,
     ) -> Result<()> {
         let timestamp = Utc::now().timestamp();
         let idempotency_key = Uuid::new_v4();
 
-        StripeMeterEvent::create(
-            &self.client,
-            StripeCreateMeterEventParams {
+        self.client
+            .create_meter_event(StripeCreateMeterEventParams {
                 identifier: &format!("model_requests/{}", idempotency_key),
                 event_name,
                 payload: StripeCreateMeterEventPayload {
@@ -154,39 +223,43 @@ impl StripeBilling {
                     stripe_customer_id: customer_id,
                 },
                 timestamp: Some(timestamp),
-            },
-        )
-        .await?;
+            })
+            .await?;
 
         Ok(())
     }
 
     pub async fn checkout_with_zed_pro(
         &self,
-        customer_id: stripe::CustomerId,
+        customer_id: &StripeCustomerId,
         github_login: &str,
         success_url: &str,
     ) -> Result<String> {
         let zed_pro_price_id = self.zed_pro_price_id().await?;
 
-        let mut params = stripe::CreateCheckoutSession::new();
-        params.mode = Some(stripe::CheckoutSessionMode::Subscription);
+        let mut params = StripeCreateCheckoutSessionParams::default();
+        params.mode = Some(StripeCheckoutSessionMode::Subscription);
         params.customer = Some(customer_id);
         params.client_reference_id = Some(github_login);
-        params.line_items = Some(vec![stripe::CreateCheckoutSessionLineItems {
+        params.line_items = Some(vec![StripeCreateCheckoutSessionLineItems {
             price: Some(zed_pro_price_id.to_string()),
             quantity: Some(1),
-            ..Default::default()
         }]);
         params.success_url = Some(success_url);
+        params.billing_address_collection = Some(StripeBillingAddressCollection::Required);
+        params.customer_update = Some(StripeCustomerUpdate {
+            address: Some(StripeCustomerUpdateAddress::Auto),
+            name: Some(StripeCustomerUpdateName::Auto),
+            shipping: None,
+        });
 
-        let session = stripe::CheckoutSession::create(&self.client, params).await?;
+        let session = self.client.create_checkout_session(params).await?;
         Ok(session.url.context("no checkout session URL")?)
     }
 
     pub async fn checkout_with_zed_pro_trial(
         &self,
-        customer_id: stripe::CustomerId,
+        customer_id: &StripeCustomerId,
         github_login: &str,
         feature_flags: Vec<String>,
         success_url: &str,
@@ -207,132 +280,81 @@ impl StripeBilling {
             );
         }
 
-        let mut params = stripe::CreateCheckoutSession::new();
-        params.subscription_data = Some(stripe::CreateCheckoutSessionSubscriptionData {
+        let mut params = StripeCreateCheckoutSessionParams::default();
+        params.subscription_data = Some(StripeCreateCheckoutSessionSubscriptionData {
             trial_period_days: Some(trial_period_days),
-            trial_settings: Some(stripe::CreateCheckoutSessionSubscriptionDataTrialSettings {
-                end_behavior: stripe::CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehavior {
-                    missing_payment_method: stripe::CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehaviorMissingPaymentMethod::Pause,
-                }
+            trial_settings: Some(StripeSubscriptionTrialSettings {
+                end_behavior: StripeSubscriptionTrialSettingsEndBehavior {
+                    missing_payment_method:
+                        StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel,
+                },
             }),
             metadata: if !subscription_metadata.is_empty() {
                 Some(subscription_metadata)
             } else {
                 None
             },
-            ..Default::default()
         });
-        params.mode = Some(stripe::CheckoutSessionMode::Subscription);
+        params.mode = Some(StripeCheckoutSessionMode::Subscription);
         params.payment_method_collection =
-            Some(stripe::CheckoutSessionPaymentMethodCollection::IfRequired);
+            Some(StripeCheckoutSessionPaymentMethodCollection::IfRequired);
         params.customer = Some(customer_id);
         params.client_reference_id = Some(github_login);
-        params.line_items = Some(vec![stripe::CreateCheckoutSessionLineItems {
+        params.line_items = Some(vec![StripeCreateCheckoutSessionLineItems {
             price: Some(zed_pro_price_id.to_string()),
             quantity: Some(1),
-            ..Default::default()
         }]);
         params.success_url = Some(success_url);
+        params.billing_address_collection = Some(StripeBillingAddressCollection::Required);
+        params.customer_update = Some(StripeCustomerUpdate {
+            address: Some(StripeCustomerUpdateAddress::Auto),
+            name: Some(StripeCustomerUpdateName::Auto),
+            shipping: None,
+        });
 
-        let session = stripe::CheckoutSession::create(&self.client, params).await?;
+        let session = self.client.create_checkout_session(params).await?;
         Ok(session.url.context("no checkout session URL")?)
     }
 
-    pub async fn checkout_with_zed_free(
+    pub async fn subscribe_to_zed_free(
         &self,
-        customer_id: stripe::CustomerId,
-        github_login: &str,
-        success_url: &str,
-    ) -> Result<String> {
+        customer_id: StripeCustomerId,
+    ) -> Result<StripeSubscription> {
         let zed_free_price_id = self.zed_free_price_id().await?;
 
-        let mut params = stripe::CreateCheckoutSession::new();
-        params.mode = Some(stripe::CheckoutSessionMode::Subscription);
-        params.payment_method_collection =
-            Some(stripe::CheckoutSessionPaymentMethodCollection::IfRequired);
-        params.customer = Some(customer_id);
-        params.client_reference_id = Some(github_login);
-        params.line_items = Some(vec![stripe::CreateCheckoutSessionLineItems {
-            price: Some(zed_free_price_id.to_string()),
-            quantity: Some(1),
-            ..Default::default()
-        }]);
-        params.success_url = Some(success_url);
-
-        let session = stripe::CheckoutSession::create(&self.client, params).await?;
-        Ok(session.url.context("no checkout session URL")?)
-    }
-}
-
-#[derive(Clone, Deserialize)]
-struct StripeMeter {
-    id: String,
-    event_name: String,
-}
-
-impl StripeMeter {
-    pub fn list(client: &stripe::Client) -> stripe::Response<stripe::List<Self>> {
-        #[derive(Serialize)]
-        struct Params {
-            #[serde(skip_serializing_if = "Option::is_none")]
-            limit: Option<u64>,
+        let existing_subscriptions = self
+            .client
+            .list_subscriptions_for_customer(&customer_id)
+            .await?;
+
+        let existing_active_subscription =
+            existing_subscriptions.into_iter().find(|subscription| {
+                subscription.status == SubscriptionStatus::Active
+                    || subscription.status == SubscriptionStatus::Trialing
+            });
+        if let Some(subscription) = existing_active_subscription {
+            return Ok(subscription);
         }
 
-        client.get_query("/billing/meters", Params { limit: Some(100) })
-    }
-}
+        let params = StripeCreateSubscriptionParams {
+            customer: customer_id,
+            items: vec![StripeCreateSubscriptionItems {
+                price: Some(zed_free_price_id),
+                quantity: Some(1),
+            }],
+        };
 
-#[derive(Deserialize)]
-struct StripeMeterEvent {
-    identifier: String,
-}
+        let subscription = self.client.create_subscription(params).await?;
 
-impl StripeMeterEvent {
-    pub async fn create(
-        client: &stripe::Client,
-        params: StripeCreateMeterEventParams<'_>,
-    ) -> Result<Self, stripe::StripeError> {
-        let identifier = params.identifier;
-        match client.post_form("/billing/meter_events", params).await {
-            Ok(event) => Ok(event),
-            Err(stripe::StripeError::Stripe(error)) => {
-                if error.http_status == 400
-                    && error
-                        .message
-                        .as_ref()
-                        .map_or(false, |message| message.contains(identifier))
-                {
-                    Ok(Self {
-                        identifier: identifier.to_string(),
-                    })
-                } else {
-                    Err(stripe::StripeError::Stripe(error))
-                }
-            }
-            Err(error) => Err(error),
-        }
+        Ok(subscription)
     }
 }
 
-#[derive(Serialize)]
-struct StripeCreateMeterEventParams<'a> {
-    identifier: &'a str,
-    event_name: &'a str,
-    payload: StripeCreateMeterEventPayload<'a>,
-    timestamp: Option<i64>,
-}
-
-#[derive(Serialize)]
-struct StripeCreateMeterEventPayload<'a> {
-    value: u64,
-    stripe_customer_id: &'a stripe::CustomerId,
-}
-
 fn subscription_contains_price(
-    subscription: &stripe::Subscription,
-    price_id: &stripe::PriceId,
+    subscription: &StripeSubscription,
+    price_id: &StripePriceId,
 ) -> bool {
-    subscription.items.data.iter().any(|item| {
+    subscription.items.iter().any(|item| {
         item.price
             .as_ref()
             .map_or(false, |price| price.id == *price_id)

crates/collab/src/stripe_client.rs 🔗

@@ -0,0 +1,273 @@
+#[cfg(test)]
+mod fake_stripe_client;
+mod real_stripe_client;
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use anyhow::Result;
+use async_trait::async_trait;
+
+#[cfg(test)]
+pub use fake_stripe_client::*;
+pub use real_stripe_client::*;
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display, Serialize)]
+pub struct StripeCustomerId(pub Arc<str>);
+
+#[derive(Debug, Clone)]
+pub struct StripeCustomer {
+    pub id: StripeCustomerId,
+    pub email: Option<String>,
+}
+
+#[derive(Debug)]
+pub struct CreateCustomerParams<'a> {
+    pub email: Option<&'a str>,
+}
+
+#[derive(Debug)]
+pub struct UpdateCustomerParams<'a> {
+    pub email: Option<&'a str>,
+}
+
+#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display)]
+pub struct StripeSubscriptionId(pub Arc<str>);
+
+#[derive(Debug, PartialEq, Clone)]
+pub struct StripeSubscription {
+    pub id: StripeSubscriptionId,
+    pub customer: StripeCustomerId,
+    // TODO: Create our own version of this enum.
+    pub status: stripe::SubscriptionStatus,
+    pub current_period_end: i64,
+    pub current_period_start: i64,
+    pub items: Vec<StripeSubscriptionItem>,
+    pub cancel_at: Option<i64>,
+    pub cancellation_details: Option<StripeCancellationDetails>,
+}
+
+#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display)]
+pub struct StripeSubscriptionItemId(pub Arc<str>);
+
+#[derive(Debug, PartialEq, Clone)]
+pub struct StripeSubscriptionItem {
+    pub id: StripeSubscriptionItemId,
+    pub price: Option<StripePrice>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct StripeCancellationDetails {
+    pub reason: Option<StripeCancellationDetailsReason>,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum StripeCancellationDetailsReason {
+    CancellationRequested,
+    PaymentDisputed,
+    PaymentFailed,
+}
+
+#[derive(Debug)]
+pub struct StripeCreateSubscriptionParams {
+    pub customer: StripeCustomerId,
+    pub items: Vec<StripeCreateSubscriptionItems>,
+}
+
+#[derive(Debug)]
+pub struct StripeCreateSubscriptionItems {
+    pub price: Option<StripePriceId>,
+    pub quantity: Option<u64>,
+}
+
+#[derive(Debug, Clone)]
+pub struct UpdateSubscriptionParams {
+    pub items: Option<Vec<UpdateSubscriptionItems>>,
+    pub trial_settings: Option<StripeSubscriptionTrialSettings>,
+}
+
+#[derive(Debug, PartialEq, Clone)]
+pub struct UpdateSubscriptionItems {
+    pub price: Option<StripePriceId>,
+}
+
+#[derive(Debug, PartialEq, Clone)]
+pub struct StripeSubscriptionTrialSettings {
+    pub end_behavior: StripeSubscriptionTrialSettingsEndBehavior,
+}
+
+#[derive(Debug, PartialEq, Clone)]
+pub struct StripeSubscriptionTrialSettingsEndBehavior {
+    pub missing_payment_method: StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod {
+    Cancel,
+    CreateInvoice,
+    Pause,
+}
+
+#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display)]
+pub struct StripePriceId(pub Arc<str>);
+
+#[derive(Debug, PartialEq, Clone)]
+pub struct StripePrice {
+    pub id: StripePriceId,
+    pub unit_amount: Option<i64>,
+    pub lookup_key: Option<String>,
+    pub recurring: Option<StripePriceRecurring>,
+}
+
+#[derive(Debug, PartialEq, Clone)]
+pub struct StripePriceRecurring {
+    pub meter: Option<String>,
+}
+
+#[derive(Debug, PartialEq, Eq, Hash, Clone, derive_more::Display, Deserialize)]
+pub struct StripeMeterId(pub Arc<str>);
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct StripeMeter {
+    pub id: StripeMeterId,
+    pub event_name: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct StripeCreateMeterEventParams<'a> {
+    pub identifier: &'a str,
+    pub event_name: &'a str,
+    pub payload: StripeCreateMeterEventPayload<'a>,
+    pub timestamp: Option<i64>,
+}
+
+#[derive(Debug, Serialize)]
+pub struct StripeCreateMeterEventPayload<'a> {
+    pub value: u64,
+    pub stripe_customer_id: &'a StripeCustomerId,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum StripeBillingAddressCollection {
+    Auto,
+    Required,
+}
+
+#[derive(Debug, PartialEq, Clone)]
+pub struct StripeCustomerUpdate {
+    pub address: Option<StripeCustomerUpdateAddress>,
+    pub name: Option<StripeCustomerUpdateName>,
+    pub shipping: Option<StripeCustomerUpdateShipping>,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum StripeCustomerUpdateAddress {
+    Auto,
+    Never,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum StripeCustomerUpdateName {
+    Auto,
+    Never,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum StripeCustomerUpdateShipping {
+    Auto,
+    Never,
+}
+
+#[derive(Debug, Default)]
+pub struct StripeCreateCheckoutSessionParams<'a> {
+    pub customer: Option<&'a StripeCustomerId>,
+    pub client_reference_id: Option<&'a str>,
+    pub mode: Option<StripeCheckoutSessionMode>,
+    pub line_items: Option<Vec<StripeCreateCheckoutSessionLineItems>>,
+    pub payment_method_collection: Option<StripeCheckoutSessionPaymentMethodCollection>,
+    pub subscription_data: Option<StripeCreateCheckoutSessionSubscriptionData>,
+    pub success_url: Option<&'a str>,
+    pub billing_address_collection: Option<StripeBillingAddressCollection>,
+    pub customer_update: Option<StripeCustomerUpdate>,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum StripeCheckoutSessionMode {
+    Payment,
+    Setup,
+    Subscription,
+}
+
+#[derive(Debug, PartialEq, Clone)]
+pub struct StripeCreateCheckoutSessionLineItems {
+    pub price: Option<String>,
+    pub quantity: Option<u64>,
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum StripeCheckoutSessionPaymentMethodCollection {
+    Always,
+    IfRequired,
+}
+
+#[derive(Debug, PartialEq, Clone)]
+pub struct StripeCreateCheckoutSessionSubscriptionData {
+    pub metadata: Option<HashMap<String, String>>,
+    pub trial_period_days: Option<u32>,
+    pub trial_settings: Option<StripeSubscriptionTrialSettings>,
+}
+
+#[derive(Debug)]
+pub struct StripeCheckoutSession {
+    pub url: Option<String>,
+}
+
+#[async_trait]
+pub trait StripeClient: Send + Sync {
+    async fn list_customers_by_email(&self, email: &str) -> Result<Vec<StripeCustomer>>;
+
+    async fn get_customer(&self, customer_id: &StripeCustomerId) -> Result<StripeCustomer>;
+
+    async fn create_customer(&self, params: CreateCustomerParams<'_>) -> Result<StripeCustomer>;
+
+    async fn update_customer(
+        &self,
+        customer_id: &StripeCustomerId,
+        params: UpdateCustomerParams<'_>,
+    ) -> Result<StripeCustomer>;
+
+    async fn list_subscriptions_for_customer(
+        &self,
+        customer_id: &StripeCustomerId,
+    ) -> Result<Vec<StripeSubscription>>;
+
+    async fn get_subscription(
+        &self,
+        subscription_id: &StripeSubscriptionId,
+    ) -> Result<StripeSubscription>;
+
+    async fn create_subscription(
+        &self,
+        params: StripeCreateSubscriptionParams,
+    ) -> Result<StripeSubscription>;
+
+    async fn update_subscription(
+        &self,
+        subscription_id: &StripeSubscriptionId,
+        params: UpdateSubscriptionParams,
+    ) -> Result<()>;
+
+    async fn cancel_subscription(&self, subscription_id: &StripeSubscriptionId) -> Result<()>;
+
+    async fn list_prices(&self) -> Result<Vec<StripePrice>>;
+
+    async fn list_meters(&self) -> Result<Vec<StripeMeter>>;
+
+    async fn create_meter_event(&self, params: StripeCreateMeterEventParams<'_>) -> Result<()>;
+
+    async fn create_checkout_session(
+        &self,
+        params: StripeCreateCheckoutSessionParams<'_>,
+    ) -> Result<StripeCheckoutSession>;
+}

crates/collab/src/stripe_client/fake_stripe_client.rs 🔗

@@ -0,0 +1,245 @@
+use std::sync::Arc;
+
+use anyhow::{Result, anyhow};
+use async_trait::async_trait;
+use chrono::{Duration, Utc};
+use collections::HashMap;
+use parking_lot::Mutex;
+use uuid::Uuid;
+
+use crate::stripe_client::{
+    CreateCustomerParams, StripeBillingAddressCollection, StripeCheckoutSession,
+    StripeCheckoutSessionMode, StripeCheckoutSessionPaymentMethodCollection, StripeClient,
+    StripeCreateCheckoutSessionLineItems, StripeCreateCheckoutSessionParams,
+    StripeCreateCheckoutSessionSubscriptionData, StripeCreateMeterEventParams,
+    StripeCreateSubscriptionParams, StripeCustomer, StripeCustomerId, StripeCustomerUpdate,
+    StripeMeter, StripeMeterId, StripePrice, StripePriceId, StripeSubscription,
+    StripeSubscriptionId, StripeSubscriptionItem, StripeSubscriptionItemId, UpdateCustomerParams,
+    UpdateSubscriptionParams,
+};
+
+#[derive(Debug, Clone)]
+pub struct StripeCreateMeterEventCall {
+    pub identifier: Arc<str>,
+    pub event_name: Arc<str>,
+    pub value: u64,
+    pub stripe_customer_id: StripeCustomerId,
+    pub timestamp: Option<i64>,
+}
+
+#[derive(Debug, Clone)]
+pub struct StripeCreateCheckoutSessionCall {
+    pub customer: Option<StripeCustomerId>,
+    pub client_reference_id: Option<String>,
+    pub mode: Option<StripeCheckoutSessionMode>,
+    pub line_items: Option<Vec<StripeCreateCheckoutSessionLineItems>>,
+    pub payment_method_collection: Option<StripeCheckoutSessionPaymentMethodCollection>,
+    pub subscription_data: Option<StripeCreateCheckoutSessionSubscriptionData>,
+    pub success_url: Option<String>,
+    pub billing_address_collection: Option<StripeBillingAddressCollection>,
+    pub customer_update: Option<StripeCustomerUpdate>,
+}
+
+pub struct FakeStripeClient {
+    pub customers: Arc<Mutex<HashMap<StripeCustomerId, StripeCustomer>>>,
+    pub subscriptions: Arc<Mutex<HashMap<StripeSubscriptionId, StripeSubscription>>>,
+    pub update_subscription_calls:
+        Arc<Mutex<Vec<(StripeSubscriptionId, UpdateSubscriptionParams)>>>,
+    pub prices: Arc<Mutex<HashMap<StripePriceId, StripePrice>>>,
+    pub meters: Arc<Mutex<HashMap<StripeMeterId, StripeMeter>>>,
+    pub create_meter_event_calls: Arc<Mutex<Vec<StripeCreateMeterEventCall>>>,
+    pub create_checkout_session_calls: Arc<Mutex<Vec<StripeCreateCheckoutSessionCall>>>,
+}
+
+impl FakeStripeClient {
+    pub fn new() -> Self {
+        Self {
+            customers: Arc::new(Mutex::new(HashMap::default())),
+            subscriptions: Arc::new(Mutex::new(HashMap::default())),
+            update_subscription_calls: Arc::new(Mutex::new(Vec::new())),
+            prices: Arc::new(Mutex::new(HashMap::default())),
+            meters: Arc::new(Mutex::new(HashMap::default())),
+            create_meter_event_calls: Arc::new(Mutex::new(Vec::new())),
+            create_checkout_session_calls: Arc::new(Mutex::new(Vec::new())),
+        }
+    }
+}
+
+#[async_trait]
+impl StripeClient for FakeStripeClient {
+    async fn list_customers_by_email(&self, email: &str) -> Result<Vec<StripeCustomer>> {
+        Ok(self
+            .customers
+            .lock()
+            .values()
+            .filter(|customer| customer.email.as_deref() == Some(email))
+            .cloned()
+            .collect())
+    }
+
+    async fn get_customer(&self, customer_id: &StripeCustomerId) -> Result<StripeCustomer> {
+        self.customers
+            .lock()
+            .get(customer_id)
+            .cloned()
+            .ok_or_else(|| anyhow!("no customer found for {customer_id:?}"))
+    }
+
+    async fn create_customer(&self, params: CreateCustomerParams<'_>) -> Result<StripeCustomer> {
+        let customer = StripeCustomer {
+            id: StripeCustomerId(format!("cus_{}", Uuid::new_v4()).into()),
+            email: params.email.map(|email| email.to_string()),
+        };
+
+        self.customers
+            .lock()
+            .insert(customer.id.clone(), customer.clone());
+
+        Ok(customer)
+    }
+
+    async fn update_customer(
+        &self,
+        customer_id: &StripeCustomerId,
+        params: UpdateCustomerParams<'_>,
+    ) -> Result<StripeCustomer> {
+        let mut customers = self.customers.lock();
+        if let Some(customer) = customers.get_mut(customer_id) {
+            if let Some(email) = params.email {
+                customer.email = Some(email.to_string());
+            }
+            Ok(customer.clone())
+        } else {
+            Err(anyhow!("no customer found for {customer_id:?}"))
+        }
+    }
+
+    async fn list_subscriptions_for_customer(
+        &self,
+        customer_id: &StripeCustomerId,
+    ) -> Result<Vec<StripeSubscription>> {
+        let subscriptions = self
+            .subscriptions
+            .lock()
+            .values()
+            .filter(|subscription| subscription.customer == *customer_id)
+            .cloned()
+            .collect();
+
+        Ok(subscriptions)
+    }
+
+    async fn get_subscription(
+        &self,
+        subscription_id: &StripeSubscriptionId,
+    ) -> Result<StripeSubscription> {
+        self.subscriptions
+            .lock()
+            .get(subscription_id)
+            .cloned()
+            .ok_or_else(|| anyhow!("no subscription found for {subscription_id:?}"))
+    }
+
+    async fn create_subscription(
+        &self,
+        params: StripeCreateSubscriptionParams,
+    ) -> Result<StripeSubscription> {
+        let now = Utc::now();
+
+        let subscription = StripeSubscription {
+            id: StripeSubscriptionId(format!("sub_{}", Uuid::new_v4()).into()),
+            customer: params.customer,
+            status: stripe::SubscriptionStatus::Active,
+            current_period_start: now.timestamp(),
+            current_period_end: (now + Duration::days(30)).timestamp(),
+            items: params
+                .items
+                .into_iter()
+                .map(|item| StripeSubscriptionItem {
+                    id: StripeSubscriptionItemId(format!("si_{}", Uuid::new_v4()).into()),
+                    price: item
+                        .price
+                        .and_then(|price_id| self.prices.lock().get(&price_id).cloned()),
+                })
+                .collect(),
+            cancel_at: None,
+            cancellation_details: None,
+        };
+
+        self.subscriptions
+            .lock()
+            .insert(subscription.id.clone(), subscription.clone());
+
+        Ok(subscription)
+    }
+
+    async fn update_subscription(
+        &self,
+        subscription_id: &StripeSubscriptionId,
+        params: UpdateSubscriptionParams,
+    ) -> Result<()> {
+        let subscription = self.get_subscription(subscription_id).await?;
+
+        self.update_subscription_calls
+            .lock()
+            .push((subscription.id, params));
+
+        Ok(())
+    }
+
+    async fn cancel_subscription(&self, subscription_id: &StripeSubscriptionId) -> Result<()> {
+        // TODO: Implement fake subscription cancellation.
+        let _ = subscription_id;
+
+        Ok(())
+    }
+
+    async fn list_prices(&self) -> Result<Vec<StripePrice>> {
+        let prices = self.prices.lock().values().cloned().collect();
+
+        Ok(prices)
+    }
+
+    async fn list_meters(&self) -> Result<Vec<StripeMeter>> {
+        let meters = self.meters.lock().values().cloned().collect();
+
+        Ok(meters)
+    }
+
+    async fn create_meter_event(&self, params: StripeCreateMeterEventParams<'_>) -> Result<()> {
+        self.create_meter_event_calls
+            .lock()
+            .push(StripeCreateMeterEventCall {
+                identifier: params.identifier.into(),
+                event_name: params.event_name.into(),
+                value: params.payload.value,
+                stripe_customer_id: params.payload.stripe_customer_id.clone(),
+                timestamp: params.timestamp,
+            });
+
+        Ok(())
+    }
+
+    async fn create_checkout_session(
+        &self,
+        params: StripeCreateCheckoutSessionParams<'_>,
+    ) -> Result<StripeCheckoutSession> {
+        self.create_checkout_session_calls
+            .lock()
+            .push(StripeCreateCheckoutSessionCall {
+                customer: params.customer.cloned(),
+                client_reference_id: params.client_reference_id.map(|id| id.to_string()),
+                mode: params.mode,
+                line_items: params.line_items,
+                payment_method_collection: params.payment_method_collection,
+                subscription_data: params.subscription_data,
+                success_url: params.success_url.map(|url| url.to_string()),
+                billing_address_collection: params.billing_address_collection,
+                customer_update: params.customer_update,
+            });
+
+        Ok(StripeCheckoutSession {
+            url: Some("https://checkout.stripe.com/c/pay/cs_test_1".to_string()),
+        })
+    }
+}

crates/collab/src/stripe_client/real_stripe_client.rs 🔗

@@ -0,0 +1,592 @@
+use std::str::FromStr as _;
+use std::sync::Arc;
+
+use anyhow::{Context as _, Result, anyhow};
+use async_trait::async_trait;
+use serde::{Deserialize, Serialize};
+use stripe::{
+    CancellationDetails, CancellationDetailsReason, CheckoutSession, CheckoutSessionMode,
+    CheckoutSessionPaymentMethodCollection, CreateCheckoutSession, CreateCheckoutSessionLineItems,
+    CreateCheckoutSessionSubscriptionData, CreateCheckoutSessionSubscriptionDataTrialSettings,
+    CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehavior,
+    CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehaviorMissingPaymentMethod,
+    CreateCustomer, Customer, CustomerId, ListCustomers, Price, PriceId, Recurring, Subscription,
+    SubscriptionId, SubscriptionItem, SubscriptionItemId, UpdateCustomer, UpdateSubscriptionItems,
+    UpdateSubscriptionTrialSettings, UpdateSubscriptionTrialSettingsEndBehavior,
+    UpdateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod,
+};
+
+use crate::stripe_client::{
+    CreateCustomerParams, StripeBillingAddressCollection, StripeCancellationDetails,
+    StripeCancellationDetailsReason, StripeCheckoutSession, StripeCheckoutSessionMode,
+    StripeCheckoutSessionPaymentMethodCollection, StripeClient,
+    StripeCreateCheckoutSessionLineItems, StripeCreateCheckoutSessionParams,
+    StripeCreateCheckoutSessionSubscriptionData, StripeCreateMeterEventParams,
+    StripeCreateSubscriptionParams, StripeCustomer, StripeCustomerId, StripeCustomerUpdate,
+    StripeCustomerUpdateAddress, StripeCustomerUpdateName, StripeCustomerUpdateShipping,
+    StripeMeter, StripePrice, StripePriceId, StripePriceRecurring, StripeSubscription,
+    StripeSubscriptionId, StripeSubscriptionItem, StripeSubscriptionItemId,
+    StripeSubscriptionTrialSettings, StripeSubscriptionTrialSettingsEndBehavior,
+    StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, UpdateCustomerParams,
+    UpdateSubscriptionParams,
+};
+
+pub struct RealStripeClient {
+    client: Arc<stripe::Client>,
+}
+
+impl RealStripeClient {
+    pub fn new(client: Arc<stripe::Client>) -> Self {
+        Self { client }
+    }
+}
+
+#[async_trait]
+impl StripeClient for RealStripeClient {
+    async fn list_customers_by_email(&self, email: &str) -> Result<Vec<StripeCustomer>> {
+        let response = Customer::list(
+            &self.client,
+            &ListCustomers {
+                email: Some(email),
+                ..Default::default()
+            },
+        )
+        .await?;
+
+        Ok(response
+            .data
+            .into_iter()
+            .map(StripeCustomer::from)
+            .collect())
+    }
+
+    async fn get_customer(&self, customer_id: &StripeCustomerId) -> Result<StripeCustomer> {
+        let customer_id = customer_id.try_into()?;
+
+        let customer = Customer::retrieve(&self.client, &customer_id, &[]).await?;
+
+        Ok(StripeCustomer::from(customer))
+    }
+
+    async fn create_customer(&self, params: CreateCustomerParams<'_>) -> Result<StripeCustomer> {
+        let customer = Customer::create(
+            &self.client,
+            CreateCustomer {
+                email: params.email,
+                ..Default::default()
+            },
+        )
+        .await?;
+
+        Ok(StripeCustomer::from(customer))
+    }
+
+    async fn update_customer(
+        &self,
+        customer_id: &StripeCustomerId,
+        params: UpdateCustomerParams<'_>,
+    ) -> Result<StripeCustomer> {
+        let customer = Customer::update(
+            &self.client,
+            &customer_id.try_into()?,
+            UpdateCustomer {
+                email: params.email,
+                ..Default::default()
+            },
+        )
+        .await?;
+
+        Ok(StripeCustomer::from(customer))
+    }
+
+    async fn list_subscriptions_for_customer(
+        &self,
+        customer_id: &StripeCustomerId,
+    ) -> Result<Vec<StripeSubscription>> {
+        let customer_id = customer_id.try_into()?;
+
+        let subscriptions = stripe::Subscription::list(
+            &self.client,
+            &stripe::ListSubscriptions {
+                customer: Some(customer_id),
+                status: None,
+                ..Default::default()
+            },
+        )
+        .await?;
+
+        Ok(subscriptions
+            .data
+            .into_iter()
+            .map(StripeSubscription::from)
+            .collect())
+    }
+
+    async fn get_subscription(
+        &self,
+        subscription_id: &StripeSubscriptionId,
+    ) -> Result<StripeSubscription> {
+        let subscription_id = subscription_id.try_into()?;
+
+        let subscription = Subscription::retrieve(&self.client, &subscription_id, &[]).await?;
+
+        Ok(StripeSubscription::from(subscription))
+    }
+
+    async fn create_subscription(
+        &self,
+        params: StripeCreateSubscriptionParams,
+    ) -> Result<StripeSubscription> {
+        let customer_id = params.customer.try_into()?;
+
+        let mut create_subscription = stripe::CreateSubscription::new(customer_id);
+        create_subscription.items = Some(
+            params
+                .items
+                .into_iter()
+                .map(|item| stripe::CreateSubscriptionItems {
+                    price: item.price.map(|price| price.to_string()),
+                    quantity: item.quantity,
+                    ..Default::default()
+                })
+                .collect(),
+        );
+
+        let subscription = Subscription::create(&self.client, create_subscription).await?;
+
+        Ok(StripeSubscription::from(subscription))
+    }
+
+    async fn update_subscription(
+        &self,
+        subscription_id: &StripeSubscriptionId,
+        params: UpdateSubscriptionParams,
+    ) -> Result<()> {
+        let subscription_id = subscription_id.try_into()?;
+
+        stripe::Subscription::update(
+            &self.client,
+            &subscription_id,
+            stripe::UpdateSubscription {
+                items: params.items.map(|items| {
+                    items
+                        .into_iter()
+                        .map(|item| UpdateSubscriptionItems {
+                            price: item.price.map(|price| price.to_string()),
+                            ..Default::default()
+                        })
+                        .collect()
+                }),
+                trial_settings: params.trial_settings.map(Into::into),
+                ..Default::default()
+            },
+        )
+        .await?;
+
+        Ok(())
+    }
+
+    async fn cancel_subscription(&self, subscription_id: &StripeSubscriptionId) -> Result<()> {
+        let subscription_id = subscription_id.try_into()?;
+
+        Subscription::cancel(
+            &self.client,
+            &subscription_id,
+            stripe::CancelSubscription {
+                invoice_now: None,
+                ..Default::default()
+            },
+        )
+        .await?;
+
+        Ok(())
+    }
+
+    async fn list_prices(&self) -> Result<Vec<StripePrice>> {
+        let response = stripe::Price::list(
+            &self.client,
+            &stripe::ListPrices {
+                limit: Some(100),
+                ..Default::default()
+            },
+        )
+        .await?;
+
+        Ok(response.data.into_iter().map(StripePrice::from).collect())
+    }
+
+    async fn list_meters(&self) -> Result<Vec<StripeMeter>> {
+        #[derive(Serialize)]
+        struct Params {
+            #[serde(skip_serializing_if = "Option::is_none")]
+            limit: Option<u64>,
+        }
+
+        let response = self
+            .client
+            .get_query::<stripe::List<StripeMeter>, _>(
+                "/billing/meters",
+                Params { limit: Some(100) },
+            )
+            .await?;
+
+        Ok(response.data)
+    }
+
+    async fn create_meter_event(&self, params: StripeCreateMeterEventParams<'_>) -> Result<()> {
+        #[derive(Deserialize)]
+        struct StripeMeterEvent {
+            pub identifier: String,
+        }
+
+        let identifier = params.identifier;
+        match self
+            .client
+            .post_form::<StripeMeterEvent, _>("/billing/meter_events", params)
+            .await
+        {
+            Ok(_event) => Ok(()),
+            Err(stripe::StripeError::Stripe(error)) => {
+                if error.http_status == 400
+                    && error
+                        .message
+                        .as_ref()
+                        .map_or(false, |message| message.contains(identifier))
+                {
+                    Ok(())
+                } else {
+                    Err(anyhow!(stripe::StripeError::Stripe(error)))
+                }
+            }
+            Err(error) => Err(anyhow!("failed to create meter event: {error:?}")),
+        }
+    }
+
+    async fn create_checkout_session(
+        &self,
+        params: StripeCreateCheckoutSessionParams<'_>,
+    ) -> Result<StripeCheckoutSession> {
+        let params = params.try_into()?;
+        let session = CheckoutSession::create(&self.client, params).await?;
+
+        Ok(session.into())
+    }
+}
+
+impl From<CustomerId> for StripeCustomerId {
+    fn from(value: CustomerId) -> Self {
+        Self(value.as_str().into())
+    }
+}
+
+impl TryFrom<StripeCustomerId> for CustomerId {
+    type Error = anyhow::Error;
+
+    fn try_from(value: StripeCustomerId) -> Result<Self, Self::Error> {
+        Self::from_str(value.0.as_ref()).context("failed to parse Stripe customer ID")
+    }
+}
+
+impl TryFrom<&StripeCustomerId> for CustomerId {
+    type Error = anyhow::Error;
+
+    fn try_from(value: &StripeCustomerId) -> Result<Self, Self::Error> {
+        Self::from_str(value.0.as_ref()).context("failed to parse Stripe customer ID")
+    }
+}
+
+impl From<Customer> for StripeCustomer {
+    fn from(value: Customer) -> Self {
+        StripeCustomer {
+            id: value.id.into(),
+            email: value.email,
+        }
+    }
+}
+
+impl From<SubscriptionId> for StripeSubscriptionId {
+    fn from(value: SubscriptionId) -> Self {
+        Self(value.as_str().into())
+    }
+}
+
+impl TryFrom<&StripeSubscriptionId> for SubscriptionId {
+    type Error = anyhow::Error;
+
+    fn try_from(value: &StripeSubscriptionId) -> Result<Self, Self::Error> {
+        Self::from_str(value.0.as_ref()).context("failed to parse Stripe subscription ID")
+    }
+}
+
+impl From<Subscription> for StripeSubscription {
+    fn from(value: Subscription) -> Self {
+        Self {
+            id: value.id.into(),
+            customer: value.customer.id().into(),
+            status: value.status,
+            current_period_start: value.current_period_start,
+            current_period_end: value.current_period_end,
+            items: value.items.data.into_iter().map(Into::into).collect(),
+            cancel_at: value.cancel_at,
+            cancellation_details: value.cancellation_details.map(Into::into),
+        }
+    }
+}
+
+impl From<CancellationDetails> for StripeCancellationDetails {
+    fn from(value: CancellationDetails) -> Self {
+        Self {
+            reason: value.reason.map(Into::into),
+        }
+    }
+}
+
+impl From<CancellationDetailsReason> for StripeCancellationDetailsReason {
+    fn from(value: CancellationDetailsReason) -> Self {
+        match value {
+            CancellationDetailsReason::CancellationRequested => Self::CancellationRequested,
+            CancellationDetailsReason::PaymentDisputed => Self::PaymentDisputed,
+            CancellationDetailsReason::PaymentFailed => Self::PaymentFailed,
+        }
+    }
+}
+
+impl From<SubscriptionItemId> for StripeSubscriptionItemId {
+    fn from(value: SubscriptionItemId) -> Self {
+        Self(value.as_str().into())
+    }
+}
+
+impl From<SubscriptionItem> for StripeSubscriptionItem {
+    fn from(value: SubscriptionItem) -> Self {
+        Self {
+            id: value.id.into(),
+            price: value.price.map(Into::into),
+        }
+    }
+}
+
+impl From<StripeSubscriptionTrialSettings> for UpdateSubscriptionTrialSettings {
+    fn from(value: StripeSubscriptionTrialSettings) -> Self {
+        Self {
+            end_behavior: value.end_behavior.into(),
+        }
+    }
+}
+
+impl From<StripeSubscriptionTrialSettingsEndBehavior>
+    for UpdateSubscriptionTrialSettingsEndBehavior
+{
+    fn from(value: StripeSubscriptionTrialSettingsEndBehavior) -> Self {
+        Self {
+            missing_payment_method: value.missing_payment_method.into(),
+        }
+    }
+}
+
+impl From<StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod>
+    for UpdateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod
+{
+    fn from(value: StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod) -> Self {
+        match value {
+            StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel => Self::Cancel,
+            StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::CreateInvoice => {
+                Self::CreateInvoice
+            }
+            StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Pause => Self::Pause,
+        }
+    }
+}
+
+impl From<PriceId> for StripePriceId {
+    fn from(value: PriceId) -> Self {
+        Self(value.as_str().into())
+    }
+}
+
+impl TryFrom<StripePriceId> for PriceId {
+    type Error = anyhow::Error;
+
+    fn try_from(value: StripePriceId) -> Result<Self, Self::Error> {
+        Self::from_str(value.0.as_ref()).context("failed to parse Stripe price ID")
+    }
+}
+
+impl From<Price> for StripePrice {
+    fn from(value: Price) -> Self {
+        Self {
+            id: value.id.into(),
+            unit_amount: value.unit_amount,
+            lookup_key: value.lookup_key,
+            recurring: value.recurring.map(StripePriceRecurring::from),
+        }
+    }
+}
+
+impl From<Recurring> for StripePriceRecurring {
+    fn from(value: Recurring) -> Self {
+        Self { meter: value.meter }
+    }
+}
+
+impl<'a> TryFrom<StripeCreateCheckoutSessionParams<'a>> for CreateCheckoutSession<'a> {
+    type Error = anyhow::Error;
+
+    fn try_from(value: StripeCreateCheckoutSessionParams<'a>) -> Result<Self, Self::Error> {
+        Ok(Self {
+            customer: value
+                .customer
+                .map(|customer_id| customer_id.try_into())
+                .transpose()?,
+            client_reference_id: value.client_reference_id,
+            mode: value.mode.map(Into::into),
+            line_items: value
+                .line_items
+                .map(|line_items| line_items.into_iter().map(Into::into).collect()),
+            payment_method_collection: value.payment_method_collection.map(Into::into),
+            subscription_data: value.subscription_data.map(Into::into),
+            success_url: value.success_url,
+            billing_address_collection: value.billing_address_collection.map(Into::into),
+            customer_update: value.customer_update.map(Into::into),
+            ..Default::default()
+        })
+    }
+}
+
+impl From<StripeCheckoutSessionMode> for CheckoutSessionMode {
+    fn from(value: StripeCheckoutSessionMode) -> Self {
+        match value {
+            StripeCheckoutSessionMode::Payment => Self::Payment,
+            StripeCheckoutSessionMode::Setup => Self::Setup,
+            StripeCheckoutSessionMode::Subscription => Self::Subscription,
+        }
+    }
+}
+
+impl From<StripeCreateCheckoutSessionLineItems> for CreateCheckoutSessionLineItems {
+    fn from(value: StripeCreateCheckoutSessionLineItems) -> Self {
+        Self {
+            price: value.price,
+            quantity: value.quantity,
+            ..Default::default()
+        }
+    }
+}
+
+impl From<StripeCheckoutSessionPaymentMethodCollection> for CheckoutSessionPaymentMethodCollection {
+    fn from(value: StripeCheckoutSessionPaymentMethodCollection) -> Self {
+        match value {
+            StripeCheckoutSessionPaymentMethodCollection::Always => Self::Always,
+            StripeCheckoutSessionPaymentMethodCollection::IfRequired => Self::IfRequired,
+        }
+    }
+}
+
+impl From<StripeCreateCheckoutSessionSubscriptionData> for CreateCheckoutSessionSubscriptionData {
+    fn from(value: StripeCreateCheckoutSessionSubscriptionData) -> Self {
+        Self {
+            trial_period_days: value.trial_period_days,
+            trial_settings: value.trial_settings.map(Into::into),
+            metadata: value.metadata,
+            ..Default::default()
+        }
+    }
+}
+
+impl From<StripeSubscriptionTrialSettings> for CreateCheckoutSessionSubscriptionDataTrialSettings {
+    fn from(value: StripeSubscriptionTrialSettings) -> Self {
+        Self {
+            end_behavior: value.end_behavior.into(),
+        }
+    }
+}
+
+impl From<StripeSubscriptionTrialSettingsEndBehavior>
+    for CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehavior
+{
+    fn from(value: StripeSubscriptionTrialSettingsEndBehavior) -> Self {
+        Self {
+            missing_payment_method: value.missing_payment_method.into(),
+        }
+    }
+}
+
+impl From<StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod>
+    for CreateCheckoutSessionSubscriptionDataTrialSettingsEndBehaviorMissingPaymentMethod
+{
+    fn from(value: StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod) -> Self {
+        match value {
+            StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel => Self::Cancel,
+            StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::CreateInvoice => {
+                Self::CreateInvoice
+            }
+            StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Pause => Self::Pause,
+        }
+    }
+}
+
+impl From<CheckoutSession> for StripeCheckoutSession {
+    fn from(value: CheckoutSession) -> Self {
+        Self { url: value.url }
+    }
+}
+
+impl From<StripeBillingAddressCollection> for stripe::CheckoutSessionBillingAddressCollection {
+    fn from(value: StripeBillingAddressCollection) -> Self {
+        match value {
+            StripeBillingAddressCollection::Auto => {
+                stripe::CheckoutSessionBillingAddressCollection::Auto
+            }
+            StripeBillingAddressCollection::Required => {
+                stripe::CheckoutSessionBillingAddressCollection::Required
+            }
+        }
+    }
+}
+
+impl From<StripeCustomerUpdateAddress> for stripe::CreateCheckoutSessionCustomerUpdateAddress {
+    fn from(value: StripeCustomerUpdateAddress) -> Self {
+        match value {
+            StripeCustomerUpdateAddress::Auto => {
+                stripe::CreateCheckoutSessionCustomerUpdateAddress::Auto
+            }
+            StripeCustomerUpdateAddress::Never => {
+                stripe::CreateCheckoutSessionCustomerUpdateAddress::Never
+            }
+        }
+    }
+}
+
+impl From<StripeCustomerUpdateName> for stripe::CreateCheckoutSessionCustomerUpdateName {
+    fn from(value: StripeCustomerUpdateName) -> Self {
+        match value {
+            StripeCustomerUpdateName::Auto => stripe::CreateCheckoutSessionCustomerUpdateName::Auto,
+            StripeCustomerUpdateName::Never => {
+                stripe::CreateCheckoutSessionCustomerUpdateName::Never
+            }
+        }
+    }
+}
+
+impl From<StripeCustomerUpdateShipping> for stripe::CreateCheckoutSessionCustomerUpdateShipping {
+    fn from(value: StripeCustomerUpdateShipping) -> Self {
+        match value {
+            StripeCustomerUpdateShipping::Auto => {
+                stripe::CreateCheckoutSessionCustomerUpdateShipping::Auto
+            }
+            StripeCustomerUpdateShipping::Never => {
+                stripe::CreateCheckoutSessionCustomerUpdateShipping::Never
+            }
+        }
+    }
+}
+
+impl From<StripeCustomerUpdate> for stripe::CreateCheckoutSessionCustomerUpdate {
+    fn from(value: StripeCustomerUpdate) -> Self {
+        stripe::CreateCheckoutSessionCustomerUpdate {
+            address: value.address.map(Into::into),
+            name: value.name.map(Into::into),
+            shipping: value.shipping.map(Into::into),
+        }
+    }
+}

crates/collab/src/tests.rs 🔗

@@ -18,6 +18,7 @@ mod random_channel_buffer_tests;
 mod random_project_collaboration_tests;
 mod randomized_test_helpers;
 mod remote_editing_collaboration_tests;
+mod stripe_billing_tests;
 mod test_server;
 
 use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
@@ -36,8 +37,8 @@ fn room_participants(room: &Entity<Room>, cx: &mut TestAppContext) -> RoomPartic
     room.read_with(cx, |room, _| {
         let mut remote = room
             .remote_participants()
-            .iter()
-            .map(|(_, participant)| participant.user.github_login.clone())
+            .values()
+            .map(|participant| participant.user.github_login.clone())
             .collect::<Vec<_>>();
         let mut pending = room
             .pending_participants()

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

@@ -178,7 +178,7 @@ async fn test_channel_notes_participant_indices(
     channel_view_a.update_in(cx_a, |notes, window, cx| {
         notes.editor.update(cx, |editor, cx| {
             editor.insert("a", window, cx);
-            editor.change_selections(None, window, cx, |selections| {
+            editor.change_selections(Default::default(), window, cx, |selections| {
                 selections.select_ranges(vec![0..1]);
             });
         });
@@ -188,7 +188,7 @@ async fn test_channel_notes_participant_indices(
         notes.editor.update(cx, |editor, cx| {
             editor.move_down(&Default::default(), window, cx);
             editor.insert("b", window, cx);
-            editor.change_selections(None, window, cx, |selections| {
+            editor.change_selections(Default::default(), window, cx, |selections| {
                 selections.select_ranges(vec![1..2]);
             });
         });
@@ -198,7 +198,7 @@ async fn test_channel_notes_participant_indices(
         notes.editor.update(cx, |editor, cx| {
             editor.move_down(&Default::default(), window, cx);
             editor.insert("c", window, cx);
-            editor.change_selections(None, window, cx, |selections| {
+            editor.change_selections(Default::default(), window, cx, |selections| {
                 selections.select_ranges(vec![2..3]);
             });
         });
@@ -273,12 +273,12 @@ async fn test_channel_notes_participant_indices(
         .unwrap();
 
     editor_a.update_in(cx_a, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |selections| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
             selections.select_ranges(vec![0..1]);
         });
     });
     editor_b.update_in(cx_b, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |selections| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
             selections.select_ranges(vec![2..3]);
         });
     });

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

@@ -180,7 +180,7 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes
     server
         .app_state
         .db
-        .get_or_create_user_by_github_account("user_b", 100, None, None, Utc::now(), None)
+        .update_or_create_user_by_github_account("user_b", 100, None, None, Utc::now(), None)
         .await
         .unwrap();
 

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

@@ -18,9 +18,7 @@ use workspace::{Workspace, dock::Panel};
 use super::{TestClient, TestServer};
 
 pub fn init_test(cx: &mut gpui::TestAppContext) {
-    if std::env::var("RUST_LOG").is_ok() {
-        env_logger::try_init().ok();
-    }
+    zlog::init_test();
 
     cx.update(|cx| {
         theme::init(theme::LoadThemes::JustBase, cx);

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

@@ -4,10 +4,10 @@ use crate::{
 };
 use call::ActiveCall;
 use editor::{
-    Editor, RowInfo,
+    DocumentColorsRenderMode, Editor, EditorSettings, RowInfo, SelectionEffects,
     actions::{
         ConfirmCodeAction, ConfirmCompletion, ConfirmRename, ContextMenuFirst,
-        ExpandMacroRecursively, Redo, Rename, ToggleCodeActions, Undo,
+        ExpandMacroRecursively, MoveToEnd, Redo, Rename, SelectAll, ToggleCodeActions, Undo,
     },
     test::{
         editor_test_context::{AssertionContextManager, EditorTestContext},
@@ -15,8 +15,8 @@ use editor::{
     },
 };
 use fs::Fs;
-use futures::StreamExt;
-use gpui::{TestAppContext, UpdateGlobal, VisualContext, VisualTestContext};
+use futures::{StreamExt, lock::Mutex};
+use gpui::{App, Rgba, TestAppContext, UpdateGlobal, VisualContext, VisualTestContext};
 use indoc::indoc;
 use language::{
     FakeLspAdapter,
@@ -35,7 +35,8 @@ use rpc::RECEIVE_TIMEOUT;
 use serde_json::json;
 use settings::SettingsStore;
 use std::{
-    ops::Range,
+    collections::BTreeSet,
+    ops::{Deref as _, Range},
     path::{Path, PathBuf},
     sync::{
         Arc,
@@ -347,7 +348,9 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu
 
     // Type a completion trigger character as the guest.
     editor_b.update_in(cx_b, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| s.select_ranges([13..13]));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([13..13])
+        });
         editor.handle_input(".", window, cx);
     });
     cx_b.focus(&editor_b);
@@ -460,7 +463,9 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu
     // Now we do a second completion, this time to ensure that documentation/snippets are
     // resolved
     editor_b.update_in(cx_b, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| s.select_ranges([46..46]));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([46..46])
+        });
         editor.handle_input("; a", window, cx);
         editor.handle_input(".", window, cx);
     });
@@ -612,7 +617,7 @@ async fn test_collaborating_with_code_actions(
 
     // Move cursor to a location that contains code actions.
     editor_b.update_in(cx_b, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_ranges([Point::new(1, 31)..Point::new(1, 31)])
         });
     });
@@ -679,7 +684,7 @@ async fn test_collaborating_with_code_actions(
     editor_b.update_in(cx_b, |editor, window, cx| {
         editor.toggle_code_actions(
             &ToggleCodeActions {
-                deployed_from_indicator: None,
+                deployed_from: None,
                 quick_launch: false,
             },
             window,
@@ -816,7 +821,9 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T
 
     // Move cursor to a location that can be renamed.
     let prepare_rename = editor_b.update_in(cx_b, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| s.select_ranges([7..7]));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([7..7])
+        });
         editor.rename(&Rename, window, cx).unwrap()
     });
 
@@ -862,7 +869,9 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T
         editor.cancel(&editor::actions::Cancel, window, cx);
     });
     let prepare_rename = editor_b.update_in(cx_b, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| s.select_ranges([7..8]));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([7..8])
+        });
         editor.rename(&Rename, window, cx).unwrap()
     });
 
@@ -1363,7 +1372,9 @@ async fn test_on_input_format_from_host_to_guest(
     // Type a on type formatting trigger character as the guest.
     cx_a.focus(&editor_a);
     editor_a.update_in(cx_a, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| s.select_ranges([13..13]));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([13..13])
+        });
         editor.handle_input(">", window, cx);
     });
 
@@ -1459,7 +1470,9 @@ async fn test_on_input_format_from_guest_to_host(
     // Type a on type formatting trigger character as the guest.
     cx_b.focus(&editor_b);
     editor_b.update_in(cx_b, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| s.select_ranges([13..13]));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([13..13])
+        });
         editor.handle_input(":", window, cx);
     });
 
@@ -1696,7 +1709,9 @@ async fn test_mutual_editor_inlay_hint_cache_update(
 
     let after_client_edit = edits_made.fetch_add(1, atomic::Ordering::Release) + 1;
     editor_b.update_in(cx_b, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| s.select_ranges([13..13].clone()));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([13..13].clone())
+        });
         editor.handle_input(":", window, cx);
     });
     cx_b.focus(&editor_b);
@@ -1717,7 +1732,9 @@ async fn test_mutual_editor_inlay_hint_cache_update(
 
     let after_host_edit = edits_made.fetch_add(1, atomic::Ordering::Release) + 1;
     editor_a.update_in(cx_a, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| s.select_ranges([13..13]));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([13..13])
+        });
         editor.handle_input("a change to increment both buffers' versions", window, cx);
     });
     cx_a.focus(&editor_a);
@@ -1951,6 +1968,878 @@ async fn test_inlay_hint_refresh_is_forwarded(
     });
 }
 
+#[gpui::test(iterations = 10)]
+async fn test_lsp_document_color(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+    let expected_color = Rgba {
+        r: 0.33,
+        g: 0.33,
+        b: 0.33,
+        a: 0.33,
+    };
+    let mut server = TestServer::start(cx_a.executor()).await;
+    let executor = cx_a.executor();
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
+    cx_a.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings::<EditorSettings>(cx, |settings| {
+                settings.lsp_document_colors = Some(DocumentColorsRenderMode::None);
+            });
+        });
+    });
+    cx_b.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings::<EditorSettings>(cx, |settings| {
+                settings.lsp_document_colors = Some(DocumentColorsRenderMode::Inlay);
+            });
+        });
+    });
+
+    client_a.language_registry().add(rust_lang());
+    client_b.language_registry().add(rust_lang());
+    let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                color_provider: Some(lsp::ColorProviderCapability::Simple(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    // Client A opens a project.
+    client_a
+        .fs()
+        .insert_tree(
+            path!("/a"),
+            json!({
+                "main.rs": "fn main() { a }",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
+    active_call_a
+        .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
+        .await
+        .unwrap();
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+
+    // Client B joins the project
+    let project_b = client_b.join_remote_project(project_id, cx_b).await;
+    active_call_b
+        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+        .await
+        .unwrap();
+
+    let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
+
+    // The host opens a rust file.
+    let _buffer_a = project_a
+        .update(cx_a, |project, cx| {
+            project.open_local_buffer(path!("/a/main.rs"), cx)
+        })
+        .await
+        .unwrap();
+    let editor_a = workspace_a
+        .update_in(cx_a, |workspace, window, cx| {
+            workspace.open_path((worktree_id, "main.rs"), None, true, window, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+
+    let fake_language_server = fake_language_servers.next().await.unwrap();
+
+    let requests_made = Arc::new(AtomicUsize::new(0));
+    let closure_requests_made = Arc::clone(&requests_made);
+    let mut color_request_handle = fake_language_server
+        .set_request_handler::<lsp::request::DocumentColor, _, _>(move |params, _| {
+            let requests_made = Arc::clone(&closure_requests_made);
+            async move {
+                assert_eq!(
+                    params.text_document.uri,
+                    lsp::Url::from_file_path(path!("/a/main.rs")).unwrap(),
+                );
+                requests_made.fetch_add(1, atomic::Ordering::Release);
+                Ok(vec![lsp::ColorInformation {
+                    range: lsp::Range {
+                        start: lsp::Position {
+                            line: 0,
+                            character: 0,
+                        },
+                        end: lsp::Position {
+                            line: 0,
+                            character: 1,
+                        },
+                    },
+                    color: lsp::Color {
+                        red: 0.33,
+                        green: 0.33,
+                        blue: 0.33,
+                        alpha: 0.33,
+                    },
+                }])
+            }
+        });
+    executor.run_until_parked();
+
+    assert_eq!(
+        0,
+        requests_made.load(atomic::Ordering::Acquire),
+        "Host did not enable document colors, hence should query for none"
+    );
+    editor_a.update(cx_a, |editor, cx| {
+        assert_eq!(
+            Vec::<Rgba>::new(),
+            extract_color_inlays(editor, cx),
+            "No query colors should result in no hints"
+        );
+    });
+
+    let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
+    let editor_b = workspace_b
+        .update_in(cx_b, |workspace, window, cx| {
+            workspace.open_path((worktree_id, "main.rs"), None, true, window, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+
+    color_request_handle.next().await.unwrap();
+    executor.run_until_parked();
+
+    assert_eq!(
+        1,
+        requests_made.load(atomic::Ordering::Acquire),
+        "The client opened the file and got its first colors back"
+    );
+    editor_b.update(cx_b, |editor, cx| {
+        assert_eq!(
+            vec![expected_color],
+            extract_color_inlays(editor, cx),
+            "With document colors as inlays, color inlays should be pushed"
+        );
+    });
+
+    editor_a.update_in(cx_a, |editor, window, cx| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([13..13].clone())
+        });
+        editor.handle_input(":", window, cx);
+    });
+    color_request_handle.next().await.unwrap();
+    executor.run_until_parked();
+    assert_eq!(
+        2,
+        requests_made.load(atomic::Ordering::Acquire),
+        "After the host edits his file, the client should request the colors again"
+    );
+    editor_a.update(cx_a, |editor, cx| {
+        assert_eq!(
+            Vec::<Rgba>::new(),
+            extract_color_inlays(editor, cx),
+            "Host has no colors still"
+        );
+    });
+    editor_b.update(cx_b, |editor, cx| {
+        assert_eq!(vec![expected_color], extract_color_inlays(editor, cx),);
+    });
+
+    cx_b.update(|_, cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings::<EditorSettings>(cx, |settings| {
+                settings.lsp_document_colors = Some(DocumentColorsRenderMode::Background);
+            });
+        });
+    });
+    executor.run_until_parked();
+    assert_eq!(
+        2,
+        requests_made.load(atomic::Ordering::Acquire),
+        "After the client have changed the colors settings, no extra queries should happen"
+    );
+    editor_a.update(cx_a, |editor, cx| {
+        assert_eq!(
+            Vec::<Rgba>::new(),
+            extract_color_inlays(editor, cx),
+            "Host is unaffected by the client's settings changes"
+        );
+    });
+    editor_b.update(cx_b, |editor, cx| {
+        assert_eq!(
+            Vec::<Rgba>::new(),
+            extract_color_inlays(editor, cx),
+            "Client should have no colors hints, as in the settings"
+        );
+    });
+
+    cx_b.update(|_, cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings::<EditorSettings>(cx, |settings| {
+                settings.lsp_document_colors = Some(DocumentColorsRenderMode::Inlay);
+            });
+        });
+    });
+    executor.run_until_parked();
+    assert_eq!(
+        2,
+        requests_made.load(atomic::Ordering::Acquire),
+        "After falling back to colors as inlays, no extra LSP queries are made"
+    );
+    editor_a.update(cx_a, |editor, cx| {
+        assert_eq!(
+            Vec::<Rgba>::new(),
+            extract_color_inlays(editor, cx),
+            "Host is unaffected by the client's settings changes, again"
+        );
+    });
+    editor_b.update(cx_b, |editor, cx| {
+        assert_eq!(
+            vec![expected_color],
+            extract_color_inlays(editor, cx),
+            "Client should have its color hints back"
+        );
+    });
+
+    cx_a.update(|_, cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings::<EditorSettings>(cx, |settings| {
+                settings.lsp_document_colors = Some(DocumentColorsRenderMode::Border);
+            });
+        });
+    });
+    color_request_handle.next().await.unwrap();
+    executor.run_until_parked();
+    assert_eq!(
+        3,
+        requests_made.load(atomic::Ordering::Acquire),
+        "After the host enables document colors, another LSP query should be made"
+    );
+    editor_a.update(cx_a, |editor, cx| {
+        assert_eq!(
+            Vec::<Rgba>::new(),
+            extract_color_inlays(editor, cx),
+            "Host did not configure document colors as hints hence gets nothing"
+        );
+    });
+    editor_b.update(cx_b, |editor, cx| {
+        assert_eq!(
+            vec![expected_color],
+            extract_color_inlays(editor, cx),
+            "Client should be unaffected by the host's settings changes"
+        );
+    });
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+    let mut server = TestServer::start(cx_a.executor()).await;
+    let executor = cx_a.executor();
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
+    client_a.language_registry().add(rust_lang());
+    client_b.language_registry().add(rust_lang());
+    let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options(
+                    lsp::DiagnosticOptions {
+                        identifier: Some("test-pulls".to_string()),
+                        inter_file_dependencies: true,
+                        workspace_diagnostics: true,
+                        work_done_progress_options: lsp::WorkDoneProgressOptions {
+                            work_done_progress: None,
+                        },
+                    },
+                )),
+                ..lsp::ServerCapabilities::default()
+            },
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    // Client A opens a project.
+    client_a
+        .fs()
+        .insert_tree(
+            path!("/a"),
+            json!({
+                "main.rs": "fn main() { a }",
+                "lib.rs": "fn other() {}",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
+    active_call_a
+        .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
+        .await
+        .unwrap();
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+
+    // Client B joins the project
+    let project_b = client_b.join_remote_project(project_id, cx_b).await;
+    active_call_b
+        .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+        .await
+        .unwrap();
+
+    let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
+    executor.start_waiting();
+
+    // The host opens a rust file.
+    let _buffer_a = project_a
+        .update(cx_a, |project, cx| {
+            project.open_local_buffer(path!("/a/main.rs"), cx)
+        })
+        .await
+        .unwrap();
+    let editor_a_main = workspace_a
+        .update_in(cx_a, |workspace, window, cx| {
+            workspace.open_path((worktree_id, "main.rs"), None, true, window, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+
+    let fake_language_server = fake_language_servers.next().await.unwrap();
+    let expected_push_diagnostic_main_message = "pushed main diagnostic";
+    let expected_push_diagnostic_lib_message = "pushed lib diagnostic";
+    let expected_pull_diagnostic_main_message = "pulled main diagnostic";
+    let expected_pull_diagnostic_lib_message = "pulled lib diagnostic";
+    let expected_workspace_pull_diagnostics_main_message = "pulled workspace main diagnostic";
+    let expected_workspace_pull_diagnostics_lib_message = "pulled workspace lib diagnostic";
+
+    let diagnostics_pulls_result_ids = Arc::new(Mutex::new(BTreeSet::<Option<String>>::new()));
+    let workspace_diagnostics_pulls_result_ids = Arc::new(Mutex::new(BTreeSet::<String>::new()));
+    let diagnostics_pulls_made = Arc::new(AtomicUsize::new(0));
+    let closure_diagnostics_pulls_made = diagnostics_pulls_made.clone();
+    let closure_diagnostics_pulls_result_ids = diagnostics_pulls_result_ids.clone();
+    let mut pull_diagnostics_handle = fake_language_server
+        .set_request_handler::<lsp::request::DocumentDiagnosticRequest, _, _>(move |params, _| {
+            let requests_made = closure_diagnostics_pulls_made.clone();
+            let diagnostics_pulls_result_ids = closure_diagnostics_pulls_result_ids.clone();
+            async move {
+                let message = if lsp::Url::from_file_path(path!("/a/main.rs")).unwrap()
+                    == params.text_document.uri
+                {
+                    expected_pull_diagnostic_main_message.to_string()
+                } else if lsp::Url::from_file_path(path!("/a/lib.rs")).unwrap()
+                    == params.text_document.uri
+                {
+                    expected_pull_diagnostic_lib_message.to_string()
+                } else {
+                    panic!("Unexpected document: {}", params.text_document.uri)
+                };
+                {
+                    diagnostics_pulls_result_ids
+                        .lock()
+                        .await
+                        .insert(params.previous_result_id);
+                }
+                let new_requests_count = requests_made.fetch_add(1, atomic::Ordering::Release) + 1;
+                Ok(lsp::DocumentDiagnosticReportResult::Report(
+                    lsp::DocumentDiagnosticReport::Full(lsp::RelatedFullDocumentDiagnosticReport {
+                        related_documents: None,
+                        full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport {
+                            result_id: Some(format!("pull-{new_requests_count}")),
+                            items: vec![lsp::Diagnostic {
+                                range: lsp::Range {
+                                    start: lsp::Position {
+                                        line: 0,
+                                        character: 0,
+                                    },
+                                    end: lsp::Position {
+                                        line: 0,
+                                        character: 2,
+                                    },
+                                },
+                                severity: Some(lsp::DiagnosticSeverity::ERROR),
+                                message,
+                                ..lsp::Diagnostic::default()
+                            }],
+                        },
+                    }),
+                ))
+            }
+        });
+
+    let workspace_diagnostics_pulls_made = Arc::new(AtomicUsize::new(0));
+    let closure_workspace_diagnostics_pulls_made = workspace_diagnostics_pulls_made.clone();
+    let closure_workspace_diagnostics_pulls_result_ids =
+        workspace_diagnostics_pulls_result_ids.clone();
+    let mut workspace_diagnostics_pulls_handle = fake_language_server
+        .set_request_handler::<lsp::request::WorkspaceDiagnosticRequest, _, _>(
+        move |params, _| {
+            let workspace_requests_made = closure_workspace_diagnostics_pulls_made.clone();
+            let workspace_diagnostics_pulls_result_ids =
+                closure_workspace_diagnostics_pulls_result_ids.clone();
+            async move {
+                let workspace_request_count =
+                    workspace_requests_made.fetch_add(1, atomic::Ordering::Release) + 1;
+                {
+                    workspace_diagnostics_pulls_result_ids
+                        .lock()
+                        .await
+                        .extend(params.previous_result_ids.into_iter().map(|id| id.value));
+                }
+                Ok(lsp::WorkspaceDiagnosticReportResult::Report(
+                    lsp::WorkspaceDiagnosticReport {
+                        items: vec![
+                            lsp::WorkspaceDocumentDiagnosticReport::Full(
+                                lsp::WorkspaceFullDocumentDiagnosticReport {
+                                    uri: lsp::Url::from_file_path(path!("/a/main.rs")).unwrap(),
+                                    version: None,
+                                    full_document_diagnostic_report:
+                                        lsp::FullDocumentDiagnosticReport {
+                                            result_id: Some(format!(
+                                                "workspace_{workspace_request_count}"
+                                            )),
+                                            items: vec![lsp::Diagnostic {
+                                                range: lsp::Range {
+                                                    start: lsp::Position {
+                                                        line: 0,
+                                                        character: 1,
+                                                    },
+                                                    end: lsp::Position {
+                                                        line: 0,
+                                                        character: 3,
+                                                    },
+                                                },
+                                                severity: Some(lsp::DiagnosticSeverity::WARNING),
+                                                message:
+                                                    expected_workspace_pull_diagnostics_main_message
+                                                        .to_string(),
+                                                ..lsp::Diagnostic::default()
+                                            }],
+                                        },
+                                },
+                            ),
+                            lsp::WorkspaceDocumentDiagnosticReport::Full(
+                                lsp::WorkspaceFullDocumentDiagnosticReport {
+                                    uri: lsp::Url::from_file_path(path!("/a/lib.rs")).unwrap(),
+                                    version: None,
+                                    full_document_diagnostic_report:
+                                        lsp::FullDocumentDiagnosticReport {
+                                            result_id: Some(format!(
+                                                "workspace_{workspace_request_count}"
+                                            )),
+                                            items: vec![lsp::Diagnostic {
+                                                range: lsp::Range {
+                                                    start: lsp::Position {
+                                                        line: 0,
+                                                        character: 1,
+                                                    },
+                                                    end: lsp::Position {
+                                                        line: 0,
+                                                        character: 3,
+                                                    },
+                                                },
+                                                severity: Some(lsp::DiagnosticSeverity::WARNING),
+                                                message:
+                                                    expected_workspace_pull_diagnostics_lib_message
+                                                        .to_string(),
+                                                ..lsp::Diagnostic::default()
+                                            }],
+                                        },
+                                },
+                            ),
+                        ],
+                    },
+                ))
+            }
+        },
+    );
+
+    workspace_diagnostics_pulls_handle.next().await.unwrap();
+    assert_eq!(
+        1,
+        workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "Workspace diagnostics should be pulled initially on a server startup"
+    );
+    pull_diagnostics_handle.next().await.unwrap();
+    assert_eq!(
+        1,
+        diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "Host should query pull diagnostics when the editor is opened"
+    );
+    executor.run_until_parked();
+    editor_a_main.update(cx_a, |editor, cx| {
+        let snapshot = editor.buffer().read(cx).snapshot(cx);
+        let all_diagnostics = snapshot
+            .diagnostics_in_range(0..snapshot.len())
+            .collect::<Vec<_>>();
+        assert_eq!(
+            all_diagnostics.len(),
+            1,
+            "Expected single diagnostic, but got: {all_diagnostics:?}"
+        );
+        let diagnostic = &all_diagnostics[0];
+        let expected_messages = [
+            expected_workspace_pull_diagnostics_main_message,
+            expected_pull_diagnostic_main_message,
+        ];
+        assert!(
+            expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
+            "Expected {expected_messages:?} on the host, but got: {}",
+            diagnostic.diagnostic.message
+        );
+    });
+
+    fake_language_server.notify::<lsp::notification::PublishDiagnostics>(
+        &lsp::PublishDiagnosticsParams {
+            uri: lsp::Url::from_file_path(path!("/a/main.rs")).unwrap(),
+            diagnostics: vec![lsp::Diagnostic {
+                range: lsp::Range {
+                    start: lsp::Position {
+                        line: 0,
+                        character: 3,
+                    },
+                    end: lsp::Position {
+                        line: 0,
+                        character: 4,
+                    },
+                },
+                severity: Some(lsp::DiagnosticSeverity::INFORMATION),
+                message: expected_push_diagnostic_main_message.to_string(),
+                ..lsp::Diagnostic::default()
+            }],
+            version: None,
+        },
+    );
+    fake_language_server.notify::<lsp::notification::PublishDiagnostics>(
+        &lsp::PublishDiagnosticsParams {
+            uri: lsp::Url::from_file_path(path!("/a/lib.rs")).unwrap(),
+            diagnostics: vec![lsp::Diagnostic {
+                range: lsp::Range {
+                    start: lsp::Position {
+                        line: 0,
+                        character: 3,
+                    },
+                    end: lsp::Position {
+                        line: 0,
+                        character: 4,
+                    },
+                },
+                severity: Some(lsp::DiagnosticSeverity::INFORMATION),
+                message: expected_push_diagnostic_lib_message.to_string(),
+                ..lsp::Diagnostic::default()
+            }],
+            version: None,
+        },
+    );
+    executor.run_until_parked();
+    editor_a_main.update(cx_a, |editor, cx| {
+        let snapshot = editor.buffer().read(cx).snapshot(cx);
+        let all_diagnostics = snapshot
+            .diagnostics_in_range(0..snapshot.len())
+            .collect::<Vec<_>>();
+        assert_eq!(
+            all_diagnostics.len(),
+            2,
+            "Expected pull and push diagnostics, but got: {all_diagnostics:?}"
+        );
+        let expected_messages = [
+            expected_workspace_pull_diagnostics_main_message,
+            expected_pull_diagnostic_main_message,
+            expected_push_diagnostic_main_message,
+        ];
+        for diagnostic in all_diagnostics {
+            assert!(
+                expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
+                "Expected push and pull messages on the host: {expected_messages:?}, but got: {}",
+                diagnostic.diagnostic.message
+            );
+        }
+    });
+
+    let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
+    let editor_b_main = workspace_b
+        .update_in(cx_b, |workspace, window, cx| {
+            workspace.open_path((worktree_id, "main.rs"), None, true, window, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+
+    pull_diagnostics_handle.next().await.unwrap();
+    assert_eq!(
+        2,
+        diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "Client should query pull diagnostics when its editor is opened"
+    );
+    executor.run_until_parked();
+    assert_eq!(
+        1,
+        workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "Workspace diagnostics should not be changed as the remote client does not initialize the workspace diagnostics pull"
+    );
+    editor_b_main.update(cx_b, |editor, cx| {
+        let snapshot = editor.buffer().read(cx).snapshot(cx);
+        let all_diagnostics = snapshot
+            .diagnostics_in_range(0..snapshot.len())
+            .collect::<Vec<_>>();
+        assert_eq!(
+            all_diagnostics.len(),
+            2,
+            "Expected pull and push diagnostics, but got: {all_diagnostics:?}"
+        );
+
+        // Despite the workspace diagnostics not re-initialized for the remote client, we can still expect its message synced from the host.
+        let expected_messages = [
+            expected_workspace_pull_diagnostics_main_message,
+            expected_pull_diagnostic_main_message,
+            expected_push_diagnostic_main_message,
+        ];
+        for diagnostic in all_diagnostics {
+            assert!(
+                expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
+                "The client should get both push and pull messages: {expected_messages:?}, but got: {}",
+                diagnostic.diagnostic.message
+            );
+        }
+    });
+
+    let editor_b_lib = workspace_b
+        .update_in(cx_b, |workspace, window, cx| {
+            workspace.open_path((worktree_id, "lib.rs"), None, true, window, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+
+    pull_diagnostics_handle.next().await.unwrap();
+    assert_eq!(
+        3,
+        diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "Client should query pull diagnostics when its another editor is opened"
+    );
+    executor.run_until_parked();
+    assert_eq!(
+        1,
+        workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "The remote client still did not anything to trigger the workspace diagnostics pull"
+    );
+    editor_b_lib.update(cx_b, |editor, cx| {
+        let snapshot = editor.buffer().read(cx).snapshot(cx);
+        let all_diagnostics = snapshot
+            .diagnostics_in_range(0..snapshot.len())
+            .collect::<Vec<_>>();
+        let expected_messages = [
+            expected_pull_diagnostic_lib_message,
+            // TODO bug: the pushed diagnostics are not being sent to the client when they open the corresponding buffer.
+            // expected_push_diagnostic_lib_message,
+        ];
+        assert_eq!(
+            all_diagnostics.len(),
+            1,
+            "Expected pull diagnostics, but got: {all_diagnostics:?}"
+        );
+        for diagnostic in all_diagnostics {
+            assert!(
+                expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
+                "The client should get both push and pull messages: {expected_messages:?}, but got: {}",
+                diagnostic.diagnostic.message
+            );
+        }
+    });
+    {
+        assert!(
+            diagnostics_pulls_result_ids.lock().await.len() > 0,
+            "Initial diagnostics pulls should report None at least"
+        );
+        assert_eq!(
+            0,
+            workspace_diagnostics_pulls_result_ids
+                .lock()
+                .await
+                .deref()
+                .len(),
+            "After the initial workspace request, opening files should not reuse any result ids"
+        );
+    }
+
+    editor_b_lib.update_in(cx_b, |editor, window, cx| {
+        editor.move_to_end(&MoveToEnd, window, cx);
+        editor.handle_input(":", window, cx);
+    });
+    pull_diagnostics_handle.next().await.unwrap();
+    assert_eq!(
+        4,
+        diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "Client lib.rs edits should trigger another diagnostics pull for a buffer"
+    );
+    workspace_diagnostics_pulls_handle.next().await.unwrap();
+    assert_eq!(
+        2,
+        workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "After client lib.rs edits, the workspace diagnostics request should follow"
+    );
+    executor.run_until_parked();
+
+    editor_b_main.update_in(cx_b, |editor, window, cx| {
+        editor.move_to_end(&MoveToEnd, window, cx);
+        editor.handle_input(":", window, cx);
+    });
+    pull_diagnostics_handle.next().await.unwrap();
+    pull_diagnostics_handle.next().await.unwrap();
+    assert_eq!(
+        6,
+        diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "Client main.rs edits should trigger another diagnostics pull by both client and host as they share the buffer"
+    );
+    workspace_diagnostics_pulls_handle.next().await.unwrap();
+    assert_eq!(
+        3,
+        workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "After client main.rs edits, the workspace diagnostics pull should follow"
+    );
+    executor.run_until_parked();
+
+    editor_a_main.update_in(cx_a, |editor, window, cx| {
+        editor.move_to_end(&MoveToEnd, window, cx);
+        editor.handle_input(":", window, cx);
+    });
+    pull_diagnostics_handle.next().await.unwrap();
+    pull_diagnostics_handle.next().await.unwrap();
+    assert_eq!(
+        8,
+        diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "Host main.rs edits should trigger another diagnostics pull by both client and host as they share the buffer"
+    );
+    workspace_diagnostics_pulls_handle.next().await.unwrap();
+    assert_eq!(
+        4,
+        workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "After host main.rs edits, the workspace diagnostics pull should follow"
+    );
+    executor.run_until_parked();
+    let diagnostic_pulls_result_ids = diagnostics_pulls_result_ids.lock().await.len();
+    let workspace_pulls_result_ids = workspace_diagnostics_pulls_result_ids.lock().await.len();
+    {
+        assert!(
+            diagnostic_pulls_result_ids > 1,
+            "Should have sent result ids when pulling diagnostics"
+        );
+        assert!(
+            workspace_pulls_result_ids > 1,
+            "Should have sent result ids when pulling workspace diagnostics"
+        );
+    }
+
+    fake_language_server
+        .request::<lsp::request::WorkspaceDiagnosticRefresh>(())
+        .await
+        .into_response()
+        .expect("workspace diagnostics refresh request failed");
+    assert_eq!(
+        8,
+        diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "No single file pulls should happen after the diagnostics refresh server request"
+    );
+    workspace_diagnostics_pulls_handle.next().await.unwrap();
+    assert_eq!(
+        5,
+        workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
+        "Another workspace diagnostics pull should happen after the diagnostics refresh server request"
+    );
+    {
+        assert!(
+            diagnostics_pulls_result_ids.lock().await.len() == diagnostic_pulls_result_ids,
+            "Pulls should not happen hence no extra ids should appear"
+        );
+        assert!(
+            workspace_diagnostics_pulls_result_ids.lock().await.len() > workspace_pulls_result_ids,
+            "More workspace diagnostics should be pulled"
+        );
+    }
+    editor_b_lib.update(cx_b, |editor, cx| {
+        let snapshot = editor.buffer().read(cx).snapshot(cx);
+        let all_diagnostics = snapshot
+            .diagnostics_in_range(0..snapshot.len())
+            .collect::<Vec<_>>();
+        let expected_messages = [
+            expected_workspace_pull_diagnostics_lib_message,
+            expected_pull_diagnostic_lib_message,
+            expected_push_diagnostic_lib_message,
+        ];
+        assert_eq!(all_diagnostics.len(), 1);
+        for diagnostic in &all_diagnostics {
+            assert!(
+                expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
+                "Unexpected diagnostics: {all_diagnostics:?}"
+            );
+        }
+    });
+    editor_b_main.update(cx_b, |editor, cx| {
+        let snapshot = editor.buffer().read(cx).snapshot(cx);
+        let all_diagnostics = snapshot
+            .diagnostics_in_range(0..snapshot.len())
+            .collect::<Vec<_>>();
+        assert_eq!(all_diagnostics.len(), 2);
+
+        let expected_messages = [
+            expected_workspace_pull_diagnostics_main_message,
+            expected_pull_diagnostic_main_message,
+            expected_push_diagnostic_main_message,
+        ];
+        for diagnostic in &all_diagnostics {
+            assert!(
+                expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
+                "Unexpected diagnostics: {all_diagnostics:?}"
+            );
+        }
+    });
+    editor_a_main.update(cx_a, |editor, cx| {
+        let snapshot = editor.buffer().read(cx).snapshot(cx);
+        let all_diagnostics = snapshot
+            .diagnostics_in_range(0..snapshot.len())
+            .collect::<Vec<_>>();
+        assert_eq!(all_diagnostics.len(), 2);
+        let expected_messages = [
+            expected_workspace_pull_diagnostics_main_message,
+            expected_pull_diagnostic_main_message,
+            expected_push_diagnostic_main_message,
+        ];
+        for diagnostic in &all_diagnostics {
+            assert!(
+                expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
+                "Unexpected diagnostics: {all_diagnostics:?}"
+            );
+        }
+    });
+}
+
 #[gpui::test(iterations = 10)]
 async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
     let mut server = TestServer::start(cx_a.executor()).await;

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

@@ -6,7 +6,7 @@ use collab_ui::{
     channel_view::ChannelView,
     notifications::project_shared_notification::ProjectSharedNotification,
 };
-use editor::{Editor, MultiBuffer, PathKey};
+use editor::{Editor, MultiBuffer, PathKey, SelectionEffects};
 use gpui::{
     AppContext as _, BackgroundExecutor, BorrowAppContext, Entity, SharedString, TestAppContext,
     VisualContext, VisualTestContext, point,
@@ -376,7 +376,9 @@ async fn test_basic_following(
 
     // Changes to client A's editor are reflected on client B.
     editor_a1.update_in(cx_a, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| s.select_ranges([1..1, 2..2]));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([1..1, 2..2])
+        });
     });
     executor.advance_clock(workspace::item::LEADER_UPDATE_THROTTLE);
     executor.run_until_parked();
@@ -393,7 +395,9 @@ async fn test_basic_following(
     editor_b1.update(cx_b, |editor, cx| assert_eq!(editor.text(cx), "TWO"));
 
     editor_a1.update_in(cx_a, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| s.select_ranges([3..3]));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([3..3])
+        });
         editor.set_scroll_position(point(0., 100.), window, cx);
     });
     executor.advance_clock(workspace::item::LEADER_UPDATE_THROTTLE);
@@ -1010,7 +1014,6 @@ async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut T
     workspace_b.update_in(cx_b, |workspace, window, cx| {
         workspace.active_pane().update(cx, |pane, cx| {
             pane.close_inactive_items(&Default::default(), window, cx)
-                .unwrap()
                 .detach();
         });
     });
@@ -1611,6 +1614,8 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut
         .root(cx_a)
         .unwrap();
 
+    executor.run_until_parked();
+
     workspace_a_project_b.update(cx_a2, |workspace, cx| {
         assert_eq!(workspace.project().read(cx).remote_id(), Some(project_b_id));
         assert!(workspace.is_being_followed(client_b.peer_id().unwrap()));
@@ -1646,7 +1651,9 @@ async fn test_following_stops_on_unshare(cx_a: &mut TestAppContext, cx_b: &mut T
 
     // b should follow a to position 1
     editor_a.update_in(cx_a, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| s.select_ranges([1..1]))
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([1..1])
+        })
     });
     cx_a.executor()
         .advance_clock(workspace::item::LEADER_UPDATE_THROTTLE);
@@ -1666,7 +1673,9 @@ async fn test_following_stops_on_unshare(cx_a: &mut TestAppContext, cx_b: &mut T
 
     // b should not follow a to position 2
     editor_a.update_in(cx_a, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| s.select_ranges([2..2]))
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([2..2])
+        })
     });
     cx_a.executor()
         .advance_clock(workspace::item::LEADER_UPDATE_THROTTLE);
@@ -1967,7 +1976,7 @@ async fn test_following_to_channel_notes_without_a_shared_project(
         assert_eq!(notes.channel(cx).unwrap().name, "channel-1");
         notes.editor.update(cx, |editor, cx| {
             editor.insert("Hello from A.", window, cx);
-            editor.change_selections(None, window, cx, |selections| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
                 selections.select_ranges(vec![3..4]);
             });
         });
@@ -2066,7 +2075,7 @@ async fn share_workspace(
     workspace: &Entity<Workspace>,
     cx: &mut VisualTestContext,
 ) -> anyhow::Result<u64> {
-    let project = workspace.update(cx, |workspace, _| workspace.project().clone());
+    let project = workspace.read_with(cx, |workspace, _| workspace.project().clone());
     cx.read(ActiveCall::global)
         .update(cx, |call, cx| call.share_project(project, cx))
         .await
@@ -2108,7 +2117,7 @@ async fn test_following_after_replacement(cx_a: &mut TestAppContext, cx_b: &mut
         workspace.add_item_to_center(Box::new(editor.clone()) as _, window, cx)
     });
     editor.update_in(cx_a, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_ranges([Point::row_range(4..4)]);
         })
     });

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

@@ -6,7 +6,7 @@ use crate::{
     },
 };
 use anyhow::{Result, anyhow};
-use assistant_context_editor::ContextStore;
+use assistant_context::ContextStore;
 use assistant_slash_command::SlashCommandWorkingSet;
 use buffer_diff::{DiffHunkSecondaryStatus, DiffHunkStatus, assert_hunks};
 use call::{ActiveCall, ParticipantLocation, Room, room};
@@ -20,8 +20,8 @@ use gpui::{
     UpdateGlobal, px, size,
 };
 use language::{
-    Diagnostic, DiagnosticEntry, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher,
-    LineEnding, OffsetRangeExt, Point, Rope,
+    Diagnostic, DiagnosticEntry, DiagnosticSourceKind, FakeLspAdapter, Language, LanguageConfig,
+    LanguageMatcher, LineEnding, OffsetRangeExt, Point, Rope,
     language_settings::{
         AllLanguageSettings, Formatter, FormatterList, PrettierSettings, SelectedFormatter,
     },
@@ -51,14 +51,41 @@ use std::{
     time::Duration,
 };
 use unindent::Unindent as _;
-use util::{path, separator, uri};
+use util::{path, uri};
 use workspace::Pane;
 
 #[ctor::ctor]
 fn init_logger() {
-    if std::env::var("RUST_LOG").is_ok() {
-        env_logger::init();
+    zlog::init_test();
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_database_failure_during_client_reconnection(
+    executor: BackgroundExecutor,
+    cx: &mut TestAppContext,
+) {
+    let mut server = TestServer::start(executor.clone()).await;
+    let client = server.create_client(cx, "user_a").await;
+
+    // Keep disconnecting the client until a database failure prevents it from
+    // reconnecting.
+    server.test_db.set_query_failure_probability(0.3);
+    loop {
+        server.disconnect_client(client.peer_id().unwrap());
+        executor.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
+        if !client.status().borrow().is_connected() {
+            break;
+        }
     }
+
+    // Make the database healthy again and ensure the client can finally connect.
+    server.test_db.set_query_failure_probability(0.);
+    executor.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
+    assert!(
+        matches!(*client.status().borrow(), client::Status::Connected { .. }),
+        "status was {:?}",
+        *client.status().borrow()
+    );
 }
 
 #[gpui::test(iterations = 10)]
@@ -1649,13 +1676,13 @@ async fn test_project_reconnect(
                 .map(|p| p.to_str().unwrap())
                 .collect::<Vec<_>>(),
             vec![
-                separator!("a.txt"),
-                separator!("b.txt"),
-                separator!("subdir2"),
-                separator!("subdir2/f.txt"),
-                separator!("subdir2/g.txt"),
-                separator!("subdir2/h.txt"),
-                separator!("subdir2/i.txt")
+                path!("a.txt"),
+                path!("b.txt"),
+                path!("subdir2"),
+                path!("subdir2/f.txt"),
+                path!("subdir2/g.txt"),
+                path!("subdir2/h.txt"),
+                path!("subdir2/i.txt")
             ]
         );
         assert!(worktree_a3.read(cx).has_update_observer());
@@ -1682,13 +1709,13 @@ async fn test_project_reconnect(
                 .map(|p| p.to_str().unwrap())
                 .collect::<Vec<_>>(),
             vec![
-                separator!("a.txt"),
-                separator!("b.txt"),
-                separator!("subdir2"),
-                separator!("subdir2/f.txt"),
-                separator!("subdir2/g.txt"),
-                separator!("subdir2/h.txt"),
-                separator!("subdir2/i.txt")
+                path!("a.txt"),
+                path!("b.txt"),
+                path!("subdir2"),
+                path!("subdir2/f.txt"),
+                path!("subdir2/g.txt"),
+                path!("subdir2/h.txt"),
+                path!("subdir2/i.txt")
             ]
         );
         assert!(project.worktree_for_id(worktree2_id, cx).is_none());
@@ -1779,13 +1806,13 @@ async fn test_project_reconnect(
                 .map(|p| p.to_str().unwrap())
                 .collect::<Vec<_>>(),
             vec![
-                separator!("a.txt"),
-                separator!("b.txt"),
-                separator!("subdir2"),
-                separator!("subdir2/f.txt"),
-                separator!("subdir2/g.txt"),
-                separator!("subdir2/h.txt"),
-                separator!("subdir2/j.txt")
+                path!("a.txt"),
+                path!("b.txt"),
+                path!("subdir2"),
+                path!("subdir2/f.txt"),
+                path!("subdir2/g.txt"),
+                path!("subdir2/h.txt"),
+                path!("subdir2/j.txt")
             ]
         );
         assert!(project.worktree_for_id(worktree2_id, cx).is_none());
@@ -1849,7 +1876,6 @@ async fn test_active_call_events(
                 github_login: "user_a".to_string(),
                 avatar_uri: "avatar_a".into(),
                 name: None,
-                email: None,
             }),
             project_id: project_a_id,
             worktree_root_names: vec!["a".to_string()],
@@ -1869,7 +1895,6 @@ async fn test_active_call_events(
                 github_login: "user_b".to_string(),
                 avatar_uri: "avatar_b".into(),
                 name: None,
-                email: None,
             }),
             project_id: project_b_id,
             worktree_root_names: vec!["b".to_string()]
@@ -2597,6 +2622,7 @@ async fn test_git_diff_base_change(
     client_a.fs().set_head_for_repo(
         Path::new("/dir/.git"),
         &[("a.txt".into(), committed_text.clone())],
+        "deadbeef",
     );
 
     // Create the buffer
@@ -2690,6 +2716,7 @@ async fn test_git_diff_base_change(
     client_a.fs().set_head_for_repo(
         Path::new("/dir/.git"),
         &[("a.txt".into(), new_committed_text.clone())],
+        "deadbeef",
     );
 
     // Wait for buffer_local_a to receive it
@@ -2979,6 +3006,7 @@ async fn test_git_status_sync(
     client_a.fs().set_head_for_repo(
         path!("/dir/.git").as_ref(),
         &[("b.txt".into(), "B".into()), ("c.txt".into(), "c".into())],
+        "deadbeef",
     );
     client_a.fs().set_index_for_repo(
         path!("/dir/.git").as_ref(),
@@ -3287,13 +3315,13 @@ async fn test_fs_operations(
                 .map(|p| p.to_string_lossy())
                 .collect::<Vec<_>>(),
             [
-                separator!("DIR"),
-                separator!("DIR/SUBDIR"),
-                separator!("DIR/SUBDIR/f.txt"),
-                separator!("DIR/e.txt"),
-                separator!("a.txt"),
-                separator!("b.txt"),
-                separator!("d.txt")
+                path!("DIR"),
+                path!("DIR/SUBDIR"),
+                path!("DIR/SUBDIR/f.txt"),
+                path!("DIR/e.txt"),
+                path!("a.txt"),
+                path!("b.txt"),
+                path!("d.txt")
             ]
         );
     });
@@ -3305,13 +3333,13 @@ async fn test_fs_operations(
                 .map(|p| p.to_string_lossy())
                 .collect::<Vec<_>>(),
             [
-                separator!("DIR"),
-                separator!("DIR/SUBDIR"),
-                separator!("DIR/SUBDIR/f.txt"),
-                separator!("DIR/e.txt"),
-                separator!("a.txt"),
-                separator!("b.txt"),
-                separator!("d.txt")
+                path!("DIR"),
+                path!("DIR/SUBDIR"),
+                path!("DIR/SUBDIR/f.txt"),
+                path!("DIR/e.txt"),
+                path!("a.txt"),
+                path!("b.txt"),
+                path!("d.txt")
             ]
         );
     });
@@ -3331,14 +3359,14 @@ async fn test_fs_operations(
                 .map(|p| p.to_string_lossy())
                 .collect::<Vec<_>>(),
             [
-                separator!("DIR"),
-                separator!("DIR/SUBDIR"),
-                separator!("DIR/SUBDIR/f.txt"),
-                separator!("DIR/e.txt"),
-                separator!("a.txt"),
-                separator!("b.txt"),
-                separator!("d.txt"),
-                separator!("f.txt")
+                path!("DIR"),
+                path!("DIR/SUBDIR"),
+                path!("DIR/SUBDIR/f.txt"),
+                path!("DIR/e.txt"),
+                path!("a.txt"),
+                path!("b.txt"),
+                path!("d.txt"),
+                path!("f.txt")
             ]
         );
     });
@@ -3350,14 +3378,14 @@ async fn test_fs_operations(
                 .map(|p| p.to_string_lossy())
                 .collect::<Vec<_>>(),
             [
-                separator!("DIR"),
-                separator!("DIR/SUBDIR"),
-                separator!("DIR/SUBDIR/f.txt"),
-                separator!("DIR/e.txt"),
-                separator!("a.txt"),
-                separator!("b.txt"),
-                separator!("d.txt"),
-                separator!("f.txt")
+                path!("DIR"),
+                path!("DIR/SUBDIR"),
+                path!("DIR/SUBDIR/f.txt"),
+                path!("DIR/e.txt"),
+                path!("a.txt"),
+                path!("b.txt"),
+                path!("d.txt"),
+                path!("f.txt")
             ]
         );
     });
@@ -4207,7 +4235,8 @@ async fn test_collaborating_with_diagnostics(
                         message: "message 1".to_string(),
                         severity: lsp::DiagnosticSeverity::ERROR,
                         is_primary: true,
-                        ..Default::default()
+                        source_kind: DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
                     }
                 },
                 DiagnosticEntry {
@@ -4217,7 +4246,8 @@ async fn test_collaborating_with_diagnostics(
                         severity: lsp::DiagnosticSeverity::WARNING,
                         message: "message 2".to_string(),
                         is_primary: true,
-                        ..Default::default()
+                        source_kind: DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
                     }
                 }
             ]
@@ -4229,7 +4259,7 @@ async fn test_collaborating_with_diagnostics(
         &lsp::PublishDiagnosticsParams {
             uri: lsp::Url::from_file_path(path!("/a/a.rs")).unwrap(),
             version: None,
-            diagnostics: vec![],
+            diagnostics: Vec::new(),
         },
     );
     executor.run_until_parked();
@@ -6416,7 +6446,7 @@ async fn test_join_after_restart(cx1: &mut TestAppContext, cx2: &mut TestAppCont
 async fn test_preview_tabs(cx: &mut TestAppContext) {
     let (_server, client) = TestServer::start1(cx).await;
     let (workspace, cx) = client.build_test_workspace(cx).await;
-    let project = workspace.update(cx, |workspace, _| workspace.project().clone());
+    let project = workspace.read_with(cx, |workspace, _| workspace.project().clone());
 
     let worktree_id = project.update(cx, |project, cx| {
         project.worktrees(cx).next().unwrap().read(cx).id()
@@ -6435,7 +6465,7 @@ async fn test_preview_tabs(cx: &mut TestAppContext) {
         path: Path::new("3.rs").into(),
     };
 
-    let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
+    let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
 
     let get_path = |pane: &Pane, idx: usize, cx: &App| {
         pane.item_for_index(idx).unwrap().project_path(cx).unwrap()
@@ -6588,7 +6618,7 @@ async fn test_preview_tabs(cx: &mut TestAppContext) {
         pane.split(workspace::SplitDirection::Right, cx);
     });
 
-    let right_pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
+    let right_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
 
     pane.update(cx, |pane, cx| {
         assert_eq!(pane.items_len(), 1);

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

@@ -1,6 +1,6 @@
 use super::{RandomizedTest, TestClient, TestError, TestServer, UserTestPlan};
 use crate::{db::UserId, tests::run_randomized_test};
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use async_trait::async_trait;
 use call::ActiveCall;
 use collections::{BTreeMap, HashMap};
@@ -782,8 +782,7 @@ impl RandomizedTest for ProjectCollaborationTest {
                 let save =
                     project.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
                 let save = cx.spawn(|cx| async move {
-                    save.await
-                        .map_err(|err| anyhow!("save request failed: {:?}", err))?;
+                    save.await.context("save request failed")?;
                     assert!(
                         buffer
                             .read_with(&cx, |buffer, _| { buffer.saved_version().to_owned() })

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

@@ -30,7 +30,7 @@ use rpc::proto;
 use serde_json::json;
 use settings::SettingsStore;
 use std::{path::Path, sync::Arc};
-use util::{path, separator};
+use util::path;
 
 #[gpui::test(iterations = 10)]
 async fn test_sharing_an_ssh_remote_project(
@@ -198,7 +198,7 @@ async fn test_sharing_an_ssh_remote_project(
                 .path()
                 .to_string_lossy()
                 .to_string(),
-            separator!("src/renamed.rs").to_string()
+            path!("src/renamed.rs").to_string()
         );
     });
 }
@@ -589,12 +589,12 @@ async fn test_remote_server_debugger(
     cx_a.update(|cx| {
         release_channel::init(SemanticVersion::default(), cx);
         command_palette_hooks::init(cx);
-        if std::env::var("RUST_LOG").is_ok() {
-            env_logger::try_init().ok();
-        }
+        zlog::init_test();
+        dap_adapters::init(cx);
     });
     server_cx.update(|cx| {
         release_channel::init(SemanticVersion::default(), cx);
+        dap_adapters::init(cx);
     });
     let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
     let remote_fs = FakeFs::new(server_cx.executor());
@@ -671,7 +671,7 @@ async fn test_remote_server_debugger(
     });
 
     session.update(cx_a, |session, _| {
-        assert_eq!(session.binary().command, "ssh");
+        assert_eq!(session.binary().unwrap().command.as_deref(), Some("ssh"));
     });
 
     let shutdown_session = workspace.update(cx_a, |workspace, cx| {

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

@@ -0,0 +1,603 @@
+use std::sync::Arc;
+
+use chrono::{Duration, Utc};
+use pretty_assertions::assert_eq;
+
+use crate::llm::AGENT_EXTENDED_TRIAL_FEATURE_FLAG;
+use crate::stripe_billing::StripeBilling;
+use crate::stripe_client::{
+    FakeStripeClient, StripeBillingAddressCollection, StripeCheckoutSessionMode,
+    StripeCheckoutSessionPaymentMethodCollection, StripeCreateCheckoutSessionLineItems,
+    StripeCreateCheckoutSessionSubscriptionData, StripeCustomerId, StripeCustomerUpdate,
+    StripeCustomerUpdateAddress, StripeCustomerUpdateName, StripeMeter, StripeMeterId, StripePrice,
+    StripePriceId, StripePriceRecurring, StripeSubscription, StripeSubscriptionId,
+    StripeSubscriptionItem, StripeSubscriptionItemId, StripeSubscriptionTrialSettings,
+    StripeSubscriptionTrialSettingsEndBehavior,
+    StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, UpdateSubscriptionItems,
+};
+
+fn make_stripe_billing() -> (StripeBilling, Arc<FakeStripeClient>) {
+    let stripe_client = Arc::new(FakeStripeClient::new());
+    let stripe_billing = StripeBilling::test(stripe_client.clone());
+
+    (stripe_billing, stripe_client)
+}
+
+#[gpui::test]
+async fn test_initialize() {
+    let (stripe_billing, stripe_client) = make_stripe_billing();
+
+    // Add test meters
+    let meter1 = StripeMeter {
+        id: StripeMeterId("meter_1".into()),
+        event_name: "event_1".to_string(),
+    };
+    let meter2 = StripeMeter {
+        id: StripeMeterId("meter_2".into()),
+        event_name: "event_2".to_string(),
+    };
+    stripe_client
+        .meters
+        .lock()
+        .insert(meter1.id.clone(), meter1);
+    stripe_client
+        .meters
+        .lock()
+        .insert(meter2.id.clone(), meter2);
+
+    // Add test prices
+    let price1 = StripePrice {
+        id: StripePriceId("price_1".into()),
+        unit_amount: Some(1_000),
+        lookup_key: Some("zed-pro".to_string()),
+        recurring: None,
+    };
+    let price2 = StripePrice {
+        id: StripePriceId("price_2".into()),
+        unit_amount: Some(0),
+        lookup_key: Some("zed-free".to_string()),
+        recurring: None,
+    };
+    let price3 = StripePrice {
+        id: StripePriceId("price_3".into()),
+        unit_amount: Some(500),
+        lookup_key: None,
+        recurring: Some(StripePriceRecurring {
+            meter: Some("meter_1".to_string()),
+        }),
+    };
+    stripe_client
+        .prices
+        .lock()
+        .insert(price1.id.clone(), price1);
+    stripe_client
+        .prices
+        .lock()
+        .insert(price2.id.clone(), price2);
+    stripe_client
+        .prices
+        .lock()
+        .insert(price3.id.clone(), price3);
+
+    // Initialize the billing system
+    stripe_billing.initialize().await.unwrap();
+
+    // Verify that prices can be found by lookup key
+    let zed_pro_price_id = stripe_billing.zed_pro_price_id().await.unwrap();
+    assert_eq!(zed_pro_price_id.to_string(), "price_1");
+
+    let zed_free_price_id = stripe_billing.zed_free_price_id().await.unwrap();
+    assert_eq!(zed_free_price_id.to_string(), "price_2");
+
+    // Verify that a price can be found by lookup key
+    let zed_pro_price = stripe_billing
+        .find_price_by_lookup_key("zed-pro")
+        .await
+        .unwrap();
+    assert_eq!(zed_pro_price.id.to_string(), "price_1");
+    assert_eq!(zed_pro_price.unit_amount, Some(1_000));
+
+    // Verify that finding a non-existent lookup key returns an error
+    let result = stripe_billing
+        .find_price_by_lookup_key("non-existent")
+        .await;
+    assert!(result.is_err());
+}
+
+#[gpui::test]
+async fn test_find_or_create_customer_by_email() {
+    let (stripe_billing, stripe_client) = make_stripe_billing();
+
+    // Create a customer with an email that doesn't yet correspond to a customer.
+    {
+        let email = "user@example.com";
+
+        let customer_id = stripe_billing
+            .find_or_create_customer_by_email(Some(email))
+            .await
+            .unwrap();
+
+        let customer = stripe_client
+            .customers
+            .lock()
+            .get(&customer_id)
+            .unwrap()
+            .clone();
+        assert_eq!(customer.email.as_deref(), Some(email));
+    }
+
+    // Create a customer with an email that corresponds to an existing customer.
+    {
+        let email = "user2@example.com";
+
+        let existing_customer_id = stripe_billing
+            .find_or_create_customer_by_email(Some(email))
+            .await
+            .unwrap();
+
+        let customer_id = stripe_billing
+            .find_or_create_customer_by_email(Some(email))
+            .await
+            .unwrap();
+        assert_eq!(customer_id, existing_customer_id);
+
+        let customer = stripe_client
+            .customers
+            .lock()
+            .get(&customer_id)
+            .unwrap()
+            .clone();
+        assert_eq!(customer.email.as_deref(), Some(email));
+    }
+}
+
+#[gpui::test]
+async fn test_subscribe_to_price() {
+    let (stripe_billing, stripe_client) = make_stripe_billing();
+
+    let price = StripePrice {
+        id: StripePriceId("price_test".into()),
+        unit_amount: Some(2000),
+        lookup_key: Some("test-price".to_string()),
+        recurring: None,
+    };
+    stripe_client
+        .prices
+        .lock()
+        .insert(price.id.clone(), price.clone());
+
+    let now = Utc::now();
+    let subscription = StripeSubscription {
+        id: StripeSubscriptionId("sub_test".into()),
+        customer: StripeCustomerId("cus_test".into()),
+        status: stripe::SubscriptionStatus::Active,
+        current_period_start: now.timestamp(),
+        current_period_end: (now + Duration::days(30)).timestamp(),
+        items: vec![],
+        cancel_at: None,
+        cancellation_details: None,
+    };
+    stripe_client
+        .subscriptions
+        .lock()
+        .insert(subscription.id.clone(), subscription.clone());
+
+    stripe_billing
+        .subscribe_to_price(&subscription.id, &price)
+        .await
+        .unwrap();
+
+    let update_subscription_calls = stripe_client
+        .update_subscription_calls
+        .lock()
+        .iter()
+        .map(|(id, params)| (id.clone(), params.clone()))
+        .collect::<Vec<_>>();
+    assert_eq!(update_subscription_calls.len(), 1);
+    assert_eq!(update_subscription_calls[0].0, subscription.id);
+    assert_eq!(
+        update_subscription_calls[0].1.items,
+        Some(vec![UpdateSubscriptionItems {
+            price: Some(price.id.clone())
+        }])
+    );
+
+    // Subscribing to a price that is already on the subscription is a no-op.
+    {
+        let now = Utc::now();
+        let subscription = StripeSubscription {
+            id: StripeSubscriptionId("sub_test".into()),
+            customer: StripeCustomerId("cus_test".into()),
+            status: stripe::SubscriptionStatus::Active,
+            current_period_start: now.timestamp(),
+            current_period_end: (now + Duration::days(30)).timestamp(),
+            items: vec![StripeSubscriptionItem {
+                id: StripeSubscriptionItemId("si_test".into()),
+                price: Some(price.clone()),
+            }],
+            cancel_at: None,
+            cancellation_details: None,
+        };
+        stripe_client
+            .subscriptions
+            .lock()
+            .insert(subscription.id.clone(), subscription.clone());
+
+        stripe_billing
+            .subscribe_to_price(&subscription.id, &price)
+            .await
+            .unwrap();
+
+        assert_eq!(stripe_client.update_subscription_calls.lock().len(), 1);
+    }
+}
+
+#[gpui::test]
+async fn test_subscribe_to_zed_free() {
+    let (stripe_billing, stripe_client) = make_stripe_billing();
+
+    let zed_pro_price = StripePrice {
+        id: StripePriceId("price_1".into()),
+        unit_amount: Some(0),
+        lookup_key: Some("zed-pro".to_string()),
+        recurring: None,
+    };
+    stripe_client
+        .prices
+        .lock()
+        .insert(zed_pro_price.id.clone(), zed_pro_price.clone());
+    let zed_free_price = StripePrice {
+        id: StripePriceId("price_2".into()),
+        unit_amount: Some(0),
+        lookup_key: Some("zed-free".to_string()),
+        recurring: None,
+    };
+    stripe_client
+        .prices
+        .lock()
+        .insert(zed_free_price.id.clone(), zed_free_price.clone());
+
+    stripe_billing.initialize().await.unwrap();
+
+    // Customer is subscribed to Zed Free when not already subscribed to a plan.
+    {
+        let customer_id = StripeCustomerId("cus_no_plan".into());
+
+        let subscription = stripe_billing
+            .subscribe_to_zed_free(customer_id)
+            .await
+            .unwrap();
+
+        assert_eq!(subscription.items[0].price.as_ref(), Some(&zed_free_price));
+    }
+
+    // Customer is not subscribed to Zed Free when they already have an active subscription.
+    {
+        let customer_id = StripeCustomerId("cus_active_subscription".into());
+
+        let now = Utc::now();
+        let existing_subscription = StripeSubscription {
+            id: StripeSubscriptionId("sub_existing_active".into()),
+            customer: customer_id.clone(),
+            status: stripe::SubscriptionStatus::Active,
+            current_period_start: now.timestamp(),
+            current_period_end: (now + Duration::days(30)).timestamp(),
+            items: vec![StripeSubscriptionItem {
+                id: StripeSubscriptionItemId("si_test".into()),
+                price: Some(zed_pro_price.clone()),
+            }],
+            cancel_at: None,
+            cancellation_details: None,
+        };
+        stripe_client.subscriptions.lock().insert(
+            existing_subscription.id.clone(),
+            existing_subscription.clone(),
+        );
+
+        let subscription = stripe_billing
+            .subscribe_to_zed_free(customer_id)
+            .await
+            .unwrap();
+
+        assert_eq!(subscription, existing_subscription);
+    }
+
+    // Customer is not subscribed to Zed Free when they already have a trial subscription.
+    {
+        let customer_id = StripeCustomerId("cus_trial_subscription".into());
+
+        let now = Utc::now();
+        let existing_subscription = StripeSubscription {
+            id: StripeSubscriptionId("sub_existing_trial".into()),
+            customer: customer_id.clone(),
+            status: stripe::SubscriptionStatus::Trialing,
+            current_period_start: now.timestamp(),
+            current_period_end: (now + Duration::days(14)).timestamp(),
+            items: vec![StripeSubscriptionItem {
+                id: StripeSubscriptionItemId("si_test".into()),
+                price: Some(zed_pro_price.clone()),
+            }],
+            cancel_at: None,
+            cancellation_details: None,
+        };
+        stripe_client.subscriptions.lock().insert(
+            existing_subscription.id.clone(),
+            existing_subscription.clone(),
+        );
+
+        let subscription = stripe_billing
+            .subscribe_to_zed_free(customer_id)
+            .await
+            .unwrap();
+
+        assert_eq!(subscription, existing_subscription);
+    }
+}
+
+#[gpui::test]
+async fn test_bill_model_request_usage() {
+    let (stripe_billing, stripe_client) = make_stripe_billing();
+
+    let customer_id = StripeCustomerId("cus_test".into());
+
+    stripe_billing
+        .bill_model_request_usage(&customer_id, "some_model/requests", 73)
+        .await
+        .unwrap();
+
+    let create_meter_event_calls = stripe_client
+        .create_meter_event_calls
+        .lock()
+        .iter()
+        .cloned()
+        .collect::<Vec<_>>();
+    assert_eq!(create_meter_event_calls.len(), 1);
+    assert!(
+        create_meter_event_calls[0]
+            .identifier
+            .starts_with("model_requests/")
+    );
+    assert_eq!(create_meter_event_calls[0].stripe_customer_id, customer_id);
+    assert_eq!(
+        create_meter_event_calls[0].event_name.as_ref(),
+        "some_model/requests"
+    );
+    assert_eq!(create_meter_event_calls[0].value, 73);
+}
+
+#[gpui::test]
+async fn test_checkout_with_zed_pro() {
+    let (stripe_billing, stripe_client) = make_stripe_billing();
+
+    let customer_id = StripeCustomerId("cus_test".into());
+    let github_login = "zeduser1";
+    let success_url = "https://example.com/success";
+
+    // It returns an error when the Zed Pro price doesn't exist.
+    {
+        let result = stripe_billing
+            .checkout_with_zed_pro(&customer_id, github_login, success_url)
+            .await;
+
+        assert!(result.is_err());
+        assert_eq!(
+            result.err().unwrap().to_string(),
+            r#"no price ID found for "zed-pro""#
+        );
+    }
+
+    // Successful checkout.
+    {
+        let price = StripePrice {
+            id: StripePriceId("price_1".into()),
+            unit_amount: Some(2000),
+            lookup_key: Some("zed-pro".to_string()),
+            recurring: None,
+        };
+        stripe_client
+            .prices
+            .lock()
+            .insert(price.id.clone(), price.clone());
+
+        stripe_billing.initialize().await.unwrap();
+
+        let checkout_url = stripe_billing
+            .checkout_with_zed_pro(&customer_id, github_login, success_url)
+            .await
+            .unwrap();
+
+        assert!(checkout_url.starts_with("https://checkout.stripe.com/c/pay"));
+
+        let create_checkout_session_calls = stripe_client
+            .create_checkout_session_calls
+            .lock()
+            .drain(..)
+            .collect::<Vec<_>>();
+        assert_eq!(create_checkout_session_calls.len(), 1);
+        let call = create_checkout_session_calls.into_iter().next().unwrap();
+        assert_eq!(call.customer, Some(customer_id));
+        assert_eq!(call.client_reference_id.as_deref(), Some(github_login));
+        assert_eq!(call.mode, Some(StripeCheckoutSessionMode::Subscription));
+        assert_eq!(
+            call.line_items,
+            Some(vec![StripeCreateCheckoutSessionLineItems {
+                price: Some(price.id.to_string()),
+                quantity: Some(1)
+            }])
+        );
+        assert_eq!(call.payment_method_collection, None);
+        assert_eq!(call.subscription_data, None);
+        assert_eq!(call.success_url.as_deref(), Some(success_url));
+        assert_eq!(
+            call.billing_address_collection,
+            Some(StripeBillingAddressCollection::Required)
+        );
+        assert_eq!(
+            call.customer_update,
+            Some(StripeCustomerUpdate {
+                address: Some(StripeCustomerUpdateAddress::Auto),
+                name: Some(StripeCustomerUpdateName::Auto),
+                shipping: None,
+            })
+        );
+    }
+}
+
+#[gpui::test]
+async fn test_checkout_with_zed_pro_trial() {
+    let (stripe_billing, stripe_client) = make_stripe_billing();
+
+    let customer_id = StripeCustomerId("cus_test".into());
+    let github_login = "zeduser1";
+    let success_url = "https://example.com/success";
+
+    // It returns an error when the Zed Pro price doesn't exist.
+    {
+        let result = stripe_billing
+            .checkout_with_zed_pro_trial(&customer_id, github_login, Vec::new(), success_url)
+            .await;
+
+        assert!(result.is_err());
+        assert_eq!(
+            result.err().unwrap().to_string(),
+            r#"no price ID found for "zed-pro""#
+        );
+    }
+
+    let price = StripePrice {
+        id: StripePriceId("price_1".into()),
+        unit_amount: Some(2000),
+        lookup_key: Some("zed-pro".to_string()),
+        recurring: None,
+    };
+    stripe_client
+        .prices
+        .lock()
+        .insert(price.id.clone(), price.clone());
+
+    stripe_billing.initialize().await.unwrap();
+
+    // Successful checkout.
+    {
+        let checkout_url = stripe_billing
+            .checkout_with_zed_pro_trial(&customer_id, github_login, Vec::new(), success_url)
+            .await
+            .unwrap();
+
+        assert!(checkout_url.starts_with("https://checkout.stripe.com/c/pay"));
+
+        let create_checkout_session_calls = stripe_client
+            .create_checkout_session_calls
+            .lock()
+            .drain(..)
+            .collect::<Vec<_>>();
+        assert_eq!(create_checkout_session_calls.len(), 1);
+        let call = create_checkout_session_calls.into_iter().next().unwrap();
+        assert_eq!(call.customer.as_ref(), Some(&customer_id));
+        assert_eq!(call.client_reference_id.as_deref(), Some(github_login));
+        assert_eq!(call.mode, Some(StripeCheckoutSessionMode::Subscription));
+        assert_eq!(
+            call.line_items,
+            Some(vec![StripeCreateCheckoutSessionLineItems {
+                price: Some(price.id.to_string()),
+                quantity: Some(1)
+            }])
+        );
+        assert_eq!(
+            call.payment_method_collection,
+            Some(StripeCheckoutSessionPaymentMethodCollection::IfRequired)
+        );
+        assert_eq!(
+            call.subscription_data,
+            Some(StripeCreateCheckoutSessionSubscriptionData {
+                trial_period_days: Some(14),
+                trial_settings: Some(StripeSubscriptionTrialSettings {
+                    end_behavior: StripeSubscriptionTrialSettingsEndBehavior {
+                        missing_payment_method:
+                            StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel,
+                    },
+                }),
+                metadata: None,
+            })
+        );
+        assert_eq!(call.success_url.as_deref(), Some(success_url));
+        assert_eq!(
+            call.billing_address_collection,
+            Some(StripeBillingAddressCollection::Required)
+        );
+        assert_eq!(
+            call.customer_update,
+            Some(StripeCustomerUpdate {
+                address: Some(StripeCustomerUpdateAddress::Auto),
+                name: Some(StripeCustomerUpdateName::Auto),
+                shipping: None,
+            })
+        );
+    }
+
+    // Successful checkout with extended trial.
+    {
+        let checkout_url = stripe_billing
+            .checkout_with_zed_pro_trial(
+                &customer_id,
+                github_login,
+                vec![AGENT_EXTENDED_TRIAL_FEATURE_FLAG.to_string()],
+                success_url,
+            )
+            .await
+            .unwrap();
+
+        assert!(checkout_url.starts_with("https://checkout.stripe.com/c/pay"));
+
+        let create_checkout_session_calls = stripe_client
+            .create_checkout_session_calls
+            .lock()
+            .drain(..)
+            .collect::<Vec<_>>();
+        assert_eq!(create_checkout_session_calls.len(), 1);
+        let call = create_checkout_session_calls.into_iter().next().unwrap();
+        assert_eq!(call.customer, Some(customer_id));
+        assert_eq!(call.client_reference_id.as_deref(), Some(github_login));
+        assert_eq!(call.mode, Some(StripeCheckoutSessionMode::Subscription));
+        assert_eq!(
+            call.line_items,
+            Some(vec![StripeCreateCheckoutSessionLineItems {
+                price: Some(price.id.to_string()),
+                quantity: Some(1)
+            }])
+        );
+        assert_eq!(
+            call.payment_method_collection,
+            Some(StripeCheckoutSessionPaymentMethodCollection::IfRequired)
+        );
+        assert_eq!(
+            call.subscription_data,
+            Some(StripeCreateCheckoutSessionSubscriptionData {
+                trial_period_days: Some(60),
+                trial_settings: Some(StripeSubscriptionTrialSettings {
+                    end_behavior: StripeSubscriptionTrialSettingsEndBehavior {
+                        missing_payment_method:
+                            StripeSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel,
+                    },
+                }),
+                metadata: Some(std::collections::HashMap::from_iter([(
+                    "promo_feature_flag".into(),
+                    AGENT_EXTENDED_TRIAL_FEATURE_FLAG.into()
+                )])),
+            })
+        );
+        assert_eq!(call.success_url.as_deref(), Some(success_url));
+        assert_eq!(
+            call.billing_address_collection,
+            Some(StripeBillingAddressCollection::Required)
+        );
+        assert_eq!(
+            call.customer_update,
+            Some(StripeCustomerUpdate {
+                address: Some(StripeCustomerUpdateAddress::Auto),
+                name: Some(StripeCustomerUpdateName::Auto),
+                shipping: None,
+            })
+        );
+    }
+}

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

@@ -1,3 +1,4 @@
+use crate::stripe_client::FakeStripeClient;
 use crate::{
     AppState, Config,
     db::{NewUserParams, UserId, tests::TestDb},
@@ -52,11 +53,11 @@ use livekit_client::test::TestServer as LivekitTestServer;
 pub struct TestServer {
     pub app_state: Arc<AppState>,
     pub test_livekit_server: Arc<LivekitTestServer>,
+    pub test_db: TestDb,
     server: Arc<Server>,
     next_github_user_id: i32,
     connection_killers: Arc<Mutex<HashMap<PeerId, Arc<AtomicBool>>>>,
     forbid_connections: Arc<AtomicBool>,
-    _test_db: TestDb,
 }
 
 pub struct TestClient {
@@ -117,7 +118,7 @@ impl TestServer {
             connection_killers: Default::default(),
             forbid_connections: Default::default(),
             next_github_user_id: 0,
-            _test_db: test_db,
+            test_db,
             test_livekit_server: livekit_server,
         }
     }
@@ -241,7 +242,12 @@ impl TestServer {
                         let user = db
                             .get_user_by_id(user_id)
                             .await
-                            .expect("retrieving user failed")
+                            .map_err(|e| {
+                                EstablishConnectionError::Other(anyhow!(
+                                    "retrieving user failed: {}",
+                                    e
+                                ))
+                            })?
                             .unwrap();
                         cx.background_spawn(server.handle_connection(
                             server_conn,
@@ -252,6 +258,7 @@ impl TestServer {
                             None,
                             Some(connection_id_tx),
                             Executor::Deterministic(cx.background_executor().clone()),
+                            None,
                         ))
                         .detach();
                         let connection_id = connection_id_rx.await.map_err(|e| {
@@ -306,8 +313,8 @@ impl TestServer {
                 settings::KeymapFile::load_asset_allow_partial_failure(os_keymap, cx).unwrap(),
             );
             language_model::LanguageModelRegistry::test(cx);
-            assistant_context_editor::init(client.clone(), cx);
-            assistant_settings::init(cx);
+            assistant_context::init(client.clone(), cx);
+            agent_settings::init(cx);
         });
 
         client
@@ -517,7 +524,8 @@ impl TestServer {
             llm_db: None,
             livekit_client: Some(Arc::new(livekit_test_server.create_api_client())),
             blob_store_client: None,
-            stripe_client: None,
+            real_stripe_client: None,
+            stripe_client: Some(Arc::new(FakeStripeClient::new())),
             stripe_billing: None,
             executor,
             kinesis_client: None,

crates/collab/src/user_backfiller.rs 🔗

@@ -1,6 +1,6 @@
 use std::sync::Arc;
 
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use chrono::{DateTime, Utc};
 use util::ResultExt;
 
@@ -82,7 +82,7 @@ impl UserBackfiller {
             {
                 Ok(github_user) => {
                     self.db
-                        .get_or_create_user_by_github_account(
+                        .update_or_create_user_by_github_account(
                             &user.github_login,
                             github_user.id,
                             user.email_address.as_deref(),
@@ -144,12 +144,9 @@ impl UserBackfiller {
             }
         }
 
-        let response = match response.error_for_status() {
-            Ok(response) => response,
-            Err(err) => return Err(anyhow!("failed to fetch GitHub user: {err}")),
-        };
-
         response
+            .error_for_status()
+            .context("fetching GitHub user")?
             .json()
             .await
             .with_context(|| format!("failed to deserialize GitHub user from '{url}'"))

crates/collab_ui/src/channel_view.rs 🔗

@@ -7,8 +7,8 @@ use client::{
 };
 use collections::HashMap;
 use editor::{
-    CollaborationHub, DisplayPoint, Editor, EditorEvent, display_map::ToDisplayPoint,
-    scroll::Autoscroll,
+    CollaborationHub, DisplayPoint, Editor, EditorEvent, SelectionEffects,
+    display_map::ToDisplayPoint, scroll::Autoscroll,
 };
 use gpui::{
     AnyView, App, ClipboardItem, Context, Entity, EventEmitter, Focusable, Pixels, Point, Render,
@@ -260,9 +260,16 @@ impl ChannelView {
                 .find(|item| &Channel::slug(&item.text).to_lowercase() == &position)
             {
                 self.editor.update(cx, |editor, cx| {
-                    editor.change_selections(Some(Autoscroll::focused()), window, cx, |s| {
-                        s.replace_cursors_with(|map| vec![item.range.start.to_display_point(map)])
-                    })
+                    editor.change_selections(
+                        SelectionEffects::scroll(Autoscroll::focused()),
+                        window,
+                        cx,
+                        |s| {
+                            s.replace_cursors_with(|map| {
+                                vec![item.range.start.to_display_point(map)]
+                            })
+                        },
+                    )
                 });
                 return;
             }
@@ -354,6 +361,10 @@ impl ChannelView {
                 editor.set_read_only(true);
                 cx.notify();
             }),
+            ChannelBufferEvent::Connected => self.editor.update(cx, |editor, cx| {
+                editor.set_read_only(false);
+                cx.notify();
+            }),
             ChannelBufferEvent::ChannelChanged => {
                 self.editor.update(cx, |_, cx| {
                     cx.emit(editor::EditorEvent::TitleChanged);

crates/collab_ui/src/chat_panel.rs 🔗

@@ -90,7 +90,7 @@ impl ChatPanel {
                 languages.clone(),
                 user_store.clone(),
                 None,
-                cx.new(|cx| Editor::auto_height(4, window, cx)),
+                cx.new(|cx| Editor::auto_height(1, 4, window, cx)),
                 window,
                 cx,
             )
@@ -1059,7 +1059,7 @@ impl Render for ChatPanel {
                                         .child(
                                             Label::new(format!(
                                                 "@{}",
-                                                user_being_replied_to.github_login.clone()
+                                                user_being_replied_to.github_login
                                             ))
                                             .size(LabelSize::Small)
                                             .weight(FontWeight::BOLD),
@@ -1218,7 +1218,6 @@ mod tests {
                 avatar_uri: "avatar_fgh".into(),
                 id: 103,
                 name: None,
-                email: None,
             }),
             nonce: 5,
             mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
@@ -1274,7 +1273,6 @@ mod tests {
                 avatar_uri: "avatar_fgh".into(),
                 id: 103,
                 name: None,
-                email: None,
             }),
             nonce: 5,
             mentions: Vec::new(),
@@ -1323,7 +1321,6 @@ mod tests {
                 avatar_uri: "avatar_fgh".into(),
                 id: 103,
                 name: None,
-                email: None,
             }),
             nonce: 5,
             mentions: Vec::new(),

crates/collab_ui/src/chat_panel/message_editor.rs 🔗

@@ -12,10 +12,9 @@ use language::{
     Anchor, Buffer, BufferSnapshot, CodeLabel, LanguageRegistry, ToOffset,
     language_settings::SoftWrap,
 };
-use project::{Completion, CompletionSource, search::SearchQuery};
+use project::{Completion, CompletionResponse, CompletionSource, search::SearchQuery};
 use settings::Settings;
 use std::{
-    cell::RefCell,
     ops::Range,
     rc::Rc,
     sync::{Arc, LazyLock},
@@ -64,31 +63,22 @@ impl CompletionProvider for MessageEditorCompletionProvider {
         _: editor::CompletionContext,
         _window: &mut Window,
         cx: &mut Context<Editor>,
-    ) -> Task<Result<Option<Vec<Completion>>>> {
+    ) -> Task<Result<Vec<CompletionResponse>>> {
         let Some(handle) = self.0.upgrade() else {
-            return Task::ready(Ok(None));
+            return Task::ready(Ok(Vec::new()));
         };
         handle.update(cx, |message_editor, cx| {
             message_editor.completions(buffer, buffer_position, cx)
         })
     }
 
-    fn resolve_completions(
-        &self,
-        _buffer: Entity<Buffer>,
-        _completion_indices: Vec<usize>,
-        _completions: Rc<RefCell<Box<[Completion]>>>,
-        _cx: &mut Context<Editor>,
-    ) -> Task<anyhow::Result<bool>> {
-        Task::ready(Ok(false))
-    }
-
     fn is_completion_trigger(
         &self,
         _buffer: &Entity<Buffer>,
         _position: language::Anchor,
         text: &str,
         _trigger_in_words: bool,
+        _menu_is_open: bool,
         _cx: &mut Context<Editor>,
     ) -> bool {
         text == "@"
@@ -107,11 +97,12 @@ impl MessageEditor {
         let this = cx.entity().downgrade();
         editor.update(cx, |editor, cx| {
             editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
+            editor.set_offset_content(false, cx);
             editor.set_use_autoclose(false);
             editor.set_show_gutter(false, cx);
             editor.set_show_wrap_guides(false, cx);
             editor.set_show_indent_guides(false, cx);
-            editor.set_completion_provider(Some(Box::new(MessageEditorCompletionProvider(this))));
+            editor.set_completion_provider(Some(Rc::new(MessageEditorCompletionProvider(this))));
             editor.set_auto_replace_emoji_shortcode(
                 MessageEditorSettings::get_global(cx)
                     .auto_replace_emoji_shortcode
@@ -247,22 +238,21 @@ impl MessageEditor {
         buffer: &Entity<Buffer>,
         end_anchor: Anchor,
         cx: &mut Context<Self>,
-    ) -> Task<Result<Option<Vec<Completion>>>> {
+    ) -> Task<Result<Vec<CompletionResponse>>> {
         if let Some((start_anchor, query, candidates)) =
             self.collect_mention_candidates(buffer, end_anchor, cx)
         {
             if !candidates.is_empty() {
                 return cx.spawn(async move |_, cx| {
-                    Ok(Some(
-                        Self::resolve_completions_for_candidates(
-                            &cx,
-                            query.as_str(),
-                            &candidates,
-                            start_anchor..end_anchor,
-                            Self::completion_for_mention,
-                        )
-                        .await,
-                    ))
+                    let completion_response = Self::completions_for_candidates(
+                        &cx,
+                        query.as_str(),
+                        &candidates,
+                        start_anchor..end_anchor,
+                        Self::completion_for_mention,
+                    )
+                    .await;
+                    Ok(vec![completion_response])
                 });
             }
         }
@@ -272,41 +262,45 @@ impl MessageEditor {
         {
             if !candidates.is_empty() {
                 return cx.spawn(async move |_, cx| {
-                    Ok(Some(
-                        Self::resolve_completions_for_candidates(
-                            &cx,
-                            query.as_str(),
-                            candidates,
-                            start_anchor..end_anchor,
-                            Self::completion_for_emoji,
-                        )
-                        .await,
-                    ))
+                    let completion_response = Self::completions_for_candidates(
+                        &cx,
+                        query.as_str(),
+                        candidates,
+                        start_anchor..end_anchor,
+                        Self::completion_for_emoji,
+                    )
+                    .await;
+                    Ok(vec![completion_response])
                 });
             }
         }
 
-        Task::ready(Ok(Some(Vec::new())))
+        Task::ready(Ok(vec![CompletionResponse {
+            completions: Vec::new(),
+            is_incomplete: false,
+        }]))
     }
 
-    async fn resolve_completions_for_candidates(
+    async fn completions_for_candidates(
         cx: &AsyncApp,
         query: &str,
         candidates: &[StringMatchCandidate],
         range: Range<Anchor>,
         completion_fn: impl Fn(&StringMatch) -> (String, CodeLabel),
-    ) -> Vec<Completion> {
+    ) -> CompletionResponse {
+        const LIMIT: usize = 10;
         let matches = fuzzy::match_strings(
             candidates,
             query,
             true,
-            10,
+            true,
+            LIMIT,
             &Default::default(),
             cx.background_executor().clone(),
         )
         .await;
 
-        matches
+        let completions = matches
             .into_iter()
             .map(|mat| {
                 let (new_text, label) = completion_fn(&mat);
@@ -321,7 +315,12 @@ impl MessageEditor {
                     source: CompletionSource::Custom,
                 }
             })
-            .collect()
+            .collect::<Vec<_>>();
+
+        CompletionResponse {
+            is_incomplete: completions.len() >= LIMIT,
+            completions,
+        }
     }
 
     fn completion_for_mention(mat: &StringMatch) -> (String, CodeLabel) {
@@ -351,7 +350,7 @@ impl MessageEditor {
     ) -> Option<(Anchor, String, Vec<StringMatchCandidate>)> {
         let end_offset = end_anchor.to_offset(buffer.read(cx));
 
-        let query = buffer.update(cx, |buffer, _| {
+        let query = buffer.read_with(cx, |buffer, _| {
             let mut query = String::new();
             for ch in buffer.reversed_chars_at(end_offset).take(100) {
                 if ch == '@' {
@@ -409,7 +408,7 @@ impl MessageEditor {
 
         let end_offset = end_anchor.to_offset(buffer.read(cx));
 
-        let query = buffer.update(cx, |buffer, _| {
+        let query = buffer.read_with(cx, |buffer, _| {
             let mut query = String::new();
             for ch in buffer.reversed_chars_at(end_offset).take(100) {
                 if ch == ':' {

crates/collab_ui/src/collab_panel.rs 🔗

@@ -3,6 +3,7 @@ mod contact_finder;
 
 use self::channel_modal::ChannelModal;
 use crate::{CollaborationPanelSettings, channel_view::ChannelView, chat_panel::ChatPanel};
+use anyhow::Context as _;
 use call::ActiveCall;
 use channel::{Channel, ChannelEvent, ChannelStore};
 use client::{ChannelId, Client, Contact, User, UserStore};
@@ -13,9 +14,9 @@ use fuzzy::{StringMatchCandidate, match_strings};
 use gpui::{
     AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, Context, DismissEvent,
     Div, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, InteractiveElement, IntoElement,
-    ListOffset, ListState, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render,
-    SharedString, Styled, Subscription, Task, TextStyle, WeakEntity, Window, actions, anchored,
-    canvas, deferred, div, fill, list, point, prelude::*, px,
+    KeyContext, ListOffset, ListState, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel,
+    Render, SharedString, Styled, Subscription, Task, TextStyle, WeakEntity, Window, actions,
+    anchored, canvas, deferred, div, fill, list, point, prelude::*, px,
 };
 use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious};
 use project::{Fs, Project};
@@ -51,6 +52,8 @@ actions!(
         StartMoveChannel,
         MoveSelected,
         InsertSpace,
+        MoveChannelUp,
+        MoveChannelDown,
     ]
 );
 
@@ -378,16 +381,25 @@ impl CollabPanel {
         workspace: WeakEntity<Workspace>,
         mut cx: AsyncWindowContext,
     ) -> anyhow::Result<Entity<Self>> {
-        let serialized_panel = cx
-            .background_spawn(async move { KEY_VALUE_STORE.read_kvp(COLLABORATION_PANEL_KEY) })
-            .await
-            .map_err(|_| anyhow::anyhow!("Failed to read collaboration panel from key value store"))
-            .log_err()
+        let serialized_panel = match workspace
+            .read_with(&cx, |workspace, _| {
+                CollabPanel::serialization_key(workspace)
+            })
+            .ok()
             .flatten()
-            .map(|panel| serde_json::from_str::<SerializedCollabPanel>(&panel))
-            .transpose()
-            .log_err()
-            .flatten();
+        {
+            Some(serialization_key) => cx
+                .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
+                .await
+                .context("reading collaboration panel from key value store")
+                .log_err()
+                .flatten()
+                .map(|panel| serde_json::from_str::<SerializedCollabPanel>(&panel))
+                .transpose()
+                .log_err()
+                .flatten(),
+            None => None,
+        };
 
         workspace.update_in(&mut cx, |workspace, window, cx| {
             let panel = CollabPanel::new(workspace, window, cx);
@@ -407,14 +419,30 @@ impl CollabPanel {
         })
     }
 
+    fn serialization_key(workspace: &Workspace) -> Option<String> {
+        workspace
+            .database_id()
+            .map(|id| i64::from(id).to_string())
+            .or(workspace.session_id())
+            .map(|id| format!("{}-{:?}", COLLABORATION_PANEL_KEY, id))
+    }
+
     fn serialize(&mut self, cx: &mut Context<Self>) {
+        let Some(serialization_key) = self
+            .workspace
+            .read_with(cx, |workspace, _| CollabPanel::serialization_key(workspace))
+            .ok()
+            .flatten()
+        else {
+            return;
+        };
         let width = self.width;
         let collapsed_channels = self.collapsed_channels.clone();
         self.pending_serialization = cx.background_spawn(
             async move {
                 KEY_VALUE_STORE
                     .write_kvp(
-                        COLLABORATION_PANEL_KEY.into(),
+                        serialization_key,
                         serde_json::to_string(&SerializedCollabPanel {
                             width,
                             collapsed_channels: Some(
@@ -471,6 +499,7 @@ impl CollabPanel {
                         &self.match_candidates,
                         &query,
                         true,
+                        true,
                         usize::MAX,
                         &Default::default(),
                         executor.clone(),
@@ -514,6 +543,7 @@ impl CollabPanel {
                     &self.match_candidates,
                     &query,
                     true,
+                    true,
                     usize::MAX,
                     &Default::default(),
                     executor.clone(),
@@ -565,6 +595,7 @@ impl CollabPanel {
                     &self.match_candidates,
                     &query,
                     true,
+                    true,
                     usize::MAX,
                     &Default::default(),
                     executor.clone(),
@@ -595,6 +626,7 @@ impl CollabPanel {
                 &self.match_candidates,
                 &query,
                 true,
+                true,
                 usize::MAX,
                 &Default::default(),
                 executor.clone(),
@@ -671,6 +703,7 @@ impl CollabPanel {
                 &self.match_candidates,
                 &query,
                 true,
+                true,
                 usize::MAX,
                 &Default::default(),
                 executor.clone(),
@@ -706,6 +739,7 @@ impl CollabPanel {
                 &self.match_candidates,
                 &query,
                 true,
+                true,
                 usize::MAX,
                 &Default::default(),
                 executor.clone(),
@@ -730,6 +764,7 @@ impl CollabPanel {
                 &self.match_candidates,
                 &query,
                 true,
+                true,
                 usize::MAX,
                 &Default::default(),
                 executor.clone(),
@@ -763,6 +798,7 @@ impl CollabPanel {
                 &self.match_candidates,
                 &query,
                 true,
+                true,
                 usize::MAX,
                 &Default::default(),
                 executor.clone(),
@@ -1609,6 +1645,10 @@ impl CollabPanel {
             self.channel_name_editor.update(cx, |editor, cx| {
                 editor.insert(" ", window, cx);
             });
+        } else if self.filter_editor.focus_handle(cx).is_focused(window) {
+            self.filter_editor.update(cx, |editor, cx| {
+                editor.insert(" ", window, cx);
+            });
         }
     }
 
@@ -1935,6 +1975,33 @@ impl CollabPanel {
             })
     }
 
+    fn move_channel_up(&mut self, _: &MoveChannelUp, window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(channel) = self.selected_channel() {
+            self.channel_store.update(cx, |store, cx| {
+                store
+                    .reorder_channel(channel.id, proto::reorder_channel::Direction::Up, cx)
+                    .detach_and_prompt_err("Failed to move channel up", window, cx, |_, _, _| None)
+            });
+        }
+    }
+
+    fn move_channel_down(
+        &mut self,
+        _: &MoveChannelDown,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(channel) = self.selected_channel() {
+            self.channel_store.update(cx, |store, cx| {
+                store
+                    .reorder_channel(channel.id, proto::reorder_channel::Direction::Down, cx)
+                    .detach_and_prompt_err("Failed to move channel down", window, cx, |_, _, _| {
+                        None
+                    })
+            });
+        }
+    }
+
     fn open_channel_notes(
         &mut self,
         channel_id: ChannelId,
@@ -1948,7 +2015,7 @@ impl CollabPanel {
 
     fn show_inline_context_menu(
         &mut self,
-        _: &menu::SecondaryConfirm,
+        _: &Secondary,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -1977,6 +2044,23 @@ impl CollabPanel {
         }
     }
 
+    fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
+        let mut dispatch_context = KeyContext::new_with_defaults();
+        dispatch_context.add("CollabPanel");
+        dispatch_context.add("menu");
+
+        let identifier = if self.channel_name_editor.focus_handle(cx).is_focused(window)
+            || self.filter_editor.focus_handle(cx).is_focused(window)
+        {
+            "editing"
+        } else {
+            "not_editing"
+        };
+
+        dispatch_context.add(identifier);
+        dispatch_context
+    }
+
     fn selected_channel(&self) -> Option<&Arc<Channel>> {
         self.selection
             .and_then(|ix| self.entries.get(ix))
@@ -2939,7 +3023,7 @@ fn render_tree_branch(
 impl Render for CollabPanel {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         v_flex()
-            .key_context("CollabPanel")
+            .key_context(self.dispatch_context(window, cx))
             .on_action(cx.listener(CollabPanel::cancel))
             .on_action(cx.listener(CollabPanel::select_next))
             .on_action(cx.listener(CollabPanel::select_previous))
@@ -2951,7 +3035,9 @@ impl Render for CollabPanel {
             .on_action(cx.listener(CollabPanel::collapse_selected_channel))
             .on_action(cx.listener(CollabPanel::expand_selected_channel))
             .on_action(cx.listener(CollabPanel::start_move_selected_channel))
-            .track_focus(&self.focus_handle(cx))
+            .on_action(cx.listener(CollabPanel::move_channel_up))
+            .on_action(cx.listener(CollabPanel::move_channel_down))
+            .track_focus(&self.focus_handle)
             .size_full()
             .child(if self.user_store.read(cx).current_user().is_none() {
                 self.render_signed_out(cx)
@@ -2999,10 +3085,12 @@ impl Panel for CollabPanel {
             .unwrap_or_else(|| CollaborationPanelSettings::get_global(cx).default_width)
     }
 
-    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
+    fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
         self.width = size;
-        self.serialize(cx);
         cx.notify();
+        cx.defer_in(window, |this, _, cx| {
+            this.serialize(cx);
+        });
     }
 
     fn icon(&self, _window: &Window, cx: &App) -> Option<ui::IconName> {

crates/collab_ui/src/notifications/project_shared_notification.rs 🔗

@@ -109,9 +109,7 @@ impl ProjectSharedNotification {
     }
 
     fn dismiss(&mut self, cx: &mut Context<Self>) {
-        if let Some(active_room) =
-            ActiveCall::global(cx).read_with(cx, |call, _| call.room().cloned())
-        {
+        if let Some(active_room) = ActiveCall::global(cx).read(cx).room().cloned() {
             active_room.update(cx, |_, cx| {
                 cx.emit(room::Event::RemoteProjectInvitationDiscarded {
                     project_id: self.project_id,

crates/collab_ui/src/notifications/stories/collab_notification.rs 🔗

@@ -7,11 +7,11 @@ use crate::notifications::collab_notification::CollabNotification;
 pub struct CollabNotificationStory;
 
 impl Render for CollabNotificationStory {
-    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let window_container = |width, height| div().w(px(width)).h(px(height));
 
-        Story::container()
-            .child(Story::title_for::<CollabNotification>())
+        Story::container(cx)
+            .child(Story::title_for::<CollabNotification>(cx))
             .child(
                 StorySection::new().child(StoryItem::new(
                     "Incoming Call Notification",

crates/collab_ui/src/panel_settings.rs 🔗

@@ -28,6 +28,7 @@ pub struct ChatPanelSettings {
 }
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+#[schemars(deny_unknown_fields)]
 pub struct ChatPanelSettingsContent {
     /// When to show the panel button in the status bar.
     ///
@@ -51,6 +52,7 @@ pub struct NotificationPanelSettings {
 }
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+#[schemars(deny_unknown_fields)]
 pub struct PanelSettingsContent {
     /// Whether to show the panel button in the status bar.
     ///
@@ -67,6 +69,7 @@ pub struct PanelSettingsContent {
 }
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+#[schemars(deny_unknown_fields)]
 pub struct MessageEditorSettings {
     /// Whether to automatically replace emoji shortcodes with emoji characters.
     /// For example: typing `:wave:` gets replaced with `👋`.

crates/command_palette/src/command_palette.rs 🔗

@@ -327,6 +327,7 @@ impl PickerDelegate for CommandPaletteDelegate {
                         &candidates,
                         &query,
                         true,
+                        true,
                         10000,
                         &Default::default(),
                         executor,
@@ -448,7 +449,7 @@ impl PickerDelegate for CommandPaletteDelegate {
     }
 }
 
-fn humanize_action_name(name: &str) -> String {
+pub fn humanize_action_name(name: &str) -> String {
     let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
     let mut result = String::with_capacity(capacity);
     for char in name.chars() {
@@ -557,7 +558,7 @@ mod tests {
                 .clone()
         });
 
-        palette.update(cx, |palette, _| {
+        palette.read_with(cx, |palette, _| {
             assert!(palette.delegate.commands.len() > 5);
             let is_sorted =
                 |actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
@@ -566,7 +567,7 @@ mod tests {
 
         cx.simulate_input("bcksp");
 
-        palette.update(cx, |palette, _| {
+        palette.read_with(cx, |palette, _| {
             assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
         });
 
@@ -595,7 +596,7 @@ mod tests {
                 .picker
                 .clone()
         });
-        palette.update(cx, |palette, _| {
+        palette.read_with(cx, |palette, _| {
             assert!(palette.delegate.matches.is_empty())
         });
     }
@@ -630,7 +631,7 @@ mod tests {
         });
 
         cx.simulate_input("Editor::    Backspace");
-        palette.update(cx, |palette, _| {
+        palette.read_with(cx, |palette, _| {
             assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
         });
     }

crates/component/Cargo.toml 🔗

@@ -14,7 +14,7 @@ path = "src/component.rs"
 [dependencies]
 collections.workspace = true
 gpui.workspace = true
-linkme.workspace = true
+inventory.workspace = true
 parking_lot.workspace = true
 strum.workspace = true
 theme.workspace = true

crates/component/src/component.rs 🔗

@@ -9,13 +9,12 @@
 
 mod component_layout;
 
-pub use component_layout::*;
-
 use std::sync::LazyLock;
 
+pub use component_layout::*;
+
 use collections::HashMap;
 use gpui::{AnyElement, App, SharedString, Window};
-use linkme::distributed_slice;
 use parking_lot::RwLock;
 use strum::{Display, EnumString};
 
@@ -24,12 +23,27 @@ pub fn components() -> ComponentRegistry {
 }
 
 pub fn init() {
-    let component_fns: Vec<_> = __ALL_COMPONENTS.iter().cloned().collect();
-    for f in component_fns {
-        f();
+    for f in inventory::iter::<ComponentFn>() {
+        (f.0)();
+    }
+}
+
+pub struct ComponentFn(fn());
+
+impl ComponentFn {
+    pub const fn new(f: fn()) -> Self {
+        Self(f)
     }
 }
 
+inventory::collect!(ComponentFn);
+
+/// Private internals for macros.
+#[doc(hidden)]
+pub mod __private {
+    pub use inventory;
+}
+
 pub fn register_component<T: Component>() {
     let id = T::id();
     let metadata = ComponentMetadata {
@@ -46,9 +60,6 @@ pub fn register_component<T: Component>() {
     data.components.insert(id, metadata);
 }
 
-#[distributed_slice]
-pub static __ALL_COMPONENTS: [fn()] = [..];
-
 pub static COMPONENT_DATA: LazyLock<RwLock<ComponentRegistry>> =
     LazyLock::new(|| RwLock::new(ComponentRegistry::default()));
 
@@ -150,7 +161,7 @@ impl ComponentMetadata {
 }
 
 /// Implement this trait to define a UI component. This will allow you to
-/// derive `RegisterComponent` on it, in tutn allowing you to preview the
+/// derive `RegisterComponent` on it, in turn allowing you to preview the
 /// contents of the preview fn in `workspace: open component preview`.
 ///
 /// This can be useful for visual debugging and testing, documenting UI

crates/context_server/Cargo.toml 🔗

@@ -11,6 +11,9 @@ workspace = true
 [lib]
 path = "src/context_server.rs"
 
+[features]
+test-support = []
+
 [dependencies]
 anyhow.workspace = true
 async-trait.workspace = true

crates/context_server/src/client.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Context, Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use collections::HashMap;
 use futures::{FutureExt, StreamExt, channel::oneshot, select};
 use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, Task};
@@ -308,7 +308,7 @@ impl Client {
             .response_handlers
             .lock()
             .as_mut()
-            .ok_or_else(|| anyhow!("server shut down"))
+            .context("server shut down")
             .map(|handlers| {
                 handlers.insert(
                     RequestId::Int(id),
@@ -341,7 +341,7 @@ impl Client {
                         } else if let Some(result) = parsed.result {
                             Ok(serde_json::from_str(result.get())?)
                         } else {
-                            Err(anyhow!("Invalid response: no result or error"))
+                            anyhow::bail!("Invalid response: no result or error");
                         }
                     }
                     Err(_) => anyhow::bail!("cancelled")

crates/context_server/src/context_server.rs 🔗

@@ -1,5 +1,7 @@
 pub mod client;
 pub mod protocol;
+#[cfg(any(test, feature = "test-support"))]
+pub mod test;
 pub mod transport;
 pub mod types;
 
@@ -14,6 +16,7 @@ use gpui::AsyncApp;
 use parking_lot::RwLock;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
+use util::redact::should_redact;
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
 pub struct ContextServerId(pub Arc<str>);
@@ -24,13 +27,29 @@ impl Display for ContextServerId {
     }
 }
 
-#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
+#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
 pub struct ContextServerCommand {
     pub path: String,
     pub args: Vec<String>,
     pub env: Option<HashMap<String, String>>,
 }
 
+impl std::fmt::Debug for ContextServerCommand {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let filtered_env = self.env.as_ref().map(|env| {
+            env.iter()
+                .map(|(k, v)| (k, if should_redact(k) { "[REDACTED]" } else { v }))
+                .collect::<Vec<_>>()
+        });
+
+        f.debug_struct("ContextServerCommand")
+            .field("path", &self.path)
+            .field("args", &self.args)
+            .field("env", &filtered_env)
+            .finish()
+    }
+}
+
 enum ContextServerTransport {
     Stdio(ContextServerCommand),
     Custom(Arc<dyn crate::transport::Transport>),

crates/context_server/src/protocol.rs 🔗

@@ -6,10 +6,9 @@
 //! of messages.
 
 use anyhow::Result;
-use collections::HashMap;
 
 use crate::client::Client;
-use crate::types;
+use crate::types::{self, Notification, Request};
 
 pub struct ModelContextProtocol {
     inner: Client,
@@ -21,9 +20,10 @@ impl ModelContextProtocol {
     }
 
     fn supported_protocols() -> Vec<types::ProtocolVersion> {
-        vec![types::ProtocolVersion(
-            types::LATEST_PROTOCOL_VERSION.to_string(),
-        )]
+        vec![
+            types::ProtocolVersion(types::LATEST_PROTOCOL_VERSION.to_string()),
+            types::ProtocolVersion(types::VERSION_2024_11_05.to_string()),
+        ]
     }
 
     pub async fn initialize(
@@ -43,28 +43,24 @@ impl ModelContextProtocol {
 
         let response: types::InitializeResponse = self
             .inner
-            .request(types::RequestType::Initialize.as_str(), params)
+            .request(types::requests::Initialize::METHOD, params)
             .await?;
 
-        if !Self::supported_protocols().contains(&response.protocol_version) {
-            return Err(anyhow::anyhow!(
-                "Unsupported protocol version: {:?}",
-                response.protocol_version
-            ));
-        }
+        anyhow::ensure!(
+            Self::supported_protocols().contains(&response.protocol_version),
+            "Unsupported protocol version: {:?}",
+            response.protocol_version
+        );
 
         log::trace!("mcp server info {:?}", response.server_info);
 
-        self.inner.notify(
-            types::NotificationType::Initialized.as_str(),
-            serde_json::json!({}),
-        )?;
-
         let initialized_protocol = InitializedContextServerProtocol {
             inner: self.inner,
             initialize: response,
         };
 
+        initialized_protocol.notify::<types::notifications::Initialized>(())?;
+
         Ok(initialized_protocol)
     }
 }
@@ -95,140 +91,11 @@ impl InitializedContextServerProtocol {
         }
     }
 
-    fn check_capability(&self, capability: ServerCapability) -> Result<()> {
-        if self.capable(capability) {
-            Ok(())
-        } else {
-            Err(anyhow::anyhow!(
-                "Server does not support {:?} capability",
-                capability
-            ))
-        }
-    }
-
-    /// List the MCP prompts.
-    pub async fn list_prompts(&self) -> Result<Vec<types::Prompt>> {
-        self.check_capability(ServerCapability::Prompts)?;
-
-        let response: types::PromptsListResponse = self
-            .inner
-            .request(
-                types::RequestType::PromptsList.as_str(),
-                serde_json::json!({}),
-            )
-            .await?;
-
-        Ok(response.prompts)
-    }
-
-    /// List the MCP resources.
-    pub async fn list_resources(&self) -> Result<types::ResourcesListResponse> {
-        self.check_capability(ServerCapability::Resources)?;
-
-        let response: types::ResourcesListResponse = self
-            .inner
-            .request(
-                types::RequestType::ResourcesList.as_str(),
-                serde_json::json!({}),
-            )
-            .await?;
-
-        Ok(response)
-    }
-
-    /// Executes a prompt with the given arguments and returns the result.
-    pub async fn run_prompt<P: AsRef<str>>(
-        &self,
-        prompt: P,
-        arguments: HashMap<String, String>,
-    ) -> Result<types::PromptsGetResponse> {
-        self.check_capability(ServerCapability::Prompts)?;
-
-        let params = types::PromptsGetParams {
-            name: prompt.as_ref().to_string(),
-            arguments: Some(arguments),
-            meta: None,
-        };
-
-        let response: types::PromptsGetResponse = self
-            .inner
-            .request(types::RequestType::PromptsGet.as_str(), params)
-            .await?;
-
-        Ok(response)
-    }
-
-    pub async fn completion<P: Into<String>>(
-        &self,
-        reference: types::CompletionReference,
-        argument: P,
-        value: P,
-    ) -> Result<types::Completion> {
-        let params = types::CompletionCompleteParams {
-            r#ref: reference,
-            argument: types::CompletionArgument {
-                name: argument.into(),
-                value: value.into(),
-            },
-            meta: None,
-        };
-        let result: types::CompletionCompleteResponse = self
-            .inner
-            .request(types::RequestType::CompletionComplete.as_str(), params)
-            .await?;
-
-        let completion = types::Completion {
-            values: result.completion.values,
-            total: types::CompletionTotal::from_options(
-                result.completion.has_more,
-                result.completion.total,
-            ),
-        };
-
-        Ok(completion)
+    pub async fn request<T: Request>(&self, params: T::Params) -> Result<T::Response> {
+        self.inner.request(T::METHOD, params).await
     }
 
-    /// List MCP tools.
-    pub async fn list_tools(&self) -> Result<types::ListToolsResponse> {
-        self.check_capability(ServerCapability::Tools)?;
-
-        let response = self
-            .inner
-            .request::<types::ListToolsResponse>(types::RequestType::ListTools.as_str(), ())
-            .await?;
-
-        Ok(response)
-    }
-
-    /// Executes a tool with the given arguments
-    pub async fn run_tool<P: AsRef<str>>(
-        &self,
-        tool: P,
-        arguments: Option<HashMap<String, serde_json::Value>>,
-    ) -> Result<types::CallToolResponse> {
-        self.check_capability(ServerCapability::Tools)?;
-
-        let params = types::CallToolParams {
-            name: tool.as_ref().to_string(),
-            arguments,
-            meta: None,
-        };
-
-        let response: types::CallToolResponse = self
-            .inner
-            .request(types::RequestType::CallTool.as_str(), params)
-            .await?;
-
-        Ok(response)
-    }
-}
-
-impl InitializedContextServerProtocol {
-    pub async fn request<R: serde::de::DeserializeOwned>(
-        &self,
-        method: &str,
-        params: impl serde::Serialize,
-    ) -> Result<R> {
-        self.inner.request(method, params).await
+    pub fn notify<T: Notification>(&self, params: T::Params) -> Result<()> {
+        self.inner.notify(T::METHOD, params)
     }
 }

crates/context_server/src/test.rs 🔗

@@ -0,0 +1,118 @@
+use anyhow::Context as _;
+use collections::HashMap;
+use futures::{Stream, StreamExt as _, lock::Mutex};
+use gpui::BackgroundExecutor;
+use std::{pin::Pin, sync::Arc};
+
+use crate::{
+    transport::Transport,
+    types::{Implementation, InitializeResponse, ProtocolVersion, ServerCapabilities},
+};
+
+pub fn create_fake_transport(
+    name: impl Into<String>,
+    executor: BackgroundExecutor,
+) -> FakeTransport {
+    let name = name.into();
+    FakeTransport::new(executor).on_request::<crate::types::requests::Initialize>(move |_params| {
+        create_initialize_response(name.clone())
+    })
+}
+
+fn create_initialize_response(server_name: String) -> InitializeResponse {
+    InitializeResponse {
+        protocol_version: ProtocolVersion(crate::types::LATEST_PROTOCOL_VERSION.to_string()),
+        server_info: Implementation {
+            name: server_name,
+            version: "1.0.0".to_string(),
+        },
+        capabilities: ServerCapabilities::default(),
+        meta: None,
+    }
+}
+
+pub struct FakeTransport {
+    request_handlers:
+        HashMap<&'static str, Arc<dyn Fn(serde_json::Value) -> serde_json::Value + Send + Sync>>,
+    tx: futures::channel::mpsc::UnboundedSender<String>,
+    rx: Arc<Mutex<futures::channel::mpsc::UnboundedReceiver<String>>>,
+    executor: BackgroundExecutor,
+}
+
+impl FakeTransport {
+    pub fn new(executor: BackgroundExecutor) -> Self {
+        let (tx, rx) = futures::channel::mpsc::unbounded();
+        Self {
+            request_handlers: Default::default(),
+            tx,
+            rx: Arc::new(Mutex::new(rx)),
+            executor,
+        }
+    }
+
+    pub fn on_request<T: crate::types::Request>(
+        mut self,
+        handler: impl Fn(T::Params) -> T::Response + Send + Sync + 'static,
+    ) -> Self {
+        self.request_handlers.insert(
+            T::METHOD,
+            Arc::new(move |value| {
+                let params = value.get("params").expect("Missing parameters").clone();
+                let params: T::Params =
+                    serde_json::from_value(params).expect("Invalid parameters received");
+                let response = handler(params);
+                serde_json::to_value(response).unwrap()
+            }),
+        );
+        self
+    }
+}
+
+#[async_trait::async_trait]
+impl Transport for FakeTransport {
+    async fn send(&self, message: String) -> anyhow::Result<()> {
+        if let Ok(msg) = serde_json::from_str::<serde_json::Value>(&message) {
+            let id = msg.get("id").and_then(|id| id.as_u64()).unwrap_or(0);
+
+            if let Some(method) = msg.get("method") {
+                let method = method.as_str().expect("Invalid method received");
+                if let Some(handler) = self.request_handlers.get(method) {
+                    let payload = handler(msg);
+                    let response = serde_json::json!({
+                        "jsonrpc": "2.0",
+                        "id": id,
+                        "result": payload
+                    });
+                    self.tx
+                        .unbounded_send(response.to_string())
+                        .context("sending a message")?;
+                } else {
+                    log::debug!("No handler registered for MCP request '{method}'");
+                }
+            }
+        }
+        Ok(())
+    }
+
+    fn receive(&self) -> Pin<Box<dyn Stream<Item = String> + Send>> {
+        let rx = self.rx.clone();
+        let executor = self.executor.clone();
+        Box::pin(futures::stream::unfold(rx, move |rx| {
+            let executor = executor.clone();
+            async move {
+                let mut rx_guard = rx.lock().await;
+                executor.simulate_random_delay().await;
+                if let Some(message) = rx_guard.next().await {
+                    drop(rx_guard);
+                    Some((message, rx))
+                } else {
+                    None
+                }
+            }
+        }))
+    }
+
+    fn receive_err(&self) -> Pin<Box<dyn Stream<Item = String> + Send>> {
+        Box::pin(futures::stream::empty())
+    }
+}

crates/context_server/src/types.rs 🔗

@@ -1,76 +1,144 @@
 use collections::HashMap;
+use serde::de::DeserializeOwned;
 use serde::{Deserialize, Serialize};
 use url::Url;
 
-pub const LATEST_PROTOCOL_VERSION: &str = "2024-11-05";
-
-pub enum RequestType {
-    Initialize,
-    CallTool,
-    ResourcesUnsubscribe,
-    ResourcesSubscribe,
-    ResourcesRead,
-    ResourcesList,
-    LoggingSetLevel,
-    PromptsGet,
-    PromptsList,
-    CompletionComplete,
-    Ping,
-    ListTools,
-    ListResourceTemplates,
-    ListRoots,
-}
-
-impl RequestType {
-    pub fn as_str(&self) -> &'static str {
-        match self {
-            RequestType::Initialize => "initialize",
-            RequestType::CallTool => "tools/call",
-            RequestType::ResourcesUnsubscribe => "resources/unsubscribe",
-            RequestType::ResourcesSubscribe => "resources/subscribe",
-            RequestType::ResourcesRead => "resources/read",
-            RequestType::ResourcesList => "resources/list",
-            RequestType::LoggingSetLevel => "logging/setLevel",
-            RequestType::PromptsGet => "prompts/get",
-            RequestType::PromptsList => "prompts/list",
-            RequestType::CompletionComplete => "completion/complete",
-            RequestType::Ping => "ping",
-            RequestType::ListTools => "tools/list",
-            RequestType::ListResourceTemplates => "resources/templates/list",
-            RequestType::ListRoots => "roots/list",
-        }
+pub const LATEST_PROTOCOL_VERSION: &str = "2025-03-26";
+pub const VERSION_2024_11_05: &str = "2024-11-05";
+
+pub mod requests {
+    use super::*;
+
+    macro_rules! request {
+        ($method:expr, $name:ident, $params:ty, $response:ty) => {
+            pub struct $name;
+
+            impl Request for $name {
+                type Params = $params;
+                type Response = $response;
+                const METHOD: &'static str = $method;
+            }
+        };
     }
-}
 
-impl TryFrom<&str> for RequestType {
-    type Error = ();
-
-    fn try_from(s: &str) -> Result<Self, Self::Error> {
-        match s {
-            "initialize" => Ok(RequestType::Initialize),
-            "tools/call" => Ok(RequestType::CallTool),
-            "resources/unsubscribe" => Ok(RequestType::ResourcesUnsubscribe),
-            "resources/subscribe" => Ok(RequestType::ResourcesSubscribe),
-            "resources/read" => Ok(RequestType::ResourcesRead),
-            "resources/list" => Ok(RequestType::ResourcesList),
-            "logging/setLevel" => Ok(RequestType::LoggingSetLevel),
-            "prompts/get" => Ok(RequestType::PromptsGet),
-            "prompts/list" => Ok(RequestType::PromptsList),
-            "completion/complete" => Ok(RequestType::CompletionComplete),
-            "ping" => Ok(RequestType::Ping),
-            "tools/list" => Ok(RequestType::ListTools),
-            "resources/templates/list" => Ok(RequestType::ListResourceTemplates),
-            "roots/list" => Ok(RequestType::ListRoots),
-            _ => Err(()),
-        }
+    request!(
+        "initialize",
+        Initialize,
+        InitializeParams,
+        InitializeResponse
+    );
+    request!("tools/call", CallTool, CallToolParams, CallToolResponse);
+    request!(
+        "resources/unsubscribe",
+        ResourcesUnsubscribe,
+        ResourcesUnsubscribeParams,
+        ()
+    );
+    request!(
+        "resources/subscribe",
+        ResourcesSubscribe,
+        ResourcesSubscribeParams,
+        ()
+    );
+    request!(
+        "resources/read",
+        ResourcesRead,
+        ResourcesReadParams,
+        ResourcesReadResponse
+    );
+    request!("resources/list", ResourcesList, (), ResourcesListResponse);
+    request!(
+        "logging/setLevel",
+        LoggingSetLevel,
+        LoggingSetLevelParams,
+        ()
+    );
+    request!(
+        "prompts/get",
+        PromptsGet,
+        PromptsGetParams,
+        PromptsGetResponse
+    );
+    request!("prompts/list", PromptsList, (), PromptsListResponse);
+    request!(
+        "completion/complete",
+        CompletionComplete,
+        CompletionCompleteParams,
+        CompletionCompleteResponse
+    );
+    request!("ping", Ping, (), ());
+    request!("tools/list", ListTools, (), ListToolsResponse);
+    request!(
+        "resources/templates/list",
+        ListResourceTemplates,
+        (),
+        ListResourceTemplatesResponse
+    );
+    request!("roots/list", ListRoots, (), ListRootsResponse);
+}
+
+pub trait Request {
+    type Params: DeserializeOwned + Serialize + Send + Sync + 'static;
+    type Response: DeserializeOwned + Serialize + Send + Sync + 'static;
+    const METHOD: &'static str;
+}
+
+pub mod notifications {
+    use super::*;
+
+    macro_rules! notification {
+        ($method:expr, $name:ident, $params:ty) => {
+            pub struct $name;
+
+            impl Notification for $name {
+                type Params = $params;
+                const METHOD: &'static str = $method;
+            }
+        };
     }
+
+    notification!("notifications/initialized", Initialized, ());
+    notification!("notifications/progress", Progress, ProgressParams);
+    notification!("notifications/message", Message, MessageParams);
+    notification!(
+        "notifications/resources/updated",
+        ResourcesUpdated,
+        ResourcesUpdatedParams
+    );
+    notification!(
+        "notifications/resources/list_changed",
+        ResourcesListChanged,
+        ()
+    );
+    notification!("notifications/tools/list_changed", ToolsListChanged, ());
+    notification!("notifications/prompts/list_changed", PromptsListChanged, ());
+    notification!("notifications/roots/list_changed", RootsListChanged, ());
+}
+
+pub trait Notification {
+    type Params: DeserializeOwned + Serialize + Send + Sync + 'static;
+    const METHOD: &'static str;
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MessageParams {
+    pub level: LoggingLevel,
+    pub logger: Option<String>,
+    pub data: serde_json::Value,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ResourcesUpdatedParams {
+    pub uri: String,
 }
 
 #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(transparent)]
 pub struct ProtocolVersion(pub String);
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct InitializeParams {
     pub protocol_version: ProtocolVersion,
@@ -80,7 +148,7 @@ pub struct InitializeParams {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct CallToolParams {
     pub name: String,
@@ -90,7 +158,7 @@ pub struct CallToolParams {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ResourcesUnsubscribeParams {
     pub uri: Url,
@@ -98,7 +166,7 @@ pub struct ResourcesUnsubscribeParams {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ResourcesSubscribeParams {
     pub uri: Url,
@@ -106,7 +174,7 @@ pub struct ResourcesSubscribeParams {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ResourcesReadParams {
     pub uri: Url,
@@ -114,7 +182,7 @@ pub struct ResourcesReadParams {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct LoggingSetLevelParams {
     pub level: LoggingLevel,
@@ -122,7 +190,7 @@ pub struct LoggingSetLevelParams {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct PromptsGetParams {
     pub name: String,
@@ -132,37 +200,40 @@ pub struct PromptsGetParams {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct CompletionCompleteParams {
-    pub r#ref: CompletionReference,
+    #[serde(rename = "ref")]
+    pub reference: CompletionReference,
     pub argument: CompletionArgument,
     #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(untagged)]
 pub enum CompletionReference {
     Prompt(PromptReference),
     Resource(ResourceReference),
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct PromptReference {
-    pub r#type: PromptReferenceType,
+    #[serde(rename = "type")]
+    pub ty: PromptReferenceType,
     pub name: String,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ResourceReference {
-    pub r#type: PromptReferenceType,
+    #[serde(rename = "type")]
+    pub ty: PromptReferenceType,
     pub uri: Url,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "snake_case")]
 pub enum PromptReferenceType {
     #[serde(rename = "ref/prompt")]
@@ -171,7 +242,7 @@ pub enum PromptReferenceType {
     Resource,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct CompletionArgument {
     pub name: String,
@@ -188,7 +259,7 @@ pub struct InitializeResponse {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ResourcesReadResponse {
     pub contents: Vec<ResourceContentsType>,
@@ -196,14 +267,14 @@ pub struct ResourcesReadResponse {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(untagged)]
 pub enum ResourceContentsType {
     Text(TextResourceContents),
     Blob(BlobResourceContents),
 }
 
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ResourcesListResponse {
     pub resources: Vec<Resource>,
@@ -220,7 +291,7 @@ pub struct SamplingMessage {
     pub content: MessageContent,
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct CreateMessageRequest {
     pub messages: Vec<SamplingMessage>,
@@ -272,13 +343,20 @@ pub enum MessageContent {
         #[serde(skip_serializing_if = "Option::is_none")]
         annotations: Option<MessageAnnotations>,
     },
-    #[serde(rename = "image")]
+    #[serde(rename = "image", rename_all = "camelCase")]
     Image {
         data: String,
         mime_type: String,
         #[serde(skip_serializing_if = "Option::is_none")]
         annotations: Option<MessageAnnotations>,
     },
+    #[serde(rename = "audio", rename_all = "camelCase")]
+    Audio {
+        data: String,
+        mime_type: String,
+        #[serde(skip_serializing_if = "Option::is_none")]
+        annotations: Option<MessageAnnotations>,
+    },
     #[serde(rename = "resource")]
     Resource {
         resource: ResourceContents,
@@ -296,7 +374,7 @@ pub struct MessageAnnotations {
     pub priority: Option<f64>,
 }
 
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct PromptsGetResponse {
     #[serde(skip_serializing_if = "Option::is_none")]
@@ -306,7 +384,7 @@ pub struct PromptsGetResponse {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct PromptsListResponse {
     pub prompts: Vec<Prompt>,
@@ -316,7 +394,7 @@ pub struct PromptsListResponse {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct CompletionCompleteResponse {
     pub completion: CompletionResult,
@@ -324,7 +402,7 @@ pub struct CompletionCompleteResponse {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct CompletionResult {
     pub values: Vec<String>,
@@ -336,7 +414,7 @@ pub struct CompletionResult {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Deserialize, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct Prompt {
     pub name: String,
@@ -346,7 +424,7 @@ pub struct Prompt {
     pub arguments: Option<Vec<PromptArgument>>,
 }
 
-#[derive(Debug, Deserialize, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct PromptArgument {
     pub name: String,
@@ -375,6 +453,8 @@ pub struct ServerCapabilities {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub logging: Option<serde_json::Value>,
     #[serde(skip_serializing_if = "Option::is_none")]
+    pub completions: Option<serde_json::Value>,
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub prompts: Option<PromptsCapabilities>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub resources: Option<ResourcesCapabilities>,
@@ -419,6 +499,28 @@ pub struct Tool {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub description: Option<String>,
     pub input_schema: serde_json::Value,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub annotations: Option<ToolAnnotations>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ToolAnnotations {
+    /// A human-readable title for the tool.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub title: Option<String>,
+    /// If true, the tool does not modify its environment.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub read_only_hint: Option<bool>,
+    /// If true, the tool may perform destructive updates to its environment.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub destructive_hint: Option<bool>,
+    /// If true, calling the tool repeatedly with the same arguments will have no additional effect on its environment.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub idempotent_hint: Option<bool>,
+    /// If true, this tool may interact with an "open world" of external entities.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub open_world_hint: Option<bool>,
 }
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -509,34 +611,6 @@ pub struct ModelHint {
     pub name: Option<String>,
 }
 
-#[derive(Debug, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub enum NotificationType {
-    Initialized,
-    Progress,
-    Message,
-    ResourcesUpdated,
-    ResourcesListChanged,
-    ToolsListChanged,
-    PromptsListChanged,
-    RootsListChanged,
-}
-
-impl NotificationType {
-    pub fn as_str(&self) -> &'static str {
-        match self {
-            NotificationType::Initialized => "notifications/initialized",
-            NotificationType::Progress => "notifications/progress",
-            NotificationType::Message => "notifications/message",
-            NotificationType::ResourcesUpdated => "notifications/resources/updated",
-            NotificationType::ResourcesListChanged => "notifications/resources/list_changed",
-            NotificationType::ToolsListChanged => "notifications/tools/list_changed",
-            NotificationType::PromptsListChanged => "notifications/prompts/list_changed",
-            NotificationType::RootsListChanged => "notifications/roots/list_changed",
-        }
-    }
-}
-
 #[derive(Debug, Serialize)]
 #[serde(untagged)]
 pub enum ClientNotification {
@@ -557,12 +631,14 @@ pub enum ProgressToken {
     Number(f64),
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ProgressParams {
     pub progress_token: ProgressToken,
     pub progress: f64,
     #[serde(skip_serializing_if = "Option::is_none")]
+    pub message: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub total: Option<f64>,
     #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
     pub meta: Option<HashMap<String, serde_json::Value>>,
@@ -589,7 +665,7 @@ pub struct Completion {
     pub total: CompletionTotal,
 }
 
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct CallToolResponse {
     pub content: Vec<ToolResponseContent>,
@@ -604,8 +680,10 @@ pub struct CallToolResponse {
 pub enum ToolResponseContent {
     #[serde(rename = "text")]
     Text { text: String },
-    #[serde(rename = "image")]
+    #[serde(rename = "image", rename_all = "camelCase")]
     Image { data: String, mime_type: String },
+    #[serde(rename = "audio", rename_all = "camelCase")]
+    Audio { data: String, mime_type: String },
     #[serde(rename = "resource")]
     Resource { resource: ResourceContents },
 }
@@ -620,7 +698,7 @@ pub struct ListToolsResponse {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ListResourceTemplatesResponse {
     pub resource_templates: Vec<ResourceTemplate>,
@@ -630,7 +708,7 @@ pub struct ListResourceTemplatesResponse {
     pub meta: Option<HashMap<String, serde_json::Value>>,
 }
 
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct ListRootsResponse {
     pub roots: Vec<Root>,

crates/copilot/Cargo.toml 🔗

@@ -14,7 +14,6 @@ doctest = false
 
 [features]
 default = []
-schemars = ["dep:schemars"]
 test-support = [
     "collections/test-support",
     "gpui/test-support",
@@ -30,6 +29,7 @@ chrono.workspace = true
 client.workspace = true
 collections.workspace = true
 command_palette_hooks.workspace = true
+dirs.workspace = true
 fs.workspace = true
 futures.workspace = true
 gpui.workspace = true
@@ -43,16 +43,15 @@ node_runtime.workspace = true
 parking_lot.workspace = true
 paths.workspace = true
 project.workspace = true
-schemars = { workspace = true, optional = true }
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
-strum.workspace = true
 task.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true
 workspace-hack.workspace = true
+itertools.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 async-std = { version = "1.12.0", features = ["unstable"] }
@@ -63,7 +62,6 @@ clock = { workspace = true, features = ["test-support"] }
 collections = { workspace = true, features = ["test-support"] }
 ctor.workspace = true
 editor = { workspace = true, features = ["test-support"] }
-env_logger.workspace = true
 fs = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 http_client = { workspace = true, features = ["test-support"] }
@@ -78,3 +76,4 @@ settings = { workspace = true, features = ["test-support"] }
 theme = { workspace = true, features = ["test-support"] }
 util = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/copilot/src/copilot.rs 🔗

@@ -24,8 +24,10 @@ use lsp::{LanguageServer, LanguageServerBinary, LanguageServerId, LanguageServer
 use node_runtime::NodeRuntime;
 use parking_lot::Mutex;
 use request::StatusNotification;
+use serde_json::json;
 use settings::SettingsStore;
 use sign_in::{reinstall_and_sign_in_within_workspace, sign_out_within_workspace};
+use std::collections::hash_map::Entry;
 use std::{
     any::TypeId,
     env,
@@ -60,7 +62,15 @@ pub fn init(
     node_runtime: NodeRuntime,
     cx: &mut App,
 ) {
-    copilot_chat::init(fs.clone(), http.clone(), cx);
+    let language_settings = all_language_settings(None, cx);
+    let configuration = copilot_chat::CopilotChatConfiguration {
+        enterprise_uri: language_settings
+            .edit_predictions
+            .copilot
+            .enterprise_uri
+            .clone(),
+    };
+    copilot_chat::init(fs.clone(), http.clone(), configuration, cx);
 
     let copilot = cx.new({
         let node_runtime = node_runtime.clone();
@@ -133,21 +143,20 @@ enum CopilotServer {
 impl CopilotServer {
     fn as_authenticated(&mut self) -> Result<&mut RunningCopilotServer> {
         let server = self.as_running()?;
-        if matches!(server.sign_in_status, SignInStatus::Authorized { .. }) {
-            Ok(server)
-        } else {
-            Err(anyhow!("must sign in before using copilot"))
-        }
+        anyhow::ensure!(
+            matches!(server.sign_in_status, SignInStatus::Authorized { .. }),
+            "must sign in before using copilot"
+        );
+        Ok(server)
     }
 
     fn as_running(&mut self) -> Result<&mut RunningCopilotServer> {
         match self {
-            CopilotServer::Starting { .. } => Err(anyhow!("copilot is still starting")),
-            CopilotServer::Disabled => Err(anyhow!("copilot is disabled")),
-            CopilotServer::Error(error) => Err(anyhow!(
-                "copilot was not started because of an error: {}",
-                error
-            )),
+            CopilotServer::Starting { .. } => anyhow::bail!("copilot is still starting"),
+            CopilotServer::Disabled => anyhow::bail!("copilot is disabled"),
+            CopilotServer::Error(error) => {
+                anyhow::bail!("copilot was not started because of an error: {error}")
+            }
             CopilotServer::Running(server) => Ok(server),
         }
     }
@@ -233,7 +242,7 @@ impl RegisteredBuffer {
                         Some(buffer.snapshot.version.clone())
                     })
                     .ok()??;
-                let new_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()).ok()?;
+                let new_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()).ok()?;
 
                 let content_changes = cx
                     .background_spawn({
@@ -347,8 +356,11 @@ impl Copilot {
             _subscription: cx.on_app_quit(Self::shutdown_language_server),
         };
         this.start_copilot(true, false, cx);
-        cx.observe_global::<SettingsStore>(move |this, cx| this.start_copilot(true, false, cx))
-            .detach();
+        cx.observe_global::<SettingsStore>(move |this, cx| {
+            this.start_copilot(true, false, cx);
+            this.send_configuration_update(cx);
+        })
+        .detach();
         this
     }
 
@@ -409,24 +421,67 @@ impl Copilot {
         let proxy_url = copilot_settings.proxy.clone()?;
         let no_verify = copilot_settings.proxy_no_verify;
         let http_or_https_proxy = if proxy_url.starts_with("http:") {
-            "HTTP_PROXY"
+            Some("HTTP_PROXY")
         } else if proxy_url.starts_with("https:") {
-            "HTTPS_PROXY"
+            Some("HTTPS_PROXY")
         } else {
             log::error!(
                 "Unsupported protocol scheme for language server proxy (must be http or https)"
             );
-            return None;
+            None
         };
 
         let mut env = HashMap::default();
-        env.insert(http_or_https_proxy.to_string(), proxy_url);
 
-        if let Some(true) = no_verify {
-            env.insert("NODE_TLS_REJECT_UNAUTHORIZED".to_string(), "0".to_string());
-        };
+        if let Some(proxy_type) = http_or_https_proxy {
+            env.insert(proxy_type.to_string(), proxy_url);
+            if let Some(true) = no_verify {
+                env.insert("NODE_TLS_REJECT_UNAUTHORIZED".to_string(), "0".to_string());
+            };
+        }
 
-        Some(env)
+        if let Ok(oauth_token) = env::var(copilot_chat::COPILOT_OAUTH_ENV_VAR) {
+            env.insert(copilot_chat::COPILOT_OAUTH_ENV_VAR.to_string(), oauth_token);
+        }
+
+        if env.is_empty() { None } else { Some(env) }
+    }
+
+    fn send_configuration_update(&mut self, cx: &mut Context<Self>) {
+        let copilot_settings = all_language_settings(None, cx)
+            .edit_predictions
+            .copilot
+            .clone();
+
+        let settings = json!({
+            "http": {
+                "proxy": copilot_settings.proxy,
+                "proxyStrictSSL": !copilot_settings.proxy_no_verify.unwrap_or(false)
+            },
+            "github-enterprise": {
+                "uri": copilot_settings.enterprise_uri
+            }
+        });
+
+        if let Some(copilot_chat) = copilot_chat::CopilotChat::global(cx) {
+            copilot_chat.update(cx, |chat, cx| {
+                chat.set_configuration(
+                    copilot_chat::CopilotChatConfiguration {
+                        enterprise_uri: copilot_settings.enterprise_uri.clone(),
+                    },
+                    cx,
+                );
+            });
+        }
+
+        if let Ok(server) = self.server.as_running() {
+            server
+                .lsp
+                .notify::<lsp::notification::DidChangeConfiguration>(
+                    &lsp::DidChangeConfigurationParams { settings },
+                )
+                .log_err();
+        }
     }
 
     #[cfg(any(test, feature = "test-support"))]
@@ -521,7 +576,7 @@ impl Copilot {
 
             let server = cx
                 .update(|cx| {
-                    let mut params = server.default_initialize_params(cx);
+                    let mut params = server.default_initialize_params(false, cx);
                     params.initialization_options = Some(editor_info_json);
                     server.initialize(params, configuration.into(), cx)
                 })?
@@ -535,12 +590,6 @@ impl Copilot {
                 .into_response()
                 .context("copilot: check status")?;
 
-            server
-                .request::<request::SetEditorInfo>(editor_info)
-                .await
-                .into_response()
-                .context("copilot: set editor info")?;
-
             anyhow::Ok((server, status))
         };
 
@@ -558,6 +607,8 @@ impl Copilot {
                     });
                     cx.emit(Event::CopilotLanguageServerStarted);
                     this.update_sign_in_status(status, cx);
+                    // Send configuration now that the LSP is fully started
+                    this.send_configuration_update(cx);
                 }
                 Err(error) => {
                     this.server = CopilotServer::Error(error.to_string().into());
@@ -648,7 +699,7 @@ impl Copilot {
                 }
             };
 
-            cx.background_spawn(task.map_err(|err| anyhow!("{:?}", err)))
+            cx.background_spawn(task.map_err(|err| anyhow!("{err:?}")))
         } else {
             // If we're downloading, wait until download is finished
             // If we're in a stuck state, display to the user
@@ -726,42 +777,43 @@ impl Copilot {
                 return;
             }
 
-            registered_buffers
-                .entry(buffer.entity_id())
-                .or_insert_with(|| {
-                    let uri: lsp::Url = uri_for_buffer(buffer, cx);
-                    let language_id = id_for_language(buffer.read(cx).language());
-                    let snapshot = buffer.read(cx).snapshot();
-                    server
-                        .notify::<lsp::notification::DidOpenTextDocument>(
-                            &lsp::DidOpenTextDocumentParams {
-                                text_document: lsp::TextDocumentItem {
-                                    uri: uri.clone(),
-                                    language_id: language_id.clone(),
-                                    version: 0,
-                                    text: snapshot.text(),
-                                },
+            let entry = registered_buffers.entry(buffer.entity_id());
+            if let Entry::Vacant(e) = entry {
+                let Ok(uri) = uri_for_buffer(buffer, cx) else {
+                    return;
+                };
+                let language_id = id_for_language(buffer.read(cx).language());
+                let snapshot = buffer.read(cx).snapshot();
+                server
+                    .notify::<lsp::notification::DidOpenTextDocument>(
+                        &lsp::DidOpenTextDocumentParams {
+                            text_document: lsp::TextDocumentItem {
+                                uri: uri.clone(),
+                                language_id: language_id.clone(),
+                                version: 0,
+                                text: snapshot.text(),
                             },
-                        )
-                        .ok();
+                        },
+                    )
+                    .ok();
 
-                    RegisteredBuffer {
-                        uri,
-                        language_id,
-                        snapshot,
-                        snapshot_version: 0,
-                        pending_buffer_change: Task::ready(Some(())),
-                        _subscriptions: [
-                            cx.subscribe(buffer, |this, buffer, event, cx| {
-                                this.handle_buffer_event(buffer, event, cx).log_err();
-                            }),
-                            cx.observe_release(buffer, move |this, _buffer, _cx| {
-                                this.buffers.remove(&weak_buffer);
-                                this.unregister_buffer(&weak_buffer);
-                            }),
-                        ],
-                    }
+                e.insert(RegisteredBuffer {
+                    uri,
+                    language_id,
+                    snapshot,
+                    snapshot_version: 0,
+                    pending_buffer_change: Task::ready(Some(())),
+                    _subscriptions: [
+                        cx.subscribe(buffer, |this, buffer, event, cx| {
+                            this.handle_buffer_event(buffer, event, cx).log_err();
+                        }),
+                        cx.observe_release(buffer, move |this, _buffer, _cx| {
+                            this.buffers.remove(&weak_buffer);
+                            this.unregister_buffer(&weak_buffer);
+                        }),
+                    ],
                 });
+            }
         }
     }
 
@@ -793,7 +845,9 @@ impl Copilot {
                     language::BufferEvent::FileHandleChanged
                     | language::BufferEvent::LanguageChanged => {
                         let new_language_id = id_for_language(buffer.read(cx).language());
-                        let new_uri = uri_for_buffer(&buffer, cx);
+                        let Ok(new_uri) = uri_for_buffer(&buffer, cx) else {
+                            return Ok(());
+                        };
                         if new_uri != registered_buffer.uri
                             || new_language_id != registered_buffer.language_id
                         {
@@ -1063,11 +1117,13 @@ fn id_for_language(language: Option<&Arc<Language>>) -> String {
         .unwrap_or_else(|| "plaintext".to_string())
 }
 
-fn uri_for_buffer(buffer: &Entity<Buffer>, cx: &App) -> lsp::Url {
+fn uri_for_buffer(buffer: &Entity<Buffer>, cx: &App) -> Result<lsp::Url, ()> {
     if let Some(file) = buffer.read(cx).file().and_then(|file| file.as_local()) {
-        lsp::Url::from_file_path(file.abs_path(cx)).unwrap()
+        lsp::Url::from_file_path(file.abs_path(cx))
     } else {
-        format!("buffer://{}", buffer.entity_id()).parse().unwrap()
+        format!("buffer://{}", buffer.entity_id())
+            .parse()
+            .map_err(|_| ())
     }
 }
 
@@ -1330,7 +1386,5 @@ mod tests {
 #[cfg(test)]
 #[ctor::ctor]
 fn init_logger() {
-    if std::env::var("RUST_LOG").is_ok() {
-        env_logger::init();
-    }
+    zlog::init_test();
 }

crates/copilot/src/copilot_chat.rs 🔗

@@ -2,20 +2,71 @@ use std::path::PathBuf;
 use std::sync::Arc;
 use std::sync::OnceLock;
 
+use anyhow::Context as _;
 use anyhow::{Result, anyhow};
 use chrono::DateTime;
 use collections::HashSet;
 use fs::Fs;
 use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
+use gpui::WeakEntity;
 use gpui::{App, AsyncApp, Global, prelude::*};
 use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
+use itertools::Itertools;
 use paths::home_dir;
 use serde::{Deserialize, Serialize};
 use settings::watch_config_dir;
-use strum::EnumIter;
 
-pub const COPILOT_CHAT_COMPLETION_URL: &str = "https://api.githubcopilot.com/chat/completions";
-pub const COPILOT_CHAT_AUTH_URL: &str = "https://api.github.com/copilot_internal/v2/token";
+pub const COPILOT_OAUTH_ENV_VAR: &str = "GH_COPILOT_TOKEN";
+
+#[derive(Default, Clone, Debug, PartialEq)]
+pub struct CopilotChatConfiguration {
+    pub enterprise_uri: Option<String>,
+}
+
+impl CopilotChatConfiguration {
+    pub fn token_url(&self) -> String {
+        if let Some(enterprise_uri) = &self.enterprise_uri {
+            let domain = Self::parse_domain(enterprise_uri);
+            format!("https://api.{}/copilot_internal/v2/token", domain)
+        } else {
+            "https://api.github.com/copilot_internal/v2/token".to_string()
+        }
+    }
+
+    pub fn oauth_domain(&self) -> String {
+        if let Some(enterprise_uri) = &self.enterprise_uri {
+            Self::parse_domain(enterprise_uri)
+        } else {
+            "github.com".to_string()
+        }
+    }
+
+    pub fn api_url_from_endpoint(&self, endpoint: &str) -> String {
+        format!("{}/chat/completions", endpoint)
+    }
+
+    pub fn models_url_from_endpoint(&self, endpoint: &str) -> String {
+        format!("{}/models", endpoint)
+    }
+
+    fn parse_domain(enterprise_uri: &str) -> String {
+        let uri = enterprise_uri.trim_end_matches('/');
+
+        if let Some(domain) = uri.strip_prefix("https://") {
+            domain.split('/').next().unwrap_or(domain).to_string()
+        } else if let Some(domain) = uri.strip_prefix("http://") {
+            domain.split('/').next().unwrap_or(domain).to_string()
+        } else {
+            uri.split('/').next().unwrap_or(uri).to_string()
+        }
+    }
+}
+
+// Copilot's base model; defined by Microsoft in premium requests table
+// This will be moved to the front of the Copilot model list, and will be used for
+// 'fast' requests (e.g. title generation)
+// https://docs.github.com/en/copilot/managing-copilot/monitoring-usage-and-entitlements/about-premium-requests
+const DEFAULT_MODEL_ID: &str = "gpt-4.1";
 
 #[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
 #[serde(rename_all = "lowercase")]
@@ -25,132 +76,130 @@ pub enum Role {
     System,
 }
 
-#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
-#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)]
-pub enum Model {
-    #[default]
-    #[serde(alias = "gpt-4o", rename = "gpt-4o-2024-05-13")]
-    Gpt4o,
-    #[serde(alias = "gpt-4", rename = "gpt-4")]
-    Gpt4,
-    #[serde(alias = "gpt-4.1", rename = "gpt-4.1")]
-    Gpt4_1,
-    #[serde(alias = "gpt-3.5-turbo", rename = "gpt-3.5-turbo")]
-    Gpt3_5Turbo,
-    #[serde(alias = "o1", rename = "o1")]
-    O1,
-    #[serde(alias = "o1-mini", rename = "o3-mini")]
-    O3Mini,
-    #[serde(alias = "o3", rename = "o3")]
-    O3,
-    #[serde(alias = "o4-mini", rename = "o4-mini")]
-    O4Mini,
-    #[serde(alias = "claude-3-5-sonnet", rename = "claude-3.5-sonnet")]
-    Claude3_5Sonnet,
-    #[serde(alias = "claude-3-7-sonnet", rename = "claude-3.7-sonnet")]
-    Claude3_7Sonnet,
-    #[serde(
-        alias = "claude-3.7-sonnet-thought",
-        rename = "claude-3.7-sonnet-thought"
-    )]
-    Claude3_7SonnetThinking,
-    #[serde(alias = "gemini-2.0-flash", rename = "gemini-2.0-flash-001")]
-    Gemini20Flash,
-    #[serde(alias = "gemini-2.5-pro", rename = "gemini-2.5-pro")]
-    Gemini25Pro,
+#[derive(Deserialize)]
+struct ModelSchema {
+    #[serde(deserialize_with = "deserialize_models_skip_errors")]
+    data: Vec<Model>,
+}
+
+fn deserialize_models_skip_errors<'de, D>(deserializer: D) -> Result<Vec<Model>, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    let raw_values = Vec::<serde_json::Value>::deserialize(deserializer)?;
+    let models = raw_values
+        .into_iter()
+        .filter_map(|value| match serde_json::from_value::<Model>(value) {
+            Ok(model) => Some(model),
+            Err(err) => {
+                log::warn!("GitHub Copilot Chat model failed to deserialize: {:?}", err);
+                None
+            }
+        })
+        .collect();
+
+    Ok(models)
+}
+
+#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
+pub struct Model {
+    capabilities: ModelCapabilities,
+    id: String,
+    name: String,
+    policy: Option<ModelPolicy>,
+    vendor: ModelVendor,
+    model_picker_enabled: bool,
+}
+
+#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
+struct ModelCapabilities {
+    family: String,
+    #[serde(default)]
+    limits: ModelLimits,
+    supports: ModelSupportedFeatures,
+}
+
+#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
+struct ModelLimits {
+    #[serde(default)]
+    max_context_window_tokens: usize,
+    #[serde(default)]
+    max_output_tokens: usize,
+    #[serde(default)]
+    max_prompt_tokens: u64,
+}
+
+#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
+struct ModelPolicy {
+    state: String,
+}
+
+#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
+struct ModelSupportedFeatures {
+    #[serde(default)]
+    streaming: bool,
+    #[serde(default)]
+    tool_calls: bool,
+    #[serde(default)]
+    parallel_tool_calls: bool,
+    #[serde(default)]
+    vision: bool,
+}
+
+#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
+pub enum ModelVendor {
+    // Azure OpenAI should have no functional difference from OpenAI in Copilot Chat
+    #[serde(alias = "Azure OpenAI")]
+    OpenAI,
+    Google,
+    Anthropic,
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
+#[serde(tag = "type")]
+pub enum ChatMessagePart {
+    #[serde(rename = "text")]
+    Text { text: String },
+    #[serde(rename = "image_url")]
+    Image { image_url: ImageUrl },
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
+pub struct ImageUrl {
+    pub url: String,
 }
 
 impl Model {
-    pub fn default_fast() -> Self {
-        Self::Claude3_7Sonnet
+    pub fn uses_streaming(&self) -> bool {
+        self.capabilities.supports.streaming
     }
 
-    pub fn uses_streaming(&self) -> bool {
-        match self {
-            Self::Gpt4o
-            | Self::Gpt4
-            | Self::Gpt4_1
-            | Self::Gpt3_5Turbo
-            | Self::O3
-            | Self::O4Mini
-            | Self::Claude3_5Sonnet
-            | Self::Claude3_7Sonnet
-            | Self::Claude3_7SonnetThinking => true,
-            Self::O3Mini | Self::O1 | Self::Gemini20Flash | Self::Gemini25Pro => false,
-        }
+    pub fn id(&self) -> &str {
+        self.id.as_str()
     }
 
-    pub fn from_id(id: &str) -> Result<Self> {
-        match id {
-            "gpt-4o" => Ok(Self::Gpt4o),
-            "gpt-4" => Ok(Self::Gpt4),
-            "gpt-4.1" => Ok(Self::Gpt4_1),
-            "gpt-3.5-turbo" => Ok(Self::Gpt3_5Turbo),
-            "o1" => Ok(Self::O1),
-            "o3-mini" => Ok(Self::O3Mini),
-            "o3" => Ok(Self::O3),
-            "o4-mini" => Ok(Self::O4Mini),
-            "claude-3-5-sonnet" => Ok(Self::Claude3_5Sonnet),
-            "claude-3-7-sonnet" => Ok(Self::Claude3_7Sonnet),
-            "claude-3.7-sonnet-thought" => Ok(Self::Claude3_7SonnetThinking),
-            "gemini-2.0-flash-001" => Ok(Self::Gemini20Flash),
-            "gemini-2.5-pro" => Ok(Self::Gemini25Pro),
-            _ => Err(anyhow!("Invalid model id: {}", id)),
-        }
+    pub fn display_name(&self) -> &str {
+        self.name.as_str()
     }
 
-    pub fn id(&self) -> &'static str {
-        match self {
-            Self::Gpt3_5Turbo => "gpt-3.5-turbo",
-            Self::Gpt4 => "gpt-4",
-            Self::Gpt4_1 => "gpt-4.1",
-            Self::Gpt4o => "gpt-4o",
-            Self::O3Mini => "o3-mini",
-            Self::O1 => "o1",
-            Self::O3 => "o3",
-            Self::O4Mini => "o4-mini",
-            Self::Claude3_5Sonnet => "claude-3-5-sonnet",
-            Self::Claude3_7Sonnet => "claude-3-7-sonnet",
-            Self::Claude3_7SonnetThinking => "claude-3.7-sonnet-thought",
-            Self::Gemini20Flash => "gemini-2.0-flash-001",
-            Self::Gemini25Pro => "gemini-2.5-pro",
-        }
+    pub fn max_token_count(&self) -> u64 {
+        self.capabilities.limits.max_prompt_tokens
     }
 
-    pub fn display_name(&self) -> &'static str {
-        match self {
-            Self::Gpt3_5Turbo => "GPT-3.5",
-            Self::Gpt4 => "GPT-4",
-            Self::Gpt4_1 => "GPT-4.1",
-            Self::Gpt4o => "GPT-4o",
-            Self::O3Mini => "o3-mini",
-            Self::O1 => "o1",
-            Self::O3 => "o3",
-            Self::O4Mini => "o4-mini",
-            Self::Claude3_5Sonnet => "Claude 3.5 Sonnet",
-            Self::Claude3_7Sonnet => "Claude 3.7 Sonnet",
-            Self::Claude3_7SonnetThinking => "Claude 3.7 Sonnet Thinking",
-            Self::Gemini20Flash => "Gemini 2.0 Flash",
-            Self::Gemini25Pro => "Gemini 2.5 Pro",
-        }
+    pub fn supports_tools(&self) -> bool {
+        self.capabilities.supports.tool_calls
     }
 
-    pub fn max_token_count(&self) -> usize {
-        match self {
-            Self::Gpt4o => 64_000,
-            Self::Gpt4 => 32_768,
-            Self::Gpt4_1 => 128_000,
-            Self::Gpt3_5Turbo => 12_288,
-            Self::O3Mini => 64_000,
-            Self::O1 => 20_000,
-            Self::O3 => 128_000,
-            Self::O4Mini => 128_000,
-            Self::Claude3_5Sonnet => 200_000,
-            Self::Claude3_7Sonnet => 90_000,
-            Self::Claude3_7SonnetThinking => 90_000,
-            Self::Gemini20Flash => 128_000,
-            Self::Gemini25Pro => 128_000,
-        }
+    pub fn vendor(&self) -> ModelVendor {
+        self.vendor
+    }
+
+    pub fn supports_vision(&self) -> bool {
+        self.capabilities.supports.vision
+    }
+
+    pub fn supports_parallel_tool_calls(&self) -> bool {
+        self.capabilities.supports.parallel_tool_calls
     }
 }
 
@@ -160,7 +209,7 @@ pub struct Request {
     pub n: usize,
     pub stream: bool,
     pub temperature: f32,
-    pub model: Model,
+    pub model: String,
     pub messages: Vec<ChatMessage>,
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     pub tools: Vec<Tool>,
@@ -189,26 +238,55 @@ pub enum ToolChoice {
     None,
 }
 
-#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+#[derive(Serialize, Deserialize, Debug)]
 #[serde(tag = "role", rename_all = "lowercase")]
 pub enum ChatMessage {
     Assistant {
-        content: Option<String>,
+        content: ChatMessageContent,
         #[serde(default, skip_serializing_if = "Vec::is_empty")]
         tool_calls: Vec<ToolCall>,
     },
     User {
-        content: String,
+        content: ChatMessageContent,
     },
     System {
         content: String,
     },
     Tool {
-        content: String,
+        content: ChatMessageContent,
         tool_call_id: String,
     },
 }
 
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum ChatMessageContent {
+    Plain(String),
+    Multipart(Vec<ChatMessagePart>),
+}
+
+impl ChatMessageContent {
+    pub fn empty() -> Self {
+        ChatMessageContent::Multipart(vec![])
+    }
+}
+
+impl From<Vec<ChatMessagePart>> for ChatMessageContent {
+    fn from(mut parts: Vec<ChatMessagePart>) -> Self {
+        if let [ChatMessagePart::Text { text }] = parts.as_mut_slice() {
+            ChatMessageContent::Plain(std::mem::take(text))
+        } else {
+            ChatMessageContent::Multipart(parts)
+        }
+    }
+}
+
+impl From<String> for ChatMessageContent {
+    fn from(text: String) -> Self {
+        ChatMessageContent::Plain(text)
+    }
+}
+
 #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
 pub struct ToolCall {
     pub id: String,
@@ -232,8 +310,15 @@ pub struct FunctionContent {
 #[serde(tag = "type", rename_all = "snake_case")]
 pub struct ResponseEvent {
     pub choices: Vec<ResponseChoice>,
-    pub created: u64,
     pub id: String,
+    pub usage: Option<Usage>,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct Usage {
+    pub completion_tokens: u64,
+    pub prompt_tokens: u64,
+    pub total_tokens: u64,
 }
 
 #[derive(Debug, Deserialize)]
@@ -269,12 +354,19 @@ pub struct FunctionChunk {
 struct ApiTokenResponse {
     token: String,
     expires_at: i64,
+    endpoints: ApiTokenResponseEndpoints,
+}
+
+#[derive(Deserialize)]
+struct ApiTokenResponseEndpoints {
+    api: String,
 }
 
 #[derive(Clone)]
 struct ApiToken {
     api_key: String,
     expires_at: DateTime<chrono::Utc>,
+    api_endpoint: String,
 }
 
 impl ApiToken {
@@ -289,12 +381,13 @@ impl TryFrom<ApiTokenResponse> for ApiToken {
     type Error = anyhow::Error;
 
     fn try_from(response: ApiTokenResponse) -> Result<Self, Self::Error> {
-        let expires_at = DateTime::from_timestamp(response.expires_at, 0)
-            .ok_or_else(|| anyhow!("invalid expires_at"))?;
+        let expires_at =
+            DateTime::from_timestamp(response.expires_at, 0).context("invalid expires_at")?;
 
         Ok(Self {
             api_key: response.token,
             expires_at,
+            api_endpoint: response.endpoints.api,
         })
     }
 }
@@ -306,11 +399,18 @@ impl Global for GlobalCopilotChat {}
 pub struct CopilotChat {
     oauth_token: Option<String>,
     api_token: Option<ApiToken>,
+    configuration: CopilotChatConfiguration,
+    models: Option<Vec<Model>>,
     client: Arc<dyn HttpClient>,
 }
 
-pub fn init(fs: Arc<dyn Fs>, client: Arc<dyn HttpClient>, cx: &mut App) {
-    let copilot_chat = cx.new(|cx| CopilotChat::new(fs, client, cx));
+pub fn init(
+    fs: Arc<dyn Fs>,
+    client: Arc<dyn HttpClient>,
+    configuration: CopilotChatConfiguration,
+    cx: &mut App,
+) {
+    let copilot_chat = cx.new(|cx| CopilotChat::new(fs, client, configuration, cx));
     cx.set_global(GlobalCopilotChat(copilot_chat));
 }
 
@@ -318,12 +418,15 @@ pub fn copilot_chat_config_dir() -> &'static PathBuf {
     static COPILOT_CHAT_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
 
     COPILOT_CHAT_CONFIG_DIR.get_or_init(|| {
-        if cfg!(target_os = "windows") {
-            home_dir().join("AppData").join("Local")
+        let config_dir = if cfg!(target_os = "windows") {
+            dirs::data_local_dir().expect("failed to determine LocalAppData directory")
         } else {
-            home_dir().join(".config")
-        }
-        .join("github-copilot")
+            std::env::var("XDG_CONFIG_HOME")
+                .map(PathBuf::from)
+                .unwrap_or_else(|_| home_dir().join(".config"))
+        };
+
+        config_dir.join("github-copilot")
     })
 }
 
@@ -338,11 +441,16 @@ impl CopilotChat {
             .map(|model| model.0.clone())
     }
 
-    pub fn new(fs: Arc<dyn Fs>, client: Arc<dyn HttpClient>, cx: &App) -> Self {
+    fn new(
+        fs: Arc<dyn Fs>,
+        client: Arc<dyn HttpClient>,
+        configuration: CopilotChatConfiguration,
+        cx: &mut Context<Self>,
+    ) -> Self {
         let config_paths: HashSet<PathBuf> = copilot_chat_config_paths().into_iter().collect();
         let dir_path = copilot_chat_config_dir();
 
-        cx.spawn(async move |cx| {
+        cx.spawn(async move |this, cx| {
             let mut parent_watch_rx = watch_config_dir(
                 cx.background_executor(),
                 fs.clone(),
@@ -350,53 +458,101 @@ impl CopilotChat {
                 config_paths,
             );
             while let Some(contents) = parent_watch_rx.next().await {
-                let oauth_token = extract_oauth_token(contents);
-                cx.update(|cx| {
-                    if let Some(this) = Self::global(cx).as_ref() {
-                        this.update(cx, |this, cx| {
-                            this.oauth_token = oauth_token;
-                            cx.notify();
-                        });
-                    }
+                let oauth_domain =
+                    this.read_with(cx, |this, _| this.configuration.oauth_domain())?;
+                let oauth_token = extract_oauth_token(contents, &oauth_domain);
+
+                this.update(cx, |this, cx| {
+                    this.oauth_token = oauth_token.clone();
+                    cx.notify();
                 })?;
+
+                if oauth_token.is_some() {
+                    Self::update_models(&this, cx).await?;
+                }
             }
             anyhow::Ok(())
         })
         .detach_and_log_err(cx);
 
-        Self {
-            oauth_token: None,
+        let this = Self {
+            oauth_token: std::env::var(COPILOT_OAUTH_ENV_VAR).ok(),
             api_token: None,
+            models: None,
+            configuration,
             client,
+        };
+
+        if this.oauth_token.is_some() {
+            cx.spawn(async move |this, mut cx| Self::update_models(&this, &mut cx).await)
+                .detach_and_log_err(cx);
         }
+
+        this
+    }
+
+    async fn update_models(this: &WeakEntity<Self>, cx: &mut AsyncApp) -> Result<()> {
+        let (oauth_token, client, configuration) = this.read_with(cx, |this, _| {
+            (
+                this.oauth_token.clone(),
+                this.client.clone(),
+                this.configuration.clone(),
+            )
+        })?;
+
+        let oauth_token = oauth_token
+            .ok_or_else(|| anyhow!("OAuth token is missing while updating Copilot Chat models"))?;
+
+        let token_url = configuration.token_url();
+        let api_token = request_api_token(&oauth_token, token_url.into(), client.clone()).await?;
+
+        let models_url = configuration.models_url_from_endpoint(&api_token.api_endpoint);
+        let models =
+            get_models(models_url.into(), api_token.api_key.clone(), client.clone()).await?;
+
+        this.update(cx, |this, cx| {
+            this.api_token = Some(api_token);
+            this.models = Some(models);
+            cx.notify();
+        })?;
+        anyhow::Ok(())
     }
 
     pub fn is_authenticated(&self) -> bool {
         self.oauth_token.is_some()
     }
 
+    pub fn models(&self) -> Option<&[Model]> {
+        self.models.as_deref()
+    }
+
     pub async fn stream_completion(
         request: Request,
         mut cx: AsyncApp,
     ) -> Result<BoxStream<'static, Result<ResponseEvent>>> {
-        let Some(this) = cx.update(|cx| Self::global(cx)).ok().flatten() else {
-            return Err(anyhow!("Copilot chat is not enabled"));
-        };
+        let this = cx
+            .update(|cx| Self::global(cx))
+            .ok()
+            .flatten()
+            .context("Copilot chat is not enabled")?;
 
-        let (oauth_token, api_token, client) = this.read_with(&cx, |this, _| {
+        let (oauth_token, api_token, client, configuration) = this.read_with(&cx, |this, _| {
             (
                 this.oauth_token.clone(),
                 this.api_token.clone(),
                 this.client.clone(),
+                this.configuration.clone(),
             )
         })?;
 
-        let oauth_token = oauth_token.ok_or_else(|| anyhow!("No OAuth token available"))?;
+        let oauth_token = oauth_token.context("No OAuth token available")?;
 
         let token = match api_token {
             Some(api_token) if api_token.remaining_seconds() > 5 * 60 => api_token.clone(),
             _ => {
-                let token = request_api_token(&oauth_token, client.clone()).await?;
+                let token_url = configuration.token_url();
+                let token =
+                    request_api_token(&oauth_token, token_url.into(), client.clone()).await?;
                 this.update(&mut cx, |this, cx| {
                     this.api_token = Some(token.clone());
                     cx.notify();
@@ -405,14 +561,96 @@ impl CopilotChat {
             }
         };
 
-        stream_completion(client.clone(), token.api_key, request).await
+        let api_url = configuration.api_url_from_endpoint(&token.api_endpoint);
+        stream_completion(client.clone(), token.api_key, api_url.into(), request).await
+    }
+
+    pub fn set_configuration(
+        &mut self,
+        configuration: CopilotChatConfiguration,
+        cx: &mut Context<Self>,
+    ) {
+        let same_configuration = self.configuration == configuration;
+        self.configuration = configuration;
+        if !same_configuration {
+            self.api_token = None;
+            cx.spawn(async move |this, cx| {
+                Self::update_models(&this, cx).await?;
+                Ok::<_, anyhow::Error>(())
+            })
+            .detach();
+        }
     }
 }
 
-async fn request_api_token(oauth_token: &str, client: Arc<dyn HttpClient>) -> Result<ApiToken> {
+async fn get_models(
+    models_url: Arc<str>,
+    api_token: String,
+    client: Arc<dyn HttpClient>,
+) -> Result<Vec<Model>> {
+    let all_models = request_models(models_url, api_token, client).await?;
+
+    let mut models: Vec<Model> = all_models
+        .into_iter()
+        .filter(|model| {
+            model.model_picker_enabled
+                && model
+                    .policy
+                    .as_ref()
+                    .is_none_or(|policy| policy.state == "enabled")
+        })
+        .dedup_by(|a, b| a.capabilities.family == b.capabilities.family)
+        .collect();
+
+    if let Some(default_model_position) =
+        models.iter().position(|model| model.id == DEFAULT_MODEL_ID)
+    {
+        let default_model = models.remove(default_model_position);
+        models.insert(0, default_model);
+    }
+
+    Ok(models)
+}
+
+async fn request_models(
+    models_url: Arc<str>,
+    api_token: String,
+    client: Arc<dyn HttpClient>,
+) -> Result<Vec<Model>> {
     let request_builder = HttpRequest::builder()
         .method(Method::GET)
-        .uri(COPILOT_CHAT_AUTH_URL)
+        .uri(models_url.as_ref())
+        .header("Authorization", format!("Bearer {}", api_token))
+        .header("Content-Type", "application/json")
+        .header("Copilot-Integration-Id", "vscode-chat");
+
+    let request = request_builder.body(AsyncBody::empty())?;
+
+    let mut response = client.send(request).await?;
+
+    anyhow::ensure!(
+        response.status().is_success(),
+        "Failed to request models: {}",
+        response.status()
+    );
+    let mut body = Vec::new();
+    response.body_mut().read_to_end(&mut body).await?;
+
+    let body_str = std::str::from_utf8(&body)?;
+
+    let models = serde_json::from_str::<ModelSchema>(body_str)?.data;
+
+    Ok(models)
+}
+
+async fn request_api_token(
+    oauth_token: &str,
+    auth_url: Arc<str>,
+    client: Arc<dyn HttpClient>,
+) -> Result<ApiToken> {
+    let request_builder = HttpRequest::builder()
+        .method(Method::GET)
+        .uri(auth_url.as_ref())
         .header("Authorization", format!("token {}", oauth_token))
         .header("Accept", "application/json");
 
@@ -433,17 +671,16 @@ async fn request_api_token(oauth_token: &str, client: Arc<dyn HttpClient>) -> Re
         response.body_mut().read_to_end(&mut body).await?;
 
         let body_str = std::str::from_utf8(&body)?;
-
-        Err(anyhow!("Failed to request API token: {}", body_str))
+        anyhow::bail!("Failed to request API token: {body_str}");
     }
 }
 
-fn extract_oauth_token(contents: String) -> Option<String> {
+fn extract_oauth_token(contents: String, domain: &str) -> Option<String> {
     serde_json::from_str::<serde_json::Value>(&contents)
         .map(|v| {
             v.as_object().and_then(|obj| {
                 obj.iter().find_map(|(key, value)| {
-                    if key.starts_with("github.com") {
+                    if key.starts_with(domain) {
                         value["oauth_token"].as_str().map(|v| v.to_string())
                     } else {
                         None
@@ -458,11 +695,21 @@ fn extract_oauth_token(contents: String) -> Option<String> {
 async fn stream_completion(
     client: Arc<dyn HttpClient>,
     api_key: String,
+    completion_url: Arc<str>,
     request: Request,
 ) -> Result<BoxStream<'static, Result<ResponseEvent>>> {
-    let request_builder = HttpRequest::builder()
+    let is_vision_request = request.messages.iter().any(|message| match message {
+      ChatMessage::User { content }
+      | ChatMessage::Assistant { content, .. }
+      | ChatMessage::Tool { content, .. } => {
+          matches!(content, ChatMessageContent::Multipart(parts) if parts.iter().any(|part| matches!(part, ChatMessagePart::Image { .. })))
+      }
+      _ => false,
+  });
+
+    let mut request_builder = HttpRequest::builder()
         .method(Method::POST)
-        .uri(COPILOT_CHAT_COMPLETION_URL)
+        .uri(completion_url.as_ref())
         .header(
             "Editor-Version",
             format!(
@@ -474,6 +721,11 @@ async fn stream_completion(
         .header("Content-Type", "application/json")
         .header("Copilot-Integration-Id", "vscode-chat");
 
+    if is_vision_request {
+        request_builder =
+            request_builder.header("Copilot-Vision-Request", is_vision_request.to_string());
+    }
+
     let is_streaming = request.stream;
 
     let json = serde_json::to_string(&request)?;
@@ -484,11 +736,11 @@ async fn stream_completion(
         let mut body = Vec::new();
         response.body_mut().read_to_end(&mut body).await?;
         let body_str = std::str::from_utf8(&body)?;
-        return Err(anyhow!(
+        anyhow::bail!(
             "Failed to connect to API: {} {}",
             response.status(),
             body_str
-        ));
+        );
     }
 
     if is_streaming {
@@ -527,3 +779,82 @@ async fn stream_completion(
         Ok(futures::stream::once(async move { Ok(response) }).boxed())
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_resilient_model_schema_deserialize() {
+        let json = r#"{
+              "data": [
+                {
+                  "capabilities": {
+                    "family": "gpt-4",
+                    "limits": {
+                      "max_context_window_tokens": 32768,
+                      "max_output_tokens": 4096,
+                      "max_prompt_tokens": 32768
+                    },
+                    "object": "model_capabilities",
+                    "supports": { "streaming": true, "tool_calls": true },
+                    "tokenizer": "cl100k_base",
+                    "type": "chat"
+                  },
+                  "id": "gpt-4",
+                  "model_picker_enabled": false,
+                  "name": "GPT 4",
+                  "object": "model",
+                  "preview": false,
+                  "vendor": "Azure OpenAI",
+                  "version": "gpt-4-0613"
+                },
+                {
+                    "some-unknown-field": 123
+                },
+                {
+                  "capabilities": {
+                    "family": "claude-3.7-sonnet",
+                    "limits": {
+                      "max_context_window_tokens": 200000,
+                      "max_output_tokens": 16384,
+                      "max_prompt_tokens": 90000,
+                      "vision": {
+                        "max_prompt_image_size": 3145728,
+                        "max_prompt_images": 1,
+                        "supported_media_types": ["image/jpeg", "image/png", "image/webp"]
+                      }
+                    },
+                    "object": "model_capabilities",
+                    "supports": {
+                      "parallel_tool_calls": true,
+                      "streaming": true,
+                      "tool_calls": true,
+                      "vision": true
+                    },
+                    "tokenizer": "o200k_base",
+                    "type": "chat"
+                  },
+                  "id": "claude-3.7-sonnet",
+                  "model_picker_enabled": true,
+                  "name": "Claude 3.7 Sonnet",
+                  "object": "model",
+                  "policy": {
+                    "state": "enabled",
+                    "terms": "Enable access to the latest Claude 3.7 Sonnet model from Anthropic. [Learn more about how GitHub Copilot serves Claude 3.7 Sonnet](https://docs.github.com/copilot/using-github-copilot/using-claude-sonnet-in-github-copilot)."
+                  },
+                  "preview": false,
+                  "vendor": "Anthropic",
+                  "version": "claude-3.7-sonnet"
+                }
+              ],
+              "object": "list"
+            }"#;
+
+        let schema: ModelSchema = serde_json::from_str(&json).unwrap();
+
+        assert_eq!(schema.data.len(), 2);
+        assert_eq!(schema.data[0].id, "gpt-4");
+        assert_eq!(schema.data[1].id, "claude-3.7-sonnet");
+    }
+}

crates/copilot/src/copilot_completion_provider.rs 🔗

@@ -264,7 +264,8 @@ fn common_prefix<T1: Iterator<Item = char>, T2: Iterator<Item = char>>(a: T1, b:
 mod tests {
     use super::*;
     use editor::{
-        Editor, ExcerptRange, MultiBuffer, test::editor_lsp_test_context::EditorLspTestContext,
+        Editor, ExcerptRange, MultiBuffer, SelectionEffects,
+        test::editor_lsp_test_context::EditorLspTestContext,
     };
     use fs::FakeFs;
     use futures::StreamExt;
@@ -478,7 +479,7 @@ mod tests {
         // 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(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_ranges([Point::new(1, 2)..Point::new(1, 2)])
             });
         });
@@ -767,7 +768,7 @@ mod tests {
         );
         _ = editor.update(cx, |editor, window, cx| {
             // Ensure copilot suggestions are shown for the first excerpt.
-            editor.change_selections(None, window, cx, |s| {
+            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);
@@ -793,7 +794,7 @@ mod tests {
         );
         _ = editor.update(cx, |editor, window, cx| {
             // Move to another excerpt, ensuring the suggestion gets cleared.
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_ranges([Point::new(4, 5)..Point::new(4, 5)])
             });
             assert!(!editor.has_active_inline_completion());
@@ -1019,7 +1020,7 @@ mod tests {
             );
 
         _ = editor.update(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |selections| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
                 selections.select_ranges([Point::new(0, 0)..Point::new(0, 0)])
             });
             editor.refresh_inline_completion(true, false, window, cx);
@@ -1029,7 +1030,7 @@ mod tests {
         assert!(copilot_requests.try_next().is_err());
 
         _ = editor.update(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_ranges([Point::new(5, 0)..Point::new(5, 0)])
             });
             editor.refresh_inline_completion(true, false, window, cx);

crates/dap/Cargo.toml 🔗

@@ -47,13 +47,19 @@ settings.workspace = true
 smallvec.workspace = true
 smol.workspace = true
 task.workspace = true
+telemetry.workspace = true
 util.workspace = true
 workspace-hack.workspace = true
 
+[target.'cfg(not(windows))'.dependencies]
+libc.workspace = true
+
 [dev-dependencies]
 async-pipe.workspace = true
-env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
 task = { workspace = true, features = ["test-support"] }
+tree-sitter.workspace = true
+tree-sitter-go.workspace = true
 util = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/dap/src/adapters.rs 🔗

@@ -1,18 +1,18 @@
-use ::fs::Fs;
 use anyhow::{Context as _, Result, anyhow};
 use async_compression::futures::bufread::GzipDecoder;
 use async_tar::Archive;
 use async_trait::async_trait;
 use collections::HashMap;
-use dap_types::{StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest};
+pub use dap_types::{StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest};
+use fs::Fs;
 use futures::io::BufReader;
 use gpui::{AsyncApp, SharedString};
 pub use http_client::{HttpClient, github::latest_github_release};
-use language::LanguageToolchainStore;
+use language::{LanguageName, LanguageToolchainStore};
 use node_runtime::NodeRuntime;
 use serde::{Deserialize, Serialize};
 use settings::WorktreeId;
-use smol::{self, fs::File};
+use smol::fs::File;
 use std::{
     borrow::Borrow,
     ffi::OsStr,
@@ -22,7 +22,8 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-use task::{AttachRequest, DebugRequest, DebugScenario, LaunchRequest, TcpArgumentsTemplate};
+use task::{DebugScenario, TcpArgumentsTemplate, ZedDebugConfig};
+use util::archive::extract_zip;
 
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum DapStatus {
@@ -32,15 +33,17 @@ pub enum DapStatus {
     Failed { error: String },
 }
 
-#[async_trait(?Send)]
-pub trait DapDelegate {
+#[async_trait]
+pub trait DapDelegate: Send + Sync + 'static {
     fn worktree_id(&self) -> WorktreeId;
+    fn worktree_root_path(&self) -> &Path;
     fn http_client(&self) -> Arc<dyn HttpClient>;
     fn node_runtime(&self) -> NodeRuntime;
     fn toolchain_store(&self) -> Arc<dyn LanguageToolchainStore>;
     fn fs(&self) -> Arc<dyn Fs>;
     fn output_to_console(&self, msg: String);
-    fn which(&self, command: &OsStr) -> Option<PathBuf>;
+    async fn which(&self, command: &OsStr) -> Option<PathBuf>;
+    async fn read_text_file(&self, path: PathBuf) -> Result<String>;
     async fn shell_env(&self) -> collections::HashMap<String, String>;
 }
 
@@ -90,7 +93,7 @@ impl<'a> From<&'a str> for DebugAdapterName {
     }
 }
 
-#[derive(Debug, Clone, PartialEq)]
+#[derive(Debug, Clone, PartialEq, Serialize)]
 pub struct TcpArguments {
     pub host: Ipv4Addr,
     pub port: u16,
@@ -101,8 +104,8 @@ impl TcpArguments {
     pub fn from_proto(proto: proto::TcpHost) -> anyhow::Result<Self> {
         let host = TcpArgumentsTemplate::from_proto(proto)?;
         Ok(TcpArguments {
-            host: host.host.ok_or_else(|| anyhow!("missing host"))?,
-            port: host.port.ok_or_else(|| anyhow!("missing port"))?,
+            host: host.host.context("missing host")?,
+            port: host.port.context("missing port")?,
             timeout: host.timeout,
         })
     }
@@ -128,13 +131,12 @@ impl TcpArguments {
     derive(serde::Deserialize, serde::Serialize)
 )]
 pub struct DebugTaskDefinition {
+    /// The name of this debug task
     pub label: SharedString,
+    /// The debug adapter to use
     pub adapter: DebugAdapterName,
-    pub request: DebugRequest,
-    /// Additional initialization arguments to be sent on DAP initialization
-    pub initialize_args: Option<serde_json::Value>,
-    /// Whether to tell the debug adapter to stop on entry
-    pub stop_on_entry: Option<bool>,
+    /// The configuration to send to the debug adapter
+    pub config: serde_json::Value,
     /// Optional TCP connection information
     ///
     /// If provided, this will be used to connect to the debug adapter instead of
@@ -144,96 +146,42 @@ pub struct DebugTaskDefinition {
 }
 
 impl DebugTaskDefinition {
-    pub fn cwd(&self) -> Option<&Path> {
-        if let DebugRequest::Launch(config) = &self.request {
-            config.cwd.as_ref().map(Path::new)
-        } else {
-            None
-        }
-    }
-
     pub fn to_scenario(&self) -> DebugScenario {
         DebugScenario {
             label: self.label.clone(),
             adapter: self.adapter.clone().into(),
             build: None,
-            request: Some(self.request.clone()),
-            stop_on_entry: self.stop_on_entry,
             tcp_connection: self.tcp_connection.clone(),
-            initialize_args: self.initialize_args.clone(),
+            config: self.config.clone(),
         }
     }
 
     pub fn to_proto(&self) -> proto::DebugTaskDefinition {
         proto::DebugTaskDefinition {
-            adapter: self.adapter.to_string(),
-            request: Some(match &self.request {
-                DebugRequest::Launch(config) => {
-                    proto::debug_task_definition::Request::DebugLaunchRequest(
-                        proto::DebugLaunchRequest {
-                            program: config.program.clone(),
-                            cwd: config.cwd.as_ref().map(|c| c.to_string_lossy().to_string()),
-                            args: config.args.clone(),
-                            env: config
-                                .env
-                                .iter()
-                                .map(|(k, v)| (k.clone(), v.clone()))
-                                .collect(),
-                        },
-                    )
-                }
-                DebugRequest::Attach(attach_request) => {
-                    proto::debug_task_definition::Request::DebugAttachRequest(
-                        proto::DebugAttachRequest {
-                            process_id: attach_request.process_id.unwrap_or_default(),
-                        },
-                    )
-                }
-            }),
-            label: self.label.to_string(),
-            initialize_args: self.initialize_args.as_ref().map(|v| v.to_string()),
-            tcp_connection: self.tcp_connection.as_ref().map(|t| t.to_proto()),
-            stop_on_entry: self.stop_on_entry,
+            label: self.label.clone().into(),
+            config: self.config.to_string(),
+            tcp_connection: self.tcp_connection.clone().map(|v| v.to_proto()),
+            adapter: self.adapter.clone().0.into(),
         }
     }
 
     pub fn from_proto(proto: proto::DebugTaskDefinition) -> Result<Self> {
-        let request = proto
-            .request
-            .ok_or_else(|| anyhow::anyhow!("request is required"))?;
         Ok(Self {
             label: proto.label.into(),
-            initialize_args: proto.initialize_args.map(|v| v.into()),
+            config: serde_json::from_str(&proto.config)?,
             tcp_connection: proto
                 .tcp_connection
                 .map(TcpArgumentsTemplate::from_proto)
                 .transpose()?,
-            stop_on_entry: proto.stop_on_entry,
             adapter: DebugAdapterName(proto.adapter.into()),
-            request: match request {
-                proto::debug_task_definition::Request::DebugAttachRequest(config) => {
-                    DebugRequest::Attach(AttachRequest {
-                        process_id: Some(config.process_id),
-                    })
-                }
-
-                proto::debug_task_definition::Request::DebugLaunchRequest(config) => {
-                    DebugRequest::Launch(LaunchRequest {
-                        program: config.program,
-                        cwd: config.cwd.map(|cwd| cwd.into()),
-                        args: config.args,
-                        env: Default::default(),
-                    })
-                }
-            },
         })
     }
 }
 
 /// Created from a [DebugTaskDefinition], this struct describes how to spawn the debugger to create a previously-configured debug session.
-#[derive(Debug, Clone, PartialEq)]
+#[derive(Debug, Clone, PartialEq, Serialize)]
 pub struct DebugAdapterBinary {
-    pub command: String,
+    pub command: Option<String>,
     pub arguments: Vec<String>,
     pub envs: HashMap<String, String>,
     pub cwd: Option<PathBuf>,
@@ -344,13 +292,13 @@ pub async fn download_adapter_from_github(
         .get(&github_version.url, Default::default(), true)
         .await
         .context("Error downloading release")?;
-    if !response.status().is_success() {
-        Err(anyhow!(
-            "download failed with status {}",
-            response.status().to_string()
-        ))?;
-    }
+    anyhow::ensure!(
+        response.status().is_success(),
+        "download failed with status {}",
+        response.status().to_string()
+    );
 
+    delegate.output_to_console("Download complete".to_owned());
     match file_type {
         DownloadedFileType::GzipTar => {
             let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
@@ -359,17 +307,13 @@ pub async fn download_adapter_from_github(
         }
         DownloadedFileType::Zip | DownloadedFileType::Vsix => {
             let zip_path = version_path.with_extension("zip");
-
             let mut file = File::create(&zip_path).await?;
             futures::io::copy(response.body_mut(), &mut file).await?;
-
-            // we cannot check the status as some adapter include files with names that trigger `Illegal byte sequence`
-            util::command::new_smol_command("unzip")
-                .arg(&zip_path)
-                .arg("-d")
-                .arg(&version_path)
-                .output()
-                .await?;
+            let file = File::open(&zip_path).await?;
+            extract_zip(&version_path, file)
+                .await
+                // we cannot check the status as some adapter include files with names that trigger `Illegal byte sequence`
+                .ok();
 
             util::fs::remove_matching(&adapter_path, |entry| {
                 entry
@@ -389,35 +333,47 @@ pub async fn download_adapter_from_github(
     Ok(version_path)
 }
 
-pub async fn fetch_latest_adapter_version_from_github(
-    github_repo: GithubRepo,
-    delegate: &dyn DapDelegate,
-) -> Result<AdapterVersion> {
-    let release = latest_github_release(
-        &format!("{}/{}", github_repo.repo_owner, github_repo.repo_name),
-        false,
-        false,
-        delegate.http_client(),
-    )
-    .await?;
-
-    Ok(AdapterVersion {
-        tag_name: release.tag_name,
-        url: release.zipball_url,
-    })
-}
-
 #[async_trait(?Send)]
 pub trait DebugAdapter: 'static + Send + Sync {
     fn name(&self) -> DebugAdapterName;
 
+    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario>;
+
     async fn get_binary(
         &self,
-        delegate: &dyn DapDelegate,
+        delegate: &Arc<dyn DapDelegate>,
         config: &DebugTaskDefinition,
         user_installed_path: Option<PathBuf>,
+        user_args: Option<Vec<String>>,
         cx: &mut AsyncApp,
     ) -> Result<DebugAdapterBinary>;
+
+    /// Returns the language name of an adapter if it only supports one language
+    fn adapter_language_name(&self) -> Option<LanguageName> {
+        None
+    }
+
+    /// Extracts the kind (attach/launch) of debug configuration from the given JSON config.
+    /// This method should only return error when the kind cannot be determined for a given configuration;
+    /// in particular, it *should not* validate whether the request as a whole is valid, because that's best left to the debug adapter itself to decide.
+    async fn request_kind(
+        &self,
+        config: &serde_json::Value,
+    ) -> Result<StartDebuggingRequestArgumentsRequest> {
+        match config.get("request") {
+            Some(val) if val == "launch" => Ok(StartDebuggingRequestArgumentsRequest::Launch),
+            Some(val) if val == "attach" => Ok(StartDebuggingRequestArgumentsRequest::Attach),
+            _ => Err(anyhow!(
+                "missing or invalid `request` field in config. Expected 'launch' or 'attach'"
+            )),
+        }
+    }
+
+    fn dap_schema(&self) -> serde_json::Value;
+
+    fn label_for_child_session(&self, _args: &StartDebuggingRequestArguments) -> Option<String> {
+        None
+    }
 }
 
 #[cfg(any(test, feature = "test-support"))]
@@ -430,32 +386,6 @@ impl FakeAdapter {
     pub fn new() -> Self {
         Self {}
     }
-
-    fn request_args(&self, config: &DebugTaskDefinition) -> StartDebuggingRequestArguments {
-        use serde_json::json;
-        use task::DebugRequest;
-
-        let value = json!({
-            "request": match config.request {
-                DebugRequest::Launch(_) => "launch",
-                DebugRequest::Attach(_) => "attach",
-            },
-            "process_id": if let DebugRequest::Attach(attach_config) = &config.request {
-                attach_config.process_id
-            } else {
-                None
-            },
-            "raw_request": serde_json::to_value(config).unwrap()
-        });
-        let request = match config.request {
-            DebugRequest::Launch(_) => dap_types::StartDebuggingRequestArgumentsRequest::Launch,
-            DebugRequest::Attach(_) => dap_types::StartDebuggingRequestArgumentsRequest::Attach,
-        };
-        StartDebuggingRequestArguments {
-            configuration: value,
-            request,
-        }
-    }
 }
 
 #[cfg(any(test, feature = "test-support"))]
@@ -465,20 +395,59 @@ impl DebugAdapter for FakeAdapter {
         DebugAdapterName(Self::ADAPTER_NAME.into())
     }
 
+    fn dap_schema(&self) -> serde_json::Value {
+        serde_json::Value::Null
+    }
+
+    async fn request_kind(
+        &self,
+        config: &serde_json::Value,
+    ) -> Result<StartDebuggingRequestArgumentsRequest> {
+        let request = config.as_object().unwrap()["request"].as_str().unwrap();
+
+        let request = match request {
+            "launch" => dap_types::StartDebuggingRequestArgumentsRequest::Launch,
+            "attach" => dap_types::StartDebuggingRequestArgumentsRequest::Attach,
+            _ => unreachable!("Wrong fake adapter input for request field"),
+        };
+
+        Ok(request)
+    }
+
+    fn adapter_language_name(&self) -> Option<LanguageName> {
+        None
+    }
+
+    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
+        let config = serde_json::to_value(zed_scenario.request).unwrap();
+
+        Ok(DebugScenario {
+            adapter: zed_scenario.adapter,
+            label: zed_scenario.label,
+            build: None,
+            config,
+            tcp_connection: None,
+        })
+    }
+
     async fn get_binary(
         &self,
-        _: &dyn DapDelegate,
-        config: &DebugTaskDefinition,
+        _: &Arc<dyn DapDelegate>,
+        task_definition: &DebugTaskDefinition,
         _: Option<PathBuf>,
+        _: Option<Vec<String>>,
         _: &mut AsyncApp,
     ) -> Result<DebugAdapterBinary> {
         Ok(DebugAdapterBinary {
-            command: "command".into(),
+            command: Some("command".into()),
             arguments: vec![],
             connection: None,
             envs: HashMap::default(),
             cwd: None,
-            request_args: self.request_args(config),
+            request_args: StartDebuggingRequestArguments {
+                request: self.request_kind(&task_definition.config).await?,
+                configuration: task_definition.config.clone(),
+            },
         })
     }
 }

crates/dap/src/client.rs 🔗

@@ -2,26 +2,18 @@ use crate::{
     adapters::DebugAdapterBinary,
     transport::{IoKind, LogKind, TransportDelegate},
 };
-use anyhow::{Result, anyhow};
+use anyhow::Result;
 use dap_types::{
     messages::{Message, Response},
     requests::Request,
 };
-use futures::{FutureExt as _, channel::oneshot, select};
-use gpui::{AppContext, AsyncApp, BackgroundExecutor};
-use smol::channel::{Receiver, Sender};
+use futures::channel::oneshot;
+use gpui::AsyncApp;
 use std::{
     hash::Hash,
     sync::atomic::{AtomicU64, Ordering},
-    time::Duration,
 };
 
-#[cfg(any(test, feature = "test-support"))]
-const DAP_REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
-
-#[cfg(not(any(test, feature = "test-support")))]
-const DAP_REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
-
 #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
 #[repr(transparent)]
 pub struct SessionId(pub u32);
@@ -41,7 +33,6 @@ pub struct DebugAdapterClient {
     id: SessionId,
     sequence_count: AtomicU64,
     binary: DebugAdapterBinary,
-    executor: BackgroundExecutor,
     transport_delegate: TransportDelegate,
 }
 
@@ -52,98 +43,54 @@ impl DebugAdapterClient {
         id: SessionId,
         binary: DebugAdapterBinary,
         message_handler: DapMessageHandler,
-        cx: AsyncApp,
+        cx: &mut AsyncApp,
     ) -> Result<Self> {
-        let ((server_rx, server_tx), transport_delegate) =
-            TransportDelegate::start(&binary, cx.clone()).await?;
+        let transport_delegate = TransportDelegate::start(&binary, cx).await?;
         let this = Self {
             id,
             binary,
             transport_delegate,
             sequence_count: AtomicU64::new(1),
-            executor: cx.background_executor().clone(),
         };
-        log::info!("Successfully connected to debug adapter");
+        this.connect(message_handler, cx).await?;
 
-        let client_id = this.id;
+        Ok(this)
+    }
 
-        // start handling events/reverse requests
-        cx.background_spawn(Self::handle_receive_messages(
-            client_id,
-            server_rx,
-            server_tx.clone(),
-            message_handler,
-        ))
-        .detach();
+    pub fn should_reconnect_for_ssh(&self) -> bool {
+        self.transport_delegate.tcp_arguments().is_some()
+            && self.binary.command.as_deref() == Some("ssh")
+    }
 
-        Ok(this)
+    pub async fn connect(
+        &self,
+        message_handler: DapMessageHandler,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        self.transport_delegate.connect(message_handler, cx).await
     }
 
-    pub async fn reconnect(
+    pub async fn create_child_connection(
         &self,
         session_id: SessionId,
         binary: DebugAdapterBinary,
         message_handler: DapMessageHandler,
-        cx: AsyncApp,
+        cx: &mut AsyncApp,
     ) -> Result<Self> {
-        let binary = match self.transport_delegate.transport() {
-            crate::transport::Transport::Tcp(tcp_transport) => DebugAdapterBinary {
-                command: binary.command,
-                arguments: binary.arguments,
-                envs: binary.envs,
-                cwd: binary.cwd,
-                connection: Some(crate::adapters::TcpArguments {
-                    host: tcp_transport.host,
-                    port: tcp_transport.port,
-                    timeout: Some(tcp_transport.timeout),
-                }),
+        let binary = if let Some(connection) = self.transport_delegate.tcp_arguments() {
+            DebugAdapterBinary {
+                command: None,
+                arguments: Default::default(),
+                envs: Default::default(),
+                cwd: Default::default(),
+                connection: Some(connection),
                 request_args: binary.request_args,
-            },
-            _ => self.binary.clone(),
-        };
-
-        Self::start(session_id, binary, message_handler, cx).await
-    }
-
-    async fn handle_receive_messages(
-        client_id: SessionId,
-        server_rx: Receiver<Message>,
-        client_tx: Sender<Message>,
-        mut message_handler: DapMessageHandler,
-    ) -> Result<()> {
-        let result = loop {
-            let message = match server_rx.recv().await {
-                Ok(message) => message,
-                Err(e) => break Err(e.into()),
-            };
-            match message {
-                Message::Event(ev) => {
-                    log::debug!("Client {} received event `{}`", client_id.0, &ev);
-
-                    message_handler(Message::Event(ev))
-                }
-                Message::Request(req) => {
-                    log::debug!(
-                        "Client {} received reverse request `{}`",
-                        client_id.0,
-                        &req.command
-                    );
-
-                    message_handler(Message::Request(req))
-                }
-                Message::Response(response) => {
-                    log::debug!("Received response after request timeout: {:#?}", response);
-                }
             }
-
-            smol::future::yield_now().await;
+        } else {
+            self.binary.clone()
         };
 
-        drop(client_tx);
-
-        log::debug!("Handle receive messages dropped");
-
-        result
+        Self::start(session_id, binary, message_handler, cx).await
     }
 
     /// Send a request to an adapter and get a response back
@@ -161,8 +108,7 @@ impl DebugAdapterClient {
             arguments: Some(serialized_arguments),
         };
         self.transport_delegate
-            .add_pending_request(sequence_id, callback_tx)
-            .await;
+            .add_pending_request(sequence_id, callback_tx);
 
         log::debug!(
             "Client {} send `{}` request with sequence_id: {}",
@@ -173,40 +119,30 @@ impl DebugAdapterClient {
 
         self.send_message(Message::Request(request)).await?;
 
-        let mut timeout = self.executor.timer(DAP_REQUEST_TIMEOUT).fuse();
         let command = R::COMMAND.to_string();
 
-        select! {
-            response = callback_rx.fuse() => {
-                log::debug!(
-                    "Client {} received response for: `{}` sequence_id: {}",
-                    self.id.0,
-                    command,
-                    sequence_id
-                );
-
-                let response = response??;
-                match response.success {
-                    true => {
-                        if let Some(json) = response.body {
-                            Ok(serde_json::from_value(json)?)
-                        // Note: dap types configure themselves to return `None` when an empty object is received,
-                        // which then fails here...
-                        } else if let Ok(result) = serde_json::from_value(serde_json::Value::Object(Default::default())) {
-                            Ok(result)
-                        } else {
-                            Ok(serde_json::from_value(Default::default())?)
-                        }
-                    }
-                    false => Err(anyhow!("Request failed: {}", response.message.unwrap_or_default())),
+        let response = callback_rx.await??;
+        log::debug!(
+            "Client {} received response for: `{}` sequence_id: {}",
+            self.id.0,
+            command,
+            sequence_id
+        );
+        match response.success {
+            true => {
+                if let Some(json) = response.body {
+                    Ok(serde_json::from_value(json)?)
+                // Note: dap types configure themselves to return `None` when an empty object is received,
+                // which then fails here...
+                } else if let Ok(result) =
+                    serde_json::from_value(serde_json::Value::Object(Default::default()))
+                {
+                    Ok(result)
+                } else {
+                    Ok(serde_json::from_value(Default::default())?)
                 }
             }
-
-            _ = timeout => {
-                self.transport_delegate.cancel_pending_request(&sequence_id).await;
-                log::error!("Cancelled DAP request for {command:?} id {sequence_id} which took over {DAP_REQUEST_TIMEOUT:?}");
-                anyhow::bail!("DAP request timeout");
-            }
+            false => anyhow::bail!("Request failed: {}", response.message.unwrap_or_default()),
         }
     }
 
@@ -227,8 +163,9 @@ impl DebugAdapterClient {
         self.sequence_count.fetch_add(1, Ordering::Relaxed)
     }
 
-    pub async fn shutdown(&self) -> Result<()> {
-        self.transport_delegate.shutdown().await
+    pub fn kill(&self) {
+        log::debug!("Killing DAP process");
+        self.transport_delegate.transport.lock().kill();
     }
 
     pub fn has_adapter_logs(&self) -> bool {
@@ -237,7 +174,7 @@ impl DebugAdapterClient {
 
     pub fn add_log_handler<F>(&self, f: F, kind: LogKind)
     where
-        F: 'static + Send + FnMut(IoKind, &str),
+        F: 'static + Send + FnMut(IoKind, Option<&str>, &str),
     {
         self.transport_delegate.add_log_handler(f, kind);
     }
@@ -249,8 +186,11 @@ impl DebugAdapterClient {
             + Send
             + FnMut(u64, R::Arguments) -> Result<R::Response, dap_types::ErrorResponse>,
     {
-        let transport = self.transport_delegate.transport().as_fake();
-        transport.on_request::<R, F>(handler);
+        self.transport_delegate
+            .transport
+            .lock()
+            .as_fake()
+            .on_request::<R, F>(handler);
     }
 
     #[cfg(any(test, feature = "test-support"))]
@@ -269,8 +209,11 @@ impl DebugAdapterClient {
     where
         F: 'static + Send + Fn(Response),
     {
-        let transport = self.transport_delegate.transport().as_fake();
-        transport.on_response::<R, F>(handler).await;
+        self.transport_delegate
+            .transport
+            .lock()
+            .as_fake()
+            .on_response::<R, F>(handler);
     }
 
     #[cfg(any(test, feature = "test-support"))]
@@ -300,9 +243,7 @@ mod tests {
     };
 
     pub fn init_test(cx: &mut gpui::TestAppContext) {
-        if std::env::var("RUST_LOG").is_ok() {
-            env_logger::try_init().ok();
-        }
+        zlog::init_test();
 
         cx.update(|cx| {
             let settings = SettingsStore::test(cx);
@@ -318,7 +259,7 @@ mod tests {
         let client = DebugAdapterClient::start(
             crate::client::SessionId(1),
             DebugAdapterBinary {
-                command: "command".into(),
+                command: Some("command".into()),
                 arguments: Default::default(),
                 envs: Default::default(),
                 connection: None,
@@ -329,7 +270,7 @@ mod tests {
                 },
             },
             Box::new(|_| panic!("Did not expect to hit this code path")),
-            cx.to_async(),
+            &mut cx.to_async(),
         )
         .await
         .unwrap();
@@ -375,8 +316,6 @@ mod tests {
             },
             response
         );
-
-        client.shutdown().await.unwrap();
     }
 
     #[gpui::test]
@@ -388,7 +327,7 @@ mod tests {
         let client = DebugAdapterClient::start(
             crate::client::SessionId(1),
             DebugAdapterBinary {
-                command: "command".into(),
+                command: Some("command".into()),
                 arguments: Default::default(),
                 envs: Default::default(),
                 connection: None,
@@ -411,7 +350,7 @@ mod tests {
                     );
                 }
             }),
-            cx.to_async(),
+            &mut cx.to_async(),
         )
         .await
         .unwrap();
@@ -428,8 +367,6 @@ mod tests {
             called_event_handler.load(std::sync::atomic::Ordering::SeqCst),
             "Event handler was not called"
         );
-
-        client.shutdown().await.unwrap();
     }
 
     #[gpui::test]
@@ -441,7 +378,7 @@ mod tests {
         let client = DebugAdapterClient::start(
             crate::client::SessionId(1),
             DebugAdapterBinary {
-                command: "command".into(),
+                command: Some("command".into()),
                 arguments: Default::default(),
                 envs: Default::default(),
                 connection: None,
@@ -469,7 +406,7 @@ mod tests {
                     );
                 }
             }),
-            cx.to_async(),
+            &mut cx.to_async(),
         )
         .await
         .unwrap();
@@ -493,7 +430,5 @@ mod tests {
             called_event_handler.load(std::sync::atomic::Ordering::SeqCst),
             "Event handler was not called"
         );
-
-        client.shutdown().await.unwrap();
     }
 }

crates/dap/src/dap.rs 🔗

@@ -6,8 +6,14 @@ pub mod proto_conversions;
 mod registry;
 pub mod transport;
 
+use std::net::Ipv4Addr;
+
 pub use dap_types::*;
+use debugger_settings::DebuggerSettings;
+use gpui::App;
 pub use registry::{DapLocator, DapRegistry};
+use serde::Serialize;
+use settings::Settings;
 pub use task::DebugRequest;
 
 pub type ScopeId = u64;
@@ -16,3 +22,55 @@ pub type StackFrameId = u64;
 
 #[cfg(any(test, feature = "test-support"))]
 pub use adapters::FakeAdapter;
+use task::{DebugScenario, TcpArgumentsTemplate};
+
+pub async fn configure_tcp_connection(
+    tcp_connection: TcpArgumentsTemplate,
+) -> anyhow::Result<(Ipv4Addr, u16, Option<u64>)> {
+    let host = tcp_connection.host();
+    let timeout = tcp_connection.timeout;
+
+    let port = if let Some(port) = tcp_connection.port {
+        port
+    } else {
+        transport::TcpTransport::port(&tcp_connection).await?
+    };
+
+    Ok((host, port, timeout))
+}
+
+#[derive(Clone, Copy, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum TelemetrySpawnLocation {
+    Gutter,
+    ScenarioList,
+    Custom,
+}
+
+pub fn send_telemetry(scenario: &DebugScenario, location: TelemetrySpawnLocation, cx: &App) {
+    let Some(adapter) = cx.global::<DapRegistry>().adapter(&scenario.adapter) else {
+        return;
+    };
+    let dock = DebuggerSettings::get_global(cx).dock;
+    let config = scenario.config.clone();
+    let with_build_task = scenario.build.is_some();
+    let adapter_name = scenario.adapter.clone();
+    cx.spawn(async move |_| {
+        let kind = adapter
+            .request_kind(&config)
+            .await
+            .ok()
+            .map(serde_json::to_value)
+            .and_then(Result::ok);
+
+        telemetry::event!(
+            "Debugger Session Started",
+            spawn_location = location,
+            with_build_task = with_build_task,
+            kind = kind,
+            adapter = adapter_name,
+            dock_position = dock,
+        );
+    })
+    .detach();
+}

crates/dap/src/inline_value.rs 🔗

@@ -1,5 +1,3 @@
-use std::collections::{HashMap, HashSet};
-
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub enum VariableLookupKind {
     Variable,
@@ -20,258 +18,3 @@ pub struct InlineValueLocation {
     pub row: usize,
     pub column: usize,
 }
-
-/// A trait for providing inline values for debugging purposes.
-///
-/// Implementors of this trait are responsible for analyzing a given node in the
-/// source code and extracting variable information, including their names,
-/// scopes, and positions. This information is used to display inline values
-/// during debugging sessions. Implementors must also handle variable scoping
-/// themselves by traversing the syntax tree upwards to determine whether a
-/// variable is local or global.
-pub trait InlineValueProvider {
-    /// Provides a list of inline value locations based on the given node and source code.
-    ///
-    /// # Parameters
-    /// - `node`: The root node of the active debug line. Implementors should traverse
-    ///   upwards from this node to gather variable information and determine their scope.
-    /// - `source`: The source code as a string slice, used to extract variable names.
-    /// - `max_row`: The maximum row to consider when collecting variables. Variables
-    ///   declared beyond this row should be ignored.
-    ///
-    /// # Returns
-    /// A vector of `InlineValueLocation` instances, each representing a variable's
-    /// name, scope, and the position of the inline value should be shown.
-    fn provide(
-        &self,
-        node: language::Node,
-        source: &str,
-        max_row: usize,
-    ) -> Vec<InlineValueLocation>;
-}
-
-pub struct RustInlineValueProvider;
-
-impl InlineValueProvider for RustInlineValueProvider {
-    fn provide(
-        &self,
-        mut node: language::Node,
-        source: &str,
-        max_row: usize,
-    ) -> Vec<InlineValueLocation> {
-        let mut variables = Vec::new();
-        let mut variable_names = HashSet::new();
-        let mut scope = VariableScope::Local;
-
-        loop {
-            let mut variable_names_in_scope = HashMap::new();
-            for child in node.named_children(&mut node.walk()) {
-                if child.start_position().row >= max_row {
-                    break;
-                }
-
-                if scope == VariableScope::Local && child.kind() == "let_declaration" {
-                    if let Some(identifier) = child.child_by_field_name("pattern") {
-                        let variable_name = source[identifier.byte_range()].to_string();
-
-                        if variable_names.contains(&variable_name) {
-                            continue;
-                        }
-
-                        if let Some(index) = variable_names_in_scope.get(&variable_name) {
-                            variables.remove(*index);
-                        }
-
-                        variable_names_in_scope.insert(variable_name.clone(), variables.len());
-                        variables.push(InlineValueLocation {
-                            variable_name,
-                            scope: VariableScope::Local,
-                            lookup: VariableLookupKind::Variable,
-                            row: identifier.end_position().row,
-                            column: identifier.end_position().column,
-                        });
-                    }
-                } else if child.kind() == "static_item" {
-                    if let Some(name) = child.child_by_field_name("name") {
-                        let variable_name = source[name.byte_range()].to_string();
-                        variables.push(InlineValueLocation {
-                            variable_name,
-                            scope: scope.clone(),
-                            lookup: VariableLookupKind::Expression,
-                            row: name.end_position().row,
-                            column: name.end_position().column,
-                        });
-                    }
-                }
-            }
-
-            variable_names.extend(variable_names_in_scope.keys().cloned());
-
-            if matches!(node.kind(), "function_item" | "closure_expression") {
-                scope = VariableScope::Global;
-            }
-
-            if let Some(parent) = node.parent() {
-                node = parent;
-            } else {
-                break;
-            }
-        }
-
-        variables
-    }
-}
-
-pub struct PythonInlineValueProvider;
-
-impl InlineValueProvider for PythonInlineValueProvider {
-    fn provide(
-        &self,
-        mut node: language::Node,
-        source: &str,
-        max_row: usize,
-    ) -> Vec<InlineValueLocation> {
-        let mut variables = Vec::new();
-        let mut variable_names = HashSet::new();
-        let mut scope = VariableScope::Local;
-
-        loop {
-            let mut variable_names_in_scope = HashMap::new();
-            for child in node.named_children(&mut node.walk()) {
-                if child.start_position().row >= max_row {
-                    break;
-                }
-
-                if scope == VariableScope::Local {
-                    match child.kind() {
-                        "expression_statement" => {
-                            if let Some(expr) = child.child(0) {
-                                if expr.kind() == "assignment" {
-                                    if let Some(param) = expr.child(0) {
-                                        let param_identifier = if param.kind() == "identifier" {
-                                            Some(param)
-                                        } else if param.kind() == "typed_parameter" {
-                                            param.child(0)
-                                        } else {
-                                            None
-                                        };
-
-                                        if let Some(identifier) = param_identifier {
-                                            if identifier.kind() == "identifier" {
-                                                let variable_name =
-                                                    source[identifier.byte_range()].to_string();
-
-                                                if variable_names.contains(&variable_name) {
-                                                    continue;
-                                                }
-
-                                                if let Some(index) =
-                                                    variable_names_in_scope.get(&variable_name)
-                                                {
-                                                    variables.remove(*index);
-                                                }
-
-                                                variable_names_in_scope
-                                                    .insert(variable_name.clone(), variables.len());
-                                                variables.push(InlineValueLocation {
-                                                    variable_name,
-                                                    scope: VariableScope::Local,
-                                                    lookup: VariableLookupKind::Variable,
-                                                    row: identifier.end_position().row,
-                                                    column: identifier.end_position().column,
-                                                });
-                                            }
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                        "function_definition" => {
-                            if let Some(params) = child.child_by_field_name("parameters") {
-                                for param in params.named_children(&mut params.walk()) {
-                                    let param_identifier = if param.kind() == "identifier" {
-                                        Some(param)
-                                    } else if param.kind() == "typed_parameter" {
-                                        param.child(0)
-                                    } else {
-                                        None
-                                    };
-
-                                    if let Some(identifier) = param_identifier {
-                                        if identifier.kind() == "identifier" {
-                                            let variable_name =
-                                                source[identifier.byte_range()].to_string();
-
-                                            if variable_names.contains(&variable_name) {
-                                                continue;
-                                            }
-
-                                            if let Some(index) =
-                                                variable_names_in_scope.get(&variable_name)
-                                            {
-                                                variables.remove(*index);
-                                            }
-
-                                            variable_names_in_scope
-                                                .insert(variable_name.clone(), variables.len());
-                                            variables.push(InlineValueLocation {
-                                                variable_name,
-                                                scope: VariableScope::Local,
-                                                lookup: VariableLookupKind::Variable,
-                                                row: identifier.end_position().row,
-                                                column: identifier.end_position().column,
-                                            });
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                        "for_statement" => {
-                            if let Some(target) = child.child_by_field_name("left") {
-                                if target.kind() == "identifier" {
-                                    let variable_name = source[target.byte_range()].to_string();
-
-                                    if variable_names.contains(&variable_name) {
-                                        continue;
-                                    }
-
-                                    if let Some(index) = variable_names_in_scope.get(&variable_name)
-                                    {
-                                        variables.remove(*index);
-                                    }
-
-                                    variable_names_in_scope
-                                        .insert(variable_name.clone(), variables.len());
-                                    variables.push(InlineValueLocation {
-                                        variable_name,
-                                        scope: VariableScope::Local,
-                                        lookup: VariableLookupKind::Variable,
-                                        row: target.end_position().row,
-                                        column: target.end_position().column,
-                                    });
-                                }
-                            }
-                        }
-                        _ => {}
-                    }
-                }
-            }
-
-            variable_names.extend(variable_names_in_scope.keys().cloned());
-
-            if matches!(node.kind(), "function_definition" | "module")
-                && node.range().end_point.row < max_row
-            {
-                scope = VariableScope::Global;
-            }
-
-            if let Some(parent) = node.parent() {
-                node = parent;
-            } else {
-                break;
-            }
-        }
-
-        variables
-    }
-}

crates/dap/src/proto_conversions.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use client::proto::{
     self, DapChecksum, DapChecksumAlgorithm, DapEvaluateContext, DapModule, DapScope,
     DapScopePresentationHint, DapSource, DapSourcePresentationHint, DapStackFrame, DapVariable,
@@ -311,9 +311,9 @@ impl ProtoConversion for dap_types::Module {
     fn from_proto(payload: Self::ProtoType) -> Result<Self> {
         let id = match payload
             .id
-            .ok_or(anyhow!("All DapModule proto messages must have an id"))?
+            .context("All DapModule proto messages must have an id")?
             .id
-            .ok_or(anyhow!("All DapModuleID proto messages must have an id"))?
+            .context("All DapModuleID proto messages must have an id")?
         {
             proto::dap_module_id::Id::String(string) => dap_types::ModuleId::String(string),
             proto::dap_module_id::Id::Number(num) => dap_types::ModuleId::Number(num),

crates/dap/src/registry.rs 🔗

@@ -2,25 +2,25 @@ use anyhow::Result;
 use async_trait::async_trait;
 use collections::FxHashMap;
 use gpui::{App, Global, SharedString};
+use language::LanguageName;
 use parking_lot::RwLock;
-use task::{DebugRequest, DebugScenario, SpawnInTerminal, TaskTemplate};
-
-use crate::{
-    adapters::{DebugAdapter, DebugAdapterName},
-    inline_value::InlineValueProvider,
+use task::{
+    AdapterSchema, AdapterSchemas, DebugRequest, DebugScenario, SpawnInTerminal, TaskTemplate,
 };
+
+use crate::adapters::{DebugAdapter, DebugAdapterName};
 use std::{collections::BTreeMap, sync::Arc};
 
-/// Given a user build configuration, locator creates a fill-in debug target ([DebugRequest]) on behalf of the user.
+/// Given a user build configuration, locator creates a fill-in debug target ([DebugScenario]) on behalf of the user.
 #[async_trait]
 pub trait DapLocator: Send + Sync {
     fn name(&self) -> SharedString;
     /// Determines whether this locator can generate debug target for given task.
-    fn create_scenario(
+    async fn create_scenario(
         &self,
         build_config: &TaskTemplate,
         resolved_label: &str,
-        adapter: DebugAdapterName,
+        adapter: &DebugAdapterName,
     ) -> Option<DebugScenario>;
 
     async fn run(&self, build_config: SpawnInTerminal) -> Result<DebugRequest>;
@@ -30,7 +30,6 @@ pub trait DapLocator: Send + Sync {
 struct DapRegistryState {
     adapters: BTreeMap<DebugAdapterName, Arc<dyn DebugAdapter>>,
     locators: FxHashMap<SharedString, Arc<dyn DapLocator>>,
-    inline_value_providers: FxHashMap<String, Arc<dyn InlineValueProvider>>,
 }
 
 #[derive(Clone, Default)]
@@ -40,47 +39,43 @@ impl Global for DapRegistry {}
 
 impl DapRegistry {
     pub fn global(cx: &mut App) -> &mut Self {
-        let ret = cx.default_global::<Self>();
-
-        #[cfg(any(test, feature = "test-support"))]
-        if ret.adapter(crate::FakeAdapter::ADAPTER_NAME).is_none() {
-            ret.add_adapter(Arc::new(crate::FakeAdapter::new()));
-        }
-
-        ret
+        cx.default_global::<Self>()
     }
 
     pub fn add_adapter(&self, adapter: Arc<dyn DebugAdapter>) {
         let name = adapter.name();
         let _previous_value = self.0.write().adapters.insert(name, adapter);
-        debug_assert!(
-            _previous_value.is_none(),
-            "Attempted to insert a new debug adapter when one is already registered"
-        );
     }
-
     pub fn add_locator(&self, locator: Arc<dyn DapLocator>) {
-        let _previous_value = self.0.write().locators.insert(locator.name(), locator);
-        debug_assert!(
-            _previous_value.is_none(),
-            "Attempted to insert a new debug locator when one is already registered"
-        );
+        self.0.write().locators.insert(locator.name(), locator);
     }
 
-    pub fn add_inline_value_provider(
-        &self,
-        language: String,
-        provider: Arc<dyn InlineValueProvider>,
-    ) {
-        let _previous_value = self
-            .0
-            .write()
-            .inline_value_providers
-            .insert(language, provider);
-        debug_assert!(
-            _previous_value.is_none(),
-            "Attempted to insert a new inline value provider when one is already registered"
-        );
+    pub fn remove_adapter(&self, name: &str) {
+        self.0.write().adapters.remove(name);
+    }
+
+    pub fn remove_locator(&self, locator: &str) {
+        self.0.write().locators.remove(locator);
+    }
+
+    pub fn adapter_language(&self, adapter_name: &str) -> Option<LanguageName> {
+        self.adapter(adapter_name)
+            .and_then(|adapter| adapter.adapter_language_name())
+    }
+
+    pub async fn adapters_schema(&self) -> task::AdapterSchemas {
+        let mut schemas = AdapterSchemas(vec![]);
+
+        let adapters = self.0.read().adapters.clone();
+
+        for (name, adapter) in adapters.into_iter() {
+            schemas.0.push(AdapterSchema {
+                adapter: name.into(),
+                schema: adapter.dap_schema(),
+            });
+        }
+
+        schemas
     }
 
     pub fn locators(&self) -> FxHashMap<SharedString, Arc<dyn DapLocator>> {
@@ -91,10 +86,6 @@ impl DapRegistry {
         self.0.read().adapters.get(name).cloned()
     }
 
-    pub fn inline_value_provider(&self, language: &str) -> Option<Arc<dyn InlineValueProvider>> {
-        self.0.read().inline_value_providers.get(language).cloned()
-    }
-
     pub fn enumerate_adapters(&self) -> Vec<DebugAdapterName> {
         self.0.read().adapters.keys().cloned().collect()
     }

crates/dap/src/transport.rs 🔗

@@ -1,18 +1,20 @@
-use anyhow::{Context, Result, anyhow, bail};
+use anyhow::{Context as _, Result, anyhow, bail};
+#[cfg(any(test, feature = "test-support"))]
+use async_pipe::{PipeReader, PipeWriter};
 use dap_types::{
     ErrorResponse,
     messages::{Message, Response},
 };
 use futures::{AsyncRead, AsyncReadExt as _, AsyncWrite, FutureExt as _, channel::oneshot, select};
-use gpui::AsyncApp;
+use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, Task};
+use parking_lot::Mutex;
+use proto::ErrorExt;
 use settings::Settings as _;
 use smallvec::SmallVec;
 use smol::{
     channel::{Receiver, Sender, unbounded},
     io::{AsyncBufReadExt as _, AsyncWriteExt, BufReader},
-    lock::Mutex,
     net::{TcpListener, TcpStream},
-    process::Child,
 };
 use std::{
     collections::HashMap,
@@ -22,11 +24,17 @@ use std::{
     time::Duration,
 };
 use task::TcpArgumentsTemplate;
-use util::{ResultExt as _, TryFutureExt};
+use util::ConnectionResult;
 
-use crate::{adapters::DebugAdapterBinary, debugger_settings::DebuggerSettings};
+use crate::{
+    adapters::{DebugAdapterBinary, TcpArguments},
+    client::DapMessageHandler,
+    debugger_settings::DebuggerSettings,
+};
 
-pub type IoHandler = Box<dyn Send + FnMut(IoKind, &str)>;
+pub(crate) type IoMessage = str;
+pub(crate) type Command = str;
+pub type IoHandler = Box<dyn Send + FnMut(IoKind, Option<&Command>, &IoMessage)>;
 
 #[derive(PartialEq, Eq, Clone, Copy)]
 pub enum LogKind {
@@ -34,257 +42,204 @@ pub enum LogKind {
     Rpc,
 }
 
+#[derive(Clone, Copy)]
 pub enum IoKind {
     StdIn,
     StdOut,
     StdErr,
 }
 
-pub struct TransportPipe {
-    input: Box<dyn AsyncWrite + Unpin + Send + 'static>,
-    output: Box<dyn AsyncRead + Unpin + Send + 'static>,
-    stdout: Option<Box<dyn AsyncRead + Unpin + Send + 'static>>,
-    stderr: Option<Box<dyn AsyncRead + Unpin + Send + 'static>>,
-}
-
-impl TransportPipe {
-    pub fn new(
-        input: Box<dyn AsyncWrite + Unpin + Send + 'static>,
-        output: Box<dyn AsyncRead + Unpin + Send + 'static>,
-        stdout: Option<Box<dyn AsyncRead + Unpin + Send + 'static>>,
-        stderr: Option<Box<dyn AsyncRead + Unpin + Send + 'static>>,
-    ) -> Self {
-        TransportPipe {
-            input,
-            output,
-            stdout,
-            stderr,
-        }
-    }
-}
-
 type Requests = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Response>>>>>;
-type LogHandlers = Arc<parking_lot::Mutex<SmallVec<[(LogKind, IoHandler); 2]>>>;
+type LogHandlers = Arc<Mutex<SmallVec<[(LogKind, IoHandler); 2]>>>;
 
-pub enum Transport {
-    Stdio(StdioTransport),
-    Tcp(TcpTransport),
+pub trait Transport: Send + Sync {
+    fn has_adapter_logs(&self) -> bool;
+    fn tcp_arguments(&self) -> Option<TcpArguments>;
+    fn connect(
+        &mut self,
+    ) -> Task<
+        Result<(
+            Box<dyn AsyncWrite + Unpin + Send + 'static>,
+            Box<dyn AsyncRead + Unpin + Send + 'static>,
+        )>,
+    >;
+    fn kill(&mut self);
     #[cfg(any(test, feature = "test-support"))]
-    Fake(FakeTransport),
-}
-
-impl Transport {
-    async fn start(binary: &DebugAdapterBinary, cx: AsyncApp) -> Result<(TransportPipe, Self)> {
-        #[cfg(any(test, feature = "test-support"))]
-        if cfg!(any(test, feature = "test-support")) {
-            return FakeTransport::start(cx)
-                .await
-                .map(|(transports, fake)| (transports, Self::Fake(fake)));
-        }
-
-        if binary.connection.is_some() {
-            TcpTransport::start(binary, cx)
-                .await
-                .map(|(transports, tcp)| (transports, Self::Tcp(tcp)))
-        } else {
-            StdioTransport::start(binary, cx)
-                .await
-                .map(|(transports, stdio)| (transports, Self::Stdio(stdio)))
-        }
-    }
-
-    fn has_adapter_logs(&self) -> bool {
-        match self {
-            Transport::Stdio(stdio_transport) => stdio_transport.has_adapter_logs(),
-            Transport::Tcp(tcp_transport) => tcp_transport.has_adapter_logs(),
-            #[cfg(any(test, feature = "test-support"))]
-            Transport::Fake(fake_transport) => fake_transport.has_adapter_logs(),
-        }
+    fn as_fake(&self) -> &FakeTransport {
+        unreachable!()
     }
+}
 
-    async fn kill(&self) -> Result<()> {
-        match self {
-            Transport::Stdio(stdio_transport) => stdio_transport.kill().await,
-            Transport::Tcp(tcp_transport) => tcp_transport.kill().await,
-            #[cfg(any(test, feature = "test-support"))]
-            Transport::Fake(fake_transport) => fake_transport.kill().await,
-        }
+async fn start(
+    binary: &DebugAdapterBinary,
+    log_handlers: LogHandlers,
+    cx: &mut AsyncApp,
+) -> Result<Box<dyn Transport>> {
+    #[cfg(any(test, feature = "test-support"))]
+    if cfg!(any(test, feature = "test-support")) {
+        return Ok(Box::new(FakeTransport::start(cx).await?));
     }
 
-    #[cfg(any(test, feature = "test-support"))]
-    pub(crate) fn as_fake(&self) -> &FakeTransport {
-        match self {
-            Transport::Fake(fake_transport) => fake_transport,
-            _ => panic!("Not a fake transport layer"),
-        }
+    if binary.connection.is_some() {
+        Ok(Box::new(
+            TcpTransport::start(binary, log_handlers, cx).await?,
+        ))
+    } else {
+        Ok(Box::new(
+            StdioTransport::start(binary, log_handlers, cx).await?,
+        ))
     }
 }
 
 pub(crate) struct TransportDelegate {
     log_handlers: LogHandlers,
-    current_requests: Requests,
-    pending_requests: Requests,
-    transport: Transport,
-    server_tx: Arc<Mutex<Option<Sender<Message>>>>,
-    _tasks: Vec<gpui::Task<Option<()>>>,
+    pub(crate) pending_requests: Requests,
+    pub(crate) transport: Mutex<Box<dyn Transport>>,
+    pub(crate) server_tx: smol::lock::Mutex<Option<Sender<Message>>>,
+    tasks: Mutex<Vec<Task<()>>>,
+}
+
+impl Drop for TransportDelegate {
+    fn drop(&mut self) {
+        self.transport.lock().kill()
+    }
 }
 
 impl TransportDelegate {
-    pub(crate) async fn start(
-        binary: &DebugAdapterBinary,
-        cx: AsyncApp,
-    ) -> Result<((Receiver<Message>, Sender<Message>), Self)> {
-        let (transport_pipes, transport) = Transport::start(binary, cx.clone()).await?;
-        let mut this = Self {
-            transport,
+    pub(crate) async fn start(binary: &DebugAdapterBinary, cx: &mut AsyncApp) -> Result<Self> {
+        let log_handlers: LogHandlers = Default::default();
+        let transport = start(binary, log_handlers.clone(), cx).await?;
+        Ok(Self {
+            transport: Mutex::new(transport),
+            log_handlers,
             server_tx: Default::default(),
-            log_handlers: Default::default(),
-            current_requests: Default::default(),
             pending_requests: Default::default(),
-            _tasks: Default::default(),
-        };
-        let messages = this.start_handlers(transport_pipes, cx).await?;
-        Ok((messages, this))
+            tasks: Default::default(),
+        })
     }
 
-    async fn start_handlers(
-        &mut self,
-        mut params: TransportPipe,
-        cx: AsyncApp,
-    ) -> Result<(Receiver<Message>, Sender<Message>)> {
-        let (client_tx, server_rx) = unbounded::<Message>();
+    pub async fn connect(
+        &self,
+        message_handler: DapMessageHandler,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
         let (server_tx, client_rx) = unbounded::<Message>();
+        self.tasks.lock().clear();
 
         let log_dap_communications =
             cx.update(|cx| DebuggerSettings::get_global(cx).log_dap_communications)
                 .with_context(|| "Failed to get Debugger Setting log dap communications error in transport::start_handlers. Defaulting to false")
                 .unwrap_or(false);
 
+        let connect = self.transport.lock().connect();
+        let (input, output) = connect.await?;
+
         let log_handler = if log_dap_communications {
             Some(self.log_handlers.clone())
         } else {
             None
         };
 
-        cx.update(|cx| {
-            if let Some(stdout) = params.stdout.take() {
-                self._tasks.push(
-                    cx.background_executor()
-                        .spawn(Self::handle_adapter_log(stdout, log_handler.clone()).log_err()),
-                );
-            }
-
-            self._tasks.push(
-                cx.background_executor().spawn(
-                    Self::handle_output(
-                        params.output,
-                        client_tx,
-                        self.pending_requests.clone(),
-                        log_handler.clone(),
-                    )
-                    .log_err(),
-                ),
-            );
-
-            if let Some(stderr) = params.stderr.take() {
-                self._tasks.push(
-                    cx.background_executor()
-                        .spawn(Self::handle_error(stderr, self.log_handlers.clone()).log_err()),
-                );
-            }
+        let pending_requests = self.pending_requests.clone();
+        let output_log_handler = log_handler.clone();
+        {
+            let mut tasks = self.tasks.lock();
+            tasks.push(cx.background_spawn(async move {
+                match Self::recv_from_server(
+                    output,
+                    message_handler,
+                    pending_requests.clone(),
+                    output_log_handler,
+                )
+                .await
+                {
+                    Ok(()) => {
+                        pending_requests.lock().drain().for_each(|(_, request)| {
+                            request
+                                .send(Err(anyhow!("debugger shutdown unexpectedly")))
+                                .ok();
+                        });
+                    }
+                    Err(e) => {
+                        pending_requests.lock().drain().for_each(|(_, request)| {
+                            request.send(Err(e.cloned())).ok();
+                        });
+                    }
+                }
+            }));
 
-            self._tasks.push(
-                cx.background_executor().spawn(
-                    Self::handle_input(
-                        params.input,
-                        client_rx,
-                        self.current_requests.clone(),
-                        self.pending_requests.clone(),
-                        log_handler.clone(),
-                    )
-                    .log_err(),
-                ),
-            );
-        })?;
+            tasks.push(cx.background_spawn(async move {
+                match Self::send_to_server(input, client_rx, log_handler).await {
+                    Ok(()) => {}
+                    Err(e) => log::error!("Error handling debugger input: {e}"),
+                }
+            }));
+        }
 
         {
             let mut lock = self.server_tx.lock().await;
             *lock = Some(server_tx.clone());
         }
 
-        Ok((server_rx, server_tx))
+        Ok(())
     }
 
-    pub(crate) async fn add_pending_request(
+    pub(crate) fn tcp_arguments(&self) -> Option<TcpArguments> {
+        self.transport.lock().tcp_arguments()
+    }
+
+    pub(crate) fn add_pending_request(
         &self,
         sequence_id: u64,
         request: oneshot::Sender<Result<Response>>,
     ) {
-        let mut pending_requests = self.pending_requests.lock().await;
+        let mut pending_requests = self.pending_requests.lock();
         pending_requests.insert(sequence_id, request);
     }
 
-    pub(crate) async fn cancel_pending_request(&self, sequence_id: &u64) {
-        let mut pending_requests = self.pending_requests.lock().await;
-        pending_requests.remove(sequence_id);
-    }
-
     pub(crate) async fn send_message(&self, message: Message) -> Result<()> {
         if let Some(server_tx) = self.server_tx.lock().await.as_ref() {
-            server_tx
-                .send(message)
-                .await
-                .map_err(|e| anyhow!("Failed to send message: {}", e))
+            server_tx.send(message).await.context("sending message")
         } else {
-            Err(anyhow!("Server tx already dropped"))
+            anyhow::bail!("Server tx already dropped")
         }
     }
 
-    async fn handle_adapter_log<Stdout>(
-        stdout: Stdout,
-        log_handlers: Option<LogHandlers>,
-    ) -> Result<()>
-    where
-        Stdout: AsyncRead + Unpin + Send + 'static,
-    {
+    async fn handle_adapter_log(
+        stdout: impl AsyncRead + Unpin + Send + 'static,
+        iokind: IoKind,
+        log_handlers: LogHandlers,
+    ) {
         let mut reader = BufReader::new(stdout);
         let mut line = String::new();
 
-        let result = loop {
+        loop {
             line.truncate(0);
 
-            let bytes_read = match reader.read_line(&mut line).await {
-                Ok(bytes_read) => bytes_read,
-                Err(e) => break Err(e.into()),
-            };
-
-            if bytes_read == 0 {
-                break Err(anyhow!("Debugger log stream closed"));
+            match reader.read_line(&mut line).await {
+                Ok(0) => break,
+                Ok(_) => {}
+                Err(e) => {
+                    log::debug!("handle_adapter_log: {}", e);
+                    break;
+                }
             }
 
-            if let Some(log_handlers) = log_handlers.as_ref() {
-                for (kind, handler) in log_handlers.lock().iter_mut() {
-                    if matches!(kind, LogKind::Adapter) {
-                        handler(IoKind::StdOut, line.as_str());
-                    }
+            for (kind, handler) in log_handlers.lock().iter_mut() {
+                if matches!(kind, LogKind::Adapter) {
+                    handler(iokind, None, line.as_str());
                 }
             }
-        };
-
-        log::debug!("Handle adapter log dropped");
-
-        result
+        }
     }
 
     fn build_rpc_message(message: String) -> String {
         format!("Content-Length: {}\r\n\r\n{}", message.len(), message)
     }
 
-    async fn handle_input<Stdin>(
+    async fn send_to_server<Stdin>(
         mut server_stdin: Stdin,
         client_rx: Receiver<Message>,
-        current_requests: Requests,
-        pending_requests: Requests,
         log_handlers: Option<LogHandlers>,
     ) -> Result<()>
     where
@@ -293,11 +248,11 @@ impl TransportDelegate {
         let result = loop {
             match client_rx.recv().await {
                 Ok(message) => {
-                    if let Message::Request(request) = &message {
-                        if let Some(sender) = current_requests.lock().await.remove(&request.seq) {
-                            pending_requests.lock().await.insert(request.seq, sender);
-                        }
-                    }
+                    let command = match &message {
+                        Message::Request(request) => Some(request.command.as_str()),
+                        Message::Response(response) => Some(response.command.as_str()),
+                        _ => None,
+                    };
 
                     let message = match serde_json::to_string(&message) {
                         Ok(message) => message,
@@ -307,7 +262,7 @@ impl TransportDelegate {
                     if let Some(log_handlers) = log_handlers.as_ref() {
                         for (kind, log_handler) in log_handlers.lock().iter_mut() {
                             if matches!(kind, LogKind::Rpc) {
-                                log_handler(IoKind::StdIn, &message);
+                                log_handler(IoKind::StdIn, command, &message);
                             }
                         }
                     }
@@ -332,9 +287,9 @@ impl TransportDelegate {
         result
     }
 
-    async fn handle_output<Stdout>(
+    async fn recv_from_server<Stdout>(
         server_stdout: Stdout,
-        client_tx: Sender<Message>,
+        mut message_handler: DapMessageHandler,
         pending_requests: Requests,
         log_handlers: Option<LogHandlers>,
     ) -> Result<()>
@@ -345,60 +300,30 @@ impl TransportDelegate {
         let mut reader = BufReader::new(server_stdout);
 
         let result = loop {
-            let message =
-                Self::receive_server_message(&mut reader, &mut recv_buffer, log_handlers.as_ref())
-                    .await;
-
-            match message {
-                Ok(Message::Response(res)) => {
-                    if let Some(tx) = pending_requests.lock().await.remove(&res.request_seq) {
+            match Self::receive_server_message(&mut reader, &mut recv_buffer, log_handlers.as_ref())
+                .await
+            {
+                ConnectionResult::Timeout => anyhow::bail!("Timed out when connecting to debugger"),
+                ConnectionResult::ConnectionReset => {
+                    log::info!("Debugger closed the connection");
+                    return Ok(());
+                }
+                ConnectionResult::Result(Ok(Message::Response(res))) => {
+                    let tx = pending_requests.lock().remove(&res.request_seq);
+                    if let Some(tx) = tx {
                         if let Err(e) = tx.send(Self::process_response(res)) {
                             log::trace!("Did not send response `{:?}` for a cancelled", e);
                         }
                     } else {
-                        client_tx.send(Message::Response(res)).await?;
-                    };
-                }
-                Ok(message) => {
-                    client_tx.send(message).await?;
-                }
-                Err(e) => break Err(e),
-            }
-        };
-
-        drop(client_tx);
-
-        log::debug!("Handle adapter output dropped");
-
-        result
-    }
-
-    async fn handle_error<Stderr>(stderr: Stderr, log_handlers: LogHandlers) -> Result<()>
-    where
-        Stderr: AsyncRead + Unpin + Send + 'static,
-    {
-        log::debug!("Handle error started");
-        let mut buffer = String::new();
-
-        let mut reader = BufReader::new(stderr);
-
-        let result = loop {
-            match reader.read_line(&mut buffer).await {
-                Ok(0) => break Err(anyhow!("debugger error stream closed")),
-                Ok(_) => {
-                    for (kind, log_handler) in log_handlers.lock().iter_mut() {
-                        if matches!(kind, LogKind::Adapter) {
-                            log_handler(IoKind::StdErr, buffer.as_str());
-                        }
+                        message_handler(Message::Response(res))
                     }
-
-                    buffer.truncate(0);
                 }
-                Err(error) => break Err(error.into()),
+                ConnectionResult::Result(Ok(message)) => message_handler(message),
+                ConnectionResult::Result(Err(e)) => break Err(e),
             }
         };
 
-        log::debug!("Handle adapter error dropped");
+        log::debug!("Handle adapter output dropped");
 
         result
     }
@@ -414,13 +339,13 @@ impl TransportDelegate {
                 .and_then(|response| response.error.map(|msg| msg.format))
                 .or_else(|| response.message.clone())
             {
-                return Err(anyhow!(error_message));
+                anyhow::bail!(error_message);
             };
 
-            Err(anyhow!(
+            anyhow::bail!(
                 "Received error response from adapter. Response: {:?}",
-                response.clone()
-            ))
+                response
+            );
         }
     }
 
@@ -428,92 +353,77 @@ impl TransportDelegate {
         reader: &mut BufReader<Stdout>,
         buffer: &mut String,
         log_handlers: Option<&LogHandlers>,
-    ) -> Result<Message>
+    ) -> ConnectionResult<Message>
     where
         Stdout: AsyncRead + Unpin + Send + 'static,
     {
         let mut content_length = None;
         loop {
             buffer.truncate(0);
-
-            if reader
-                .read_line(buffer)
-                .await
-                .with_context(|| "reading a message from server")?
-                == 0
-            {
-                return Err(anyhow!("debugger reader stream closed"));
+            match reader.read_line(buffer).await {
+                Ok(0) => return ConnectionResult::ConnectionReset,
+                Ok(_) => {}
+                Err(e) => return ConnectionResult::Result(Err(e.into())),
             };
 
             if buffer == "\r\n" {
                 break;
             }
 
-            let parts = buffer.trim().split_once(": ");
-
-            match parts {
-                Some(("Content-Length", value)) => {
-                    content_length = Some(value.parse().context("invalid content length")?);
+            if let Some(("Content-Length", value)) = buffer.trim().split_once(": ") {
+                match value.parse().context("invalid content length") {
+                    Ok(length) => content_length = Some(length),
+                    Err(e) => return ConnectionResult::Result(Err(e)),
                 }
-                _ => {}
             }
         }
 
-        let content_length = content_length.context("missing content length")?;
+        let content_length = match content_length.context("missing content length") {
+            Ok(length) => length,
+            Err(e) => return ConnectionResult::Result(Err(e)),
+        };
 
         let mut content = vec![0; content_length];
-        reader
+        if let Err(e) = reader
             .read_exact(&mut content)
             .await
-            .with_context(|| "reading after a loop")?;
+            .with_context(|| "reading after a loop")
+        {
+            return ConnectionResult::Result(Err(e));
+        }
+
+        let message_str = match std::str::from_utf8(&content).context("invalid utf8 from server") {
+            Ok(str) => str,
+            Err(e) => return ConnectionResult::Result(Err(e)),
+        };
 
-        let message = std::str::from_utf8(&content).context("invalid utf8 from server")?;
+        let message =
+            serde_json::from_str::<Message>(message_str).context("deserializing server message");
 
         if let Some(log_handlers) = log_handlers {
+            let command = match &message {
+                Ok(Message::Request(request)) => Some(request.command.as_str()),
+                Ok(Message::Response(response)) => Some(response.command.as_str()),
+                _ => None,
+            };
+
             for (kind, log_handler) in log_handlers.lock().iter_mut() {
                 if matches!(kind, LogKind::Rpc) {
-                    log_handler(IoKind::StdOut, &message);
+                    log_handler(IoKind::StdOut, command, message_str);
                 }
             }
         }
 
-        Ok(serde_json::from_str::<Message>(message)?)
-    }
-
-    pub async fn shutdown(&self) -> Result<()> {
-        log::debug!("Start shutdown client");
-
-        if let Some(server_tx) = self.server_tx.lock().await.take().as_ref() {
-            server_tx.close();
-        }
-
-        let mut current_requests = self.current_requests.lock().await;
-        let mut pending_requests = self.pending_requests.lock().await;
-
-        current_requests.clear();
-        pending_requests.clear();
-
-        let _ = self.transport.kill().await.log_err();
-
-        drop(current_requests);
-        drop(pending_requests);
-
-        log::debug!("Shutdown client completed");
-
-        anyhow::Ok(())
+        ConnectionResult::Result(message)
     }
 
     pub fn has_adapter_logs(&self) -> bool {
-        self.transport.has_adapter_logs()
-    }
-
-    pub fn transport(&self) -> &Transport {
-        &self.transport
+        self.transport.lock().has_adapter_logs()
     }
 
     pub fn add_log_handler<F>(&self, f: F, kind: LogKind)
     where
-        F: 'static + Send + FnMut(IoKind, &str),
+        F: 'static + Send + FnMut(IoKind, Option<&Command>, &IoMessage),
     {
         let mut log_handlers = self.log_handlers.lock();
         log_handlers.push((kind, Box::new(f)));
@@ -521,10 +431,13 @@ impl TransportDelegate {
 }
 
 pub struct TcpTransport {
+    executor: BackgroundExecutor,
     pub port: u16,
     pub host: Ipv4Addr,
     pub timeout: u64,
-    process: Mutex<Child>,
+    process: Arc<Mutex<Option<Child>>>,
+    _stderr_task: Option<Task<()>>,
+    _stdout_task: Option<Task<()>>,
 }
 
 impl TcpTransport {
@@ -544,113 +457,175 @@ impl TcpTransport {
             .port())
     }
 
-    async fn start(binary: &DebugAdapterBinary, cx: AsyncApp) -> Result<(TransportPipe, Self)> {
-        let Some(connection_args) = binary.connection.as_ref() else {
-            return Err(anyhow!("No connection arguments provided"));
-        };
+    async fn start(
+        binary: &DebugAdapterBinary,
+        log_handlers: LogHandlers,
+        cx: &mut AsyncApp,
+    ) -> Result<Self> {
+        let connection_args = binary
+            .connection
+            .as_ref()
+            .context("No connection arguments provided")?;
 
         let host = connection_args.host;
         let port = connection_args.port;
 
-        let mut command = util::command::new_std_command(&binary.command);
-        util::set_pre_exec_to_start_new_session(&mut command);
-        let mut command = smol::process::Command::from(command);
-
-        if let Some(cwd) = &binary.cwd {
-            command.current_dir(cwd);
-        }
+        let mut process = None;
+        let mut stdout_task = None;
+        let mut stderr_task = None;
 
-        command.args(&binary.arguments);
-        command.envs(&binary.envs);
+        if let Some(command) = &binary.command {
+            let mut command = util::command::new_std_command(&command);
 
-        command
-            .stdin(Stdio::null())
-            .stdout(Stdio::piped())
-            .stderr(Stdio::piped())
-            .kill_on_drop(true);
-
-        let mut process = command
-            .spawn()
-            .with_context(|| "failed to start debug adapter.")?;
+            if let Some(cwd) = &binary.cwd {
+                command.current_dir(cwd);
+            }
 
-        let address = SocketAddrV4::new(host, port);
+            command.args(&binary.arguments);
+            command.envs(&binary.envs);
+
+            let mut p = Child::spawn(command, Stdio::null())
+                .with_context(|| "failed to start debug adapter.")?;
+
+            stdout_task = p.stdout.take().map(|stdout| {
+                cx.background_executor()
+                    .spawn(TransportDelegate::handle_adapter_log(
+                        stdout,
+                        IoKind::StdOut,
+                        log_handlers.clone(),
+                    ))
+            });
+            stderr_task = p.stderr.take().map(|stderr| {
+                cx.background_executor()
+                    .spawn(TransportDelegate::handle_adapter_log(
+                        stderr,
+                        IoKind::StdErr,
+                        log_handlers,
+                    ))
+            });
+            process = Some(p);
+        };
 
         let timeout = connection_args.timeout.unwrap_or_else(|| {
             cx.update(|cx| DebuggerSettings::get_global(cx).timeout)
-                .unwrap_or(2000u64)
+                .unwrap_or(20000u64)
         });
 
-        let (mut process, (rx, tx)) = select! {
-            _ = cx.background_executor().timer(Duration::from_millis(timeout)).fuse() => {
-                return Err(anyhow!(format!("Connection to TCP DAP timeout {}:{}", host, port)))
-            },
-            result = cx.spawn(async move |cx| {
-                loop {
-                    match TcpStream::connect(address).await {
-                        Ok(stream) => return Ok((process, stream.split())),
-                        Err(_) => {
-                            if let Ok(Some(_)) = process.try_status() {
-                                let output = process.output().await?;
-                                let output = if output.stderr.is_empty() {
-                                    String::from_utf8_lossy(&output.stdout).to_string()
-                                } else {
-                                    String::from_utf8_lossy(&output.stderr).to_string()
-                                };
-                                return Err(anyhow!("{}\nerror: process exited before debugger attached.", output));
-                            }
-                            cx.background_executor().timer(Duration::from_millis(100)).await;
-                        }
-                    }
-                }
-            }).fuse() => result?
-        };
-
         log::info!(
             "Debug adapter has connected to TCP server {}:{}",
             host,
             port
         );
-        let stdout = process.stdout.take();
-        let stderr = process.stderr.take();
 
         let this = Self {
+            executor: cx.background_executor().clone(),
             port,
             host,
-            process: Mutex::new(process),
+            process: Arc::new(Mutex::new(process)),
             timeout,
+            _stdout_task: stdout_task,
+            _stderr_task: stderr_task,
         };
 
-        let pipe = TransportPipe::new(
-            Box::new(tx),
-            Box::new(BufReader::new(rx)),
-            stdout.map(|s| Box::new(s) as Box<dyn AsyncRead + Unpin + Send>),
-            stderr.map(|s| Box::new(s) as Box<dyn AsyncRead + Unpin + Send>),
-        );
-
-        Ok((pipe, this))
+        Ok(this)
     }
+}
 
+impl Transport for TcpTransport {
     fn has_adapter_logs(&self) -> bool {
         true
     }
 
-    async fn kill(&self) -> Result<()> {
-        self.process.lock().await.kill()?;
+    fn kill(&mut self) {
+        if let Some(process) = &mut *self.process.lock() {
+            process.kill();
+        }
+    }
 
-        Ok(())
+    fn tcp_arguments(&self) -> Option<TcpArguments> {
+        Some(TcpArguments {
+            host: self.host,
+            port: self.port,
+            timeout: Some(self.timeout),
+        })
+    }
+
+    fn connect(
+        &mut self,
+    ) -> Task<
+        Result<(
+            Box<dyn AsyncWrite + Unpin + Send + 'static>,
+            Box<dyn AsyncRead + Unpin + Send + 'static>,
+        )>,
+    > {
+        let executor = self.executor.clone();
+        let timeout = self.timeout;
+        let address = SocketAddrV4::new(self.host, self.port);
+        let process = self.process.clone();
+        executor.clone().spawn(async move {
+            select! {
+                _ = executor.timer(Duration::from_millis(timeout)).fuse() => {
+                    anyhow::bail!("Connection to TCP DAP timeout {address}");
+                },
+                result = executor.clone().spawn(async move {
+                    loop {
+                        match TcpStream::connect(address).await {
+                            Ok(stream) => {
+                                let (read, write) = stream.split();
+                                return Ok((Box::new(write) as _, Box::new(read) as _))
+                            },
+                            Err(_) => {
+                                let has_process = process.lock().is_some();
+                                if has_process {
+                                    let status = process.lock().as_mut().unwrap().try_status();
+                                    if let Ok(Some(_)) = status {
+                                        let process = process.lock().take().unwrap().into_inner();
+                                        let output = process.output().await?;
+                                        let output = if output.stderr.is_empty() {
+                                            String::from_utf8_lossy(&output.stdout).to_string()
+                                        } else {
+                                            String::from_utf8_lossy(&output.stderr).to_string()
+                                        };
+                                        anyhow::bail!("{output}\nerror: process exited before debugger attached.");
+                                    }
+                                }
+
+                                executor.timer(Duration::from_millis(100)).await;
+                            }
+                        }
+                    }
+                }).fuse() => result
+            }
+        })
+    }
+}
+
+impl Drop for TcpTransport {
+    fn drop(&mut self) {
+        if let Some(mut p) = self.process.lock().take() {
+            p.kill()
+        }
     }
 }
 
 pub struct StdioTransport {
-    process: Mutex<Child>,
+    process: Mutex<Option<Child>>,
+    _stderr_task: Option<Task<()>>,
 }
 
 impl StdioTransport {
-    #[allow(dead_code, reason = "This is used in non test builds of Zed")]
-    async fn start(binary: &DebugAdapterBinary, _: AsyncApp) -> Result<(TransportPipe, Self)> {
-        let mut command = util::command::new_std_command(&binary.command);
-        util::set_pre_exec_to_start_new_session(&mut command);
-        let mut command = smol::process::Command::from(command);
+    // #[allow(dead_code, reason = "This is used in non test builds of Zed")]
+    async fn start(
+        binary: &DebugAdapterBinary,
+        log_handlers: LogHandlers,
+        cx: &mut AsyncApp,
+    ) -> Result<Self> {
+        let Some(binary_command) = &binary.command else {
+            bail!(
+                "When using the `stdio` transport, the path to a debug adapter binary must be set by Zed."
+            );
+        };
+        let mut command = util::command::new_std_command(&binary_command);
 
         if let Some(cwd) = &binary.cwd {
             command.current_dir(cwd);
@@ -659,58 +634,71 @@ impl StdioTransport {
         command.args(&binary.arguments);
         command.envs(&binary.envs);
 
-        command
-            .stdin(Stdio::piped())
-            .stdout(Stdio::piped())
-            .stderr(Stdio::piped())
-            .kill_on_drop(true);
-
-        let mut process = command
-            .spawn()
-            .with_context(|| "failed to spawn command.")?;
-
-        let stdin = process
-            .stdin
-            .take()
-            .ok_or_else(|| anyhow!("Failed to open stdin"))?;
-        let stdout = process
-            .stdout
-            .take()
-            .ok_or_else(|| anyhow!("Failed to open stdout"))?;
-        let stderr = process
-            .stderr
-            .take()
-            .map(|io_err| Box::new(io_err) as Box<dyn AsyncRead + Unpin + Send>);
-
-        if stderr.is_none() {
-            bail!(
-                "Failed to connect to stderr for debug adapter command {}",
-                &binary.command
-            );
-        }
+        let mut process = Child::spawn(command, Stdio::piped()).with_context(|| {
+            format!(
+                "failed to spawn command `{} {}`.",
+                binary_command,
+                binary.arguments.join(" ")
+            )
+        })?;
 
-        log::info!("Debug adapter has connected to stdio adapter");
+        let err_task = process.stderr.take().map(|stderr| {
+            cx.background_spawn(TransportDelegate::handle_adapter_log(
+                stderr,
+                IoKind::StdErr,
+                log_handlers,
+            ))
+        });
 
-        let process = Mutex::new(process);
+        let process = Mutex::new(Some(process));
 
-        Ok((
-            TransportPipe::new(
-                Box::new(stdin),
-                Box::new(BufReader::new(stdout)),
-                None,
-                stderr,
-            ),
-            Self { process },
-        ))
+        Ok(Self {
+            process,
+            _stderr_task: err_task,
+        })
     }
+}
 
+impl Transport for StdioTransport {
     fn has_adapter_logs(&self) -> bool {
         false
     }
 
-    async fn kill(&self) -> Result<()> {
-        self.process.lock().await.kill()?;
-        Ok(())
+    fn kill(&mut self) {
+        if let Some(process) = &mut *self.process.lock() {
+            process.kill();
+        }
+    }
+
+    fn connect(
+        &mut self,
+    ) -> Task<
+        Result<(
+            Box<dyn AsyncWrite + Unpin + Send + 'static>,
+            Box<dyn AsyncRead + Unpin + Send + 'static>,
+        )>,
+    > {
+        let result = util::maybe!({
+            let mut guard = self.process.lock();
+            let process = guard.as_mut().context("oops")?;
+            Ok((
+                Box::new(process.stdin.take().context("Cannot reconnect")?) as _,
+                Box::new(process.stdout.take().context("Cannot reconnect")?) as _,
+            ))
+        });
+        Task::ready(result)
+    }
+
+    fn tcp_arguments(&self) -> Option<TcpArguments> {
+        None
+    }
+}
+
+impl Drop for StdioTransport {
+    fn drop(&mut self) {
+        if let Some(process) = &mut *self.process.lock() {
+            process.kill();
+        }
     }
 }
 

crates/dap_adapters/Cargo.toml 🔗

@@ -23,13 +23,17 @@ doctest = false
 [dependencies]
 anyhow.workspace = true
 async-trait.workspace = true
+collections.workspace = true
 dap.workspace = true
 futures.workspace = true
 gpui.workspace = true
+json_dotpath.workspace = true
 language.workspace = true
+log.workspace = true
 paths.workspace = true
 serde.workspace = true
 serde_json.workspace = true
+shlex.workspace = true
 task.workspace = true
 util.workspace = true
 workspace-hack.workspace = true

crates/dap_adapters/src/codelldb.rs 🔗

@@ -1,11 +1,12 @@
 use std::{collections::HashMap, path::PathBuf, sync::OnceLock};
 
-use anyhow::Result;
+use anyhow::{Context as _, Result};
 use async_trait::async_trait;
 use dap::adapters::{DebugTaskDefinition, latest_github_release};
 use futures::StreamExt;
 use gpui::AsyncApp;
-use task::DebugRequest;
+use serde_json::Value;
+use task::{DebugRequest, DebugScenario, ZedDebugConfig};
 use util::fs::remove_matching;
 
 use crate::*;
@@ -18,48 +19,35 @@ pub(crate) struct CodeLldbDebugAdapter {
 impl CodeLldbDebugAdapter {
     const ADAPTER_NAME: &'static str = "CodeLLDB";
 
-    fn request_args(&self, config: &DebugTaskDefinition) -> dap::StartDebuggingRequestArguments {
-        let mut configuration = json!({
-            "request": match config.request {
-                DebugRequest::Launch(_) => "launch",
-                DebugRequest::Attach(_) => "attach",
-            },
-        });
-        let map = configuration.as_object_mut().unwrap();
+    async fn request_args(
+        &self,
+        delegate: &Arc<dyn DapDelegate>,
+        task_definition: &DebugTaskDefinition,
+    ) -> Result<dap::StartDebuggingRequestArguments> {
         // CodeLLDB uses `name` for a terminal label.
-        map.insert(
-            "name".into(),
-            Value::String(String::from(config.label.as_ref())),
-        );
-        let request = config.request.to_dap();
-        match &config.request {
-            DebugRequest::Attach(attach) => {
-                map.insert("pid".into(), attach.process_id.into());
-            }
-            DebugRequest::Launch(launch) => {
-                map.insert("program".into(), launch.program.clone().into());
+        let mut configuration = task_definition.config.clone();
 
-                if !launch.args.is_empty() {
-                    map.insert("args".into(), launch.args.clone().into());
-                }
+        let obj = configuration
+            .as_object_mut()
+            .context("CodeLLDB is not a valid json object")?;
 
-                if let Some(stop_on_entry) = config.stop_on_entry {
-                    map.insert("stopOnEntry".into(), stop_on_entry.into());
-                }
-                if let Some(cwd) = launch.cwd.as_ref() {
-                    map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
-                }
-            }
-        }
-        dap::StartDebuggingRequestArguments {
+        obj.entry("name")
+            .or_insert(Value::String(String::from(task_definition.label.as_ref())));
+
+        obj.entry("cwd")
+            .or_insert(delegate.worktree_root_path().to_string_lossy().into());
+
+        let request = self.request_kind(&configuration).await?;
+
+        Ok(dap::StartDebuggingRequestArguments {
             request,
             configuration,
-        }
+        })
     }
 
     async fn fetch_latest_adapter_version(
         &self,
-        delegate: &dyn DapDelegate,
+        delegate: &Arc<dyn DapDelegate>,
     ) -> Result<AdapterVersion> {
         let release =
             latest_github_release("vadimcn/codelldb", true, false, delegate.http_client()).await?;
@@ -67,22 +55,16 @@ impl CodeLldbDebugAdapter {
         let arch = match std::env::consts::ARCH {
             "aarch64" => "arm64",
             "x86_64" => "x64",
-            _ => {
-                return Err(anyhow!(
-                    "unsupported architecture {}",
-                    std::env::consts::ARCH
-                ));
+            unsupported => {
+                anyhow::bail!("unsupported architecture {unsupported}");
             }
         };
         let platform = match std::env::consts::OS {
             "macos" => "darwin",
             "linux" => "linux",
             "windows" => "win32",
-            _ => {
-                return Err(anyhow!(
-                    "unsupported operating system {}",
-                    std::env::consts::OS
-                ));
+            unsupported => {
+                anyhow::bail!("unsupported operating system {unsupported}");
             }
         };
         let asset_name = format!("codelldb-{platform}-{arch}.vsix");
@@ -92,7 +74,7 @@ impl CodeLldbDebugAdapter {
                 .assets
                 .iter()
                 .find(|asset| asset.name == asset_name)
-                .ok_or_else(|| anyhow!("no asset found matching {:?}", asset_name))?
+                .with_context(|| format!("no asset found matching {asset_name:?}"))?
                 .browser_download_url
                 .clone(),
         };
@@ -107,11 +89,247 @@ impl DebugAdapter for CodeLldbDebugAdapter {
         DebugAdapterName(Self::ADAPTER_NAME.into())
     }
 
+    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
+        let mut configuration = json!({
+            "request": match zed_scenario.request {
+                DebugRequest::Launch(_) => "launch",
+                DebugRequest::Attach(_) => "attach",
+            },
+        });
+        let map = configuration.as_object_mut().unwrap();
+        // CodeLLDB uses `name` for a terminal label.
+        map.insert(
+            "name".into(),
+            Value::String(String::from(zed_scenario.label.as_ref())),
+        );
+        match &zed_scenario.request {
+            DebugRequest::Attach(attach) => {
+                map.insert("pid".into(), attach.process_id.into());
+            }
+            DebugRequest::Launch(launch) => {
+                map.insert("program".into(), launch.program.clone().into());
+
+                if !launch.args.is_empty() {
+                    map.insert("args".into(), launch.args.clone().into());
+                }
+                if !launch.env.is_empty() {
+                    map.insert("env".into(), launch.env_json());
+                }
+                if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
+                    map.insert("stopOnEntry".into(), stop_on_entry.into());
+                }
+                if let Some(cwd) = launch.cwd.as_ref() {
+                    map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
+                }
+            }
+        }
+
+        Ok(DebugScenario {
+            adapter: zed_scenario.adapter,
+            label: zed_scenario.label,
+            config: configuration,
+            build: None,
+            tcp_connection: None,
+        })
+    }
+
+    fn dap_schema(&self) -> serde_json::Value {
+        json!({
+            "properties": {
+                "request": {
+                    "type": "string",
+                    "enum": ["attach", "launch"],
+                    "description": "Debug adapter request type"
+                },
+                "program": {
+                    "type": "string",
+                    "description": "Path to the program to debug or attach to"
+                },
+                "args": {
+                    "type": ["array", "string"],
+                    "description": "Program arguments"
+                },
+                "cwd": {
+                    "type": "string",
+                    "description": "Program working directory"
+                },
+                "env": {
+                    "type": "object",
+                    "description": "Additional environment variables",
+                    "patternProperties": {
+                        ".*": {
+                            "type": "string"
+                        }
+                    }
+                },
+                "envFile": {
+                    "type": "string",
+                    "description": "File to read the environment variables from"
+                },
+                "stdio": {
+                    "type": ["null", "string", "array", "object"],
+                    "description": "Destination for stdio streams: null = send to debugger console or a terminal, \"<path>\" = attach to a file/tty/fifo"
+                },
+                "terminal": {
+                    "type": "string",
+                    "enum": ["integrated", "console"],
+                    "description": "Terminal type to use",
+                    "default": "integrated"
+                },
+                "console": {
+                    "type": "string",
+                    "enum": ["integratedTerminal", "internalConsole"],
+                    "description": "Terminal type to use (compatibility alias of 'terminal')"
+                },
+                "stopOnEntry": {
+                    "type": "boolean",
+                    "description": "Automatically stop debuggee after launch",
+                    "default": false
+                },
+                "initCommands": {
+                    "type": "array",
+                    "description": "Initialization commands executed upon debugger startup",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "targetCreateCommands": {
+                    "type": "array",
+                    "description": "Commands that create the debug target",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "preRunCommands": {
+                    "type": "array",
+                    "description": "Commands executed just before the program is launched",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "processCreateCommands": {
+                    "type": "array",
+                    "description": "Commands that create the debuggee process",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "postRunCommands": {
+                    "type": "array",
+                    "description": "Commands executed just after the program has been launched",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "preTerminateCommands": {
+                    "type": "array",
+                    "description": "Commands executed just before the debuggee is terminated or disconnected from",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "exitCommands": {
+                    "type": "array",
+                    "description": "Commands executed at the end of debugging session",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "expressions": {
+                    "type": "string",
+                    "enum": ["simple", "python", "native"],
+                    "description": "The default evaluator type used for expressions"
+                },
+                "sourceMap": {
+                    "type": "object",
+                    "description": "Source path remapping between the build machine and the local machine",
+                    "patternProperties": {
+                        ".*": {
+                            "type": ["string", "null"]
+                        }
+                    }
+                },
+                "relativePathBase": {
+                    "type": "string",
+                    "description": "Base directory used for resolution of relative source paths. Defaults to the workspace folder"
+                },
+                "sourceLanguages": {
+                    "type": "array",
+                    "description": "A list of source languages to enable language-specific features for",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "reverseDebugging": {
+                    "type": "boolean",
+                    "description": "Enable reverse debugging",
+                    "default": false
+                },
+                "breakpointMode": {
+                    "type": "string",
+                    "enum": ["path", "file"],
+                    "description": "Specifies how source breakpoints should be set"
+                },
+                "pid": {
+                    "type": ["integer", "string"],
+                    "description": "Process id to attach to"
+                },
+                "waitFor": {
+                    "type": "boolean",
+                    "description": "Wait for the process to launch (MacOS only)",
+                    "default": false
+                }
+            },
+            "required": ["request"],
+            "allOf": [
+                {
+                    "if": {
+                        "properties": {
+                            "request": {
+                                "enum": ["launch"]
+                            }
+                        }
+                    },
+                    "then": {
+                        "oneOf": [
+                            {
+                                "required": ["program"]
+                            },
+                            {
+                                "required": ["targetCreateCommands"]
+                            }
+                        ]
+                    }
+                },
+                {
+                    "if": {
+                        "properties": {
+                            "request": {
+                                "enum": ["attach"]
+                            }
+                        }
+                    },
+                    "then": {
+                        "oneOf": [
+                            {
+                                "required": ["pid"]
+                            },
+                            {
+                                "required": ["program"]
+                            }
+                        ]
+                    }
+                }
+            ]
+        })
+    }
+
     async fn get_binary(
         &self,
-        delegate: &dyn DapDelegate,
+        delegate: &Arc<dyn DapDelegate>,
         config: &DebugTaskDefinition,
         user_installed_path: Option<PathBuf>,
+        user_args: Option<Vec<String>>,
         _: &mut AsyncApp,
     ) -> Result<DebugAdapterBinary> {
         let mut command = user_installed_path
@@ -127,7 +345,7 @@ impl DebugAdapter for CodeLldbDebugAdapter {
                         self.name(),
                         version.clone(),
                         adapters::DownloadedFileType::Vsix,
-                        delegate,
+                        delegate.as_ref(),
                     )
                     .await?;
                     let version_path =
@@ -136,10 +354,7 @@ impl DebugAdapter for CodeLldbDebugAdapter {
                     version_path
                 } else {
                     let mut paths = delegate.fs().read_dir(&adapter_path).await?;
-                    paths
-                        .next()
-                        .await
-                        .ok_or_else(|| anyhow!("No adapter found"))??
+                    paths.next().await.context("No adapter found")??
                 };
             let adapter_dir = version_path.join("extension").join("adapter");
             let path = adapter_dir.join("codelldb").to_string_lossy().to_string();
@@ -148,13 +363,15 @@ impl DebugAdapter for CodeLldbDebugAdapter {
         };
 
         Ok(DebugAdapterBinary {
-            command: command.unwrap(),
-            cwd: None,
-            arguments: vec![
-                "--settings".into(),
-                json!({"sourceLanguages": ["cpp", "rust"]}).to_string(),
-            ],
-            request_args: self.request_args(config),
+            command: Some(command.unwrap()),
+            cwd: Some(delegate.worktree_root_path().to_path_buf()),
+            arguments: user_args.unwrap_or_else(|| {
+                vec![
+                    "--settings".into(),
+                    json!({"sourceLanguages": ["cpp", "rust"]}).to_string(),
+                ]
+            }),
+            request_args: self.request_args(delegate, &config).await?,
             envs: HashMap::default(),
             connection: None,
         })

crates/dap_adapters/src/dap_adapters.rs 🔗

@@ -6,18 +6,18 @@ mod php;
 mod python;
 mod ruby;
 
-use std::{net::Ipv4Addr, sync::Arc};
+use std::sync::Arc;
 
-use anyhow::{Result, anyhow};
+use anyhow::Result;
 use async_trait::async_trait;
 use codelldb::CodeLldbDebugAdapter;
 use dap::{
-    DapRegistry, DebugRequest,
+    DapRegistry,
     adapters::{
         self, AdapterVersion, DapDelegate, DebugAdapter, DebugAdapterBinary, DebugAdapterName,
         GithubRepo,
     },
-    inline_value::{PythonInlineValueProvider, RustInlineValueProvider},
+    configure_tcp_connection,
 };
 use gdb::GdbDebugAdapter;
 use go::GoDebugAdapter;
@@ -26,8 +26,8 @@ use javascript::JsDebugAdapter;
 use php::PhpDebugAdapter;
 use python::PythonDebugAdapter;
 use ruby::RubyDebugAdapter;
-use serde_json::{Value, json};
-use task::TcpArgumentsTemplate;
+use serde_json::json;
+use task::{DebugScenario, ZedDebugConfig};
 
 pub fn init(cx: &mut App) {
     cx.update_default_global(|registry: &mut DapRegistry, _cx| {
@@ -36,39 +36,12 @@ pub fn init(cx: &mut App) {
         registry.add_adapter(Arc::from(PhpDebugAdapter::default()));
         registry.add_adapter(Arc::from(JsDebugAdapter::default()));
         registry.add_adapter(Arc::from(RubyDebugAdapter));
-        registry.add_adapter(Arc::from(GoDebugAdapter));
+        registry.add_adapter(Arc::from(GoDebugAdapter::default()));
         registry.add_adapter(Arc::from(GdbDebugAdapter));
 
-        registry.add_inline_value_provider("Rust".to_string(), Arc::from(RustInlineValueProvider));
-        registry
-            .add_inline_value_provider("Python".to_string(), Arc::from(PythonInlineValueProvider));
-    })
-}
-
-pub(crate) async fn configure_tcp_connection(
-    tcp_connection: TcpArgumentsTemplate,
-) -> Result<(Ipv4Addr, u16, Option<u64>)> {
-    let host = tcp_connection.host();
-    let timeout = tcp_connection.timeout;
-
-    let port = if let Some(port) = tcp_connection.port {
-        port
-    } else {
-        dap::transport::TcpTransport::port(&tcp_connection).await?
-    };
-
-    Ok((host, port, timeout))
-}
-
-trait ToDap {
-    fn to_dap(&self) -> dap::StartDebuggingRequestArgumentsRequest;
-}
-
-impl ToDap for DebugRequest {
-    fn to_dap(&self) -> dap::StartDebuggingRequestArgumentsRequest {
-        match self {
-            Self::Launch(_) => dap::StartDebuggingRequestArgumentsRequest::Launch,
-            Self::Attach(_) => dap::StartDebuggingRequestArgumentsRequest::Attach,
+        #[cfg(any(test, feature = "test-support"))]
+        {
+            registry.add_adapter(Arc::from(dap::FakeAdapter {}));
         }
-    }
+    })
 }

crates/dap_adapters/src/gdb.rs 🔗

@@ -1,10 +1,10 @@
 use std::{collections::HashMap, ffi::OsStr};
 
-use anyhow::{Result, bail};
+use anyhow::{Context as _, Result, bail};
 use async_trait::async_trait;
 use dap::{StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
 use gpui::AsyncApp;
-use task::DebugRequest;
+use task::{DebugScenario, ZedDebugConfig};
 
 use crate::*;
 
@@ -13,57 +13,153 @@ pub(crate) struct GdbDebugAdapter;
 
 impl GdbDebugAdapter {
     const ADAPTER_NAME: &'static str = "GDB";
+}
 
-    fn request_args(&self, config: &DebugTaskDefinition) -> StartDebuggingRequestArguments {
-        let mut args = json!({
-            "request": match config.request {
-                DebugRequest::Launch(_) => "launch",
-                DebugRequest::Attach(_) => "attach",
-            },
-        });
+#[async_trait(?Send)]
+impl DebugAdapter for GdbDebugAdapter {
+    fn name(&self) -> DebugAdapterName {
+        DebugAdapterName(Self::ADAPTER_NAME.into())
+    }
+
+    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
+        let mut obj = serde_json::Map::default();
 
-        let map = args.as_object_mut().unwrap();
-        match &config.request {
-            DebugRequest::Attach(attach) => {
-                map.insert("pid".into(), attach.process_id.into());
+        match &zed_scenario.request {
+            dap::DebugRequest::Attach(attach) => {
+                obj.insert("request".into(), "attach".into());
+                obj.insert("pid".into(), attach.process_id.into());
             }
 
-            DebugRequest::Launch(launch) => {
-                map.insert("program".into(), launch.program.clone().into());
+            dap::DebugRequest::Launch(launch) => {
+                obj.insert("request".into(), "launch".into());
+                obj.insert("program".into(), launch.program.clone().into());
 
                 if !launch.args.is_empty() {
-                    map.insert("args".into(), launch.args.clone().into());
+                    obj.insert("args".into(), launch.args.clone().into());
+                }
+
+                if !launch.env.is_empty() {
+                    obj.insert("env".into(), launch.env_json());
                 }
 
-                if let Some(stop_on_entry) = config.stop_on_entry {
-                    map.insert(
+                if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
+                    obj.insert(
                         "stopAtBeginningOfMainSubprogram".into(),
                         stop_on_entry.into(),
                     );
                 }
                 if let Some(cwd) = launch.cwd.as_ref() {
-                    map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
+                    obj.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
                 }
             }
         }
-        StartDebuggingRequestArguments {
-            configuration: args,
-            request: config.request.to_dap(),
-        }
+
+        Ok(DebugScenario {
+            adapter: zed_scenario.adapter,
+            label: zed_scenario.label,
+            build: None,
+            config: serde_json::Value::Object(obj),
+            tcp_connection: None,
+        })
     }
-}
 
-#[async_trait(?Send)]
-impl DebugAdapter for GdbDebugAdapter {
-    fn name(&self) -> DebugAdapterName {
-        DebugAdapterName(Self::ADAPTER_NAME.into())
+    fn dap_schema(&self) -> serde_json::Value {
+        json!({
+            "oneOf": [
+                {
+                    "allOf": [
+                        {
+                            "type": "object",
+                            "required": ["request"],
+                            "properties": {
+                                "request": {
+                                    "type": "string",
+                                    "enum": ["launch"],
+                                    "description": "Request to launch a new process"
+                                }
+                            }
+                        },
+                        {
+                            "type": "object",
+                            "properties": {
+                                "program": {
+                                    "type": "string",
+                                    "description": "The program to debug. This corresponds to the GDB 'file' command."
+                                },
+                                "args": {
+                                    "type": "array",
+                                    "items": {
+                                        "type": "string"
+                                    },
+                                    "description": "Command line arguments passed to the program. These strings are provided as command-line arguments to the inferior.",
+                                    "default": []
+                                },
+                                "cwd": {
+                                    "type": "string",
+                                    "description": "Working directory for the debugged program. GDB will change its working directory to this directory."
+                                },
+                                "env": {
+                                    "type": "object",
+                                    "description": "Environment variables for the debugged program. Each key is the name of an environment variable; each value is the value of that variable."
+                                },
+                                "stopAtBeginningOfMainSubprogram": {
+                                    "type": "boolean",
+                                    "description": "When true, GDB will set a temporary breakpoint at the program's main procedure, like the 'start' command.",
+                                    "default": false
+                                },
+                                "stopOnEntry": {
+                                    "type": "boolean",
+                                    "description": "When true, GDB will set a temporary breakpoint at the program's first instruction, like the 'starti' command.",
+                                    "default": false
+                                }
+                            },
+                            "required": ["program"]
+                        }
+                    ]
+                },
+                {
+                    "allOf": [
+                        {
+                            "type": "object",
+                            "required": ["request"],
+                            "properties": {
+                                "request": {
+                                    "type": "string",
+                                    "enum": ["attach"],
+                                    "description": "Request to attach to an existing process"
+                                }
+                            }
+                        },
+                        {
+                            "type": "object",
+                            "properties": {
+                                "pid": {
+                                    "type": "number",
+                                    "description": "The process ID to which GDB should attach."
+                                },
+                                "program": {
+                                    "type": "string",
+                                    "description": "The program to debug (optional). This corresponds to the GDB 'file' command. In many cases, GDB can determine which program is running automatically."
+                                },
+                                "target": {
+                                    "type": "string",
+                                    "description": "The target to which GDB should connect. This is passed to the 'target remote' command."
+                                }
+                            },
+                            "required": ["pid"]
+                        }
+                    ]
+                }
+            ]
+        })
     }
 
     async fn get_binary(
         &self,
-        delegate: &dyn DapDelegate,
+        delegate: &Arc<dyn DapDelegate>,
         config: &DebugTaskDefinition,
         user_installed_path: Option<std::path::PathBuf>,
+        user_args: Option<Vec<String>>,
         _: &mut AsyncApp,
     ) -> Result<DebugAdapterBinary> {
         let user_setting_path = user_installed_path
@@ -72,8 +168,9 @@ impl DebugAdapter for GdbDebugAdapter {
 
         let gdb_path = delegate
             .which(OsStr::new("gdb"))
+            .await
             .and_then(|p| p.to_str().map(|s| s.to_string()))
-            .ok_or(anyhow!("Could not find gdb in path"));
+            .context("Could not find gdb in path");
 
         if gdb_path.is_err() && user_setting_path.is_none() {
             bail!("Could not find gdb path or it's not installed");
@@ -81,13 +178,23 @@ impl DebugAdapter for GdbDebugAdapter {
 
         let gdb_path = user_setting_path.unwrap_or(gdb_path?);
 
+        let mut configuration = config.config.clone();
+        if let Some(configuration) = configuration.as_object_mut() {
+            configuration
+                .entry("cwd")
+                .or_insert_with(|| delegate.worktree_root_path().to_string_lossy().into());
+        }
+
         Ok(DebugAdapterBinary {
-            command: gdb_path,
-            arguments: vec!["-i=dap".into()],
+            command: Some(gdb_path),
+            arguments: user_args.unwrap_or_else(|| vec!["-i=dap".into()]),
             envs: HashMap::default(),
-            cwd: None,
+            cwd: Some(delegate.worktree_root_path().to_path_buf()),
             connection: None,
-            request_args: self.request_args(config),
+            request_args: StartDebuggingRequestArguments {
+                request: self.request_kind(&config.config).await?,
+                configuration,
+            },
         })
     }
 }

crates/dap_adapters/src/go.rs 🔗

@@ -1,77 +1,506 @@
-use dap::{StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
-use gpui::AsyncApp;
-use std::{collections::HashMap, ffi::OsStr, path::PathBuf};
+use anyhow::{Context as _, bail};
+use collections::HashMap;
+use dap::{
+    StartDebuggingRequestArguments,
+    adapters::{
+        DebugTaskDefinition, DownloadedFileType, TcpArguments, download_adapter_from_github,
+        latest_github_release,
+    },
+};
+
+use gpui::{AsyncApp, SharedString};
+use language::LanguageName;
+use std::{env::consts, ffi::OsStr, path::PathBuf, sync::OnceLock};
+use task::TcpArgumentsTemplate;
+use util;
 
 use crate::*;
 
 #[derive(Default, Debug)]
-pub(crate) struct GoDebugAdapter;
+pub(crate) struct GoDebugAdapter {
+    shim_path: OnceLock<PathBuf>,
+}
 
 impl GoDebugAdapter {
     const ADAPTER_NAME: &'static str = "Delve";
-    fn request_args(&self, config: &DebugTaskDefinition) -> StartDebuggingRequestArguments {
-        let mut args = match &config.request {
+    async fn fetch_latest_adapter_version(
+        delegate: &Arc<dyn DapDelegate>,
+    ) -> Result<AdapterVersion> {
+        let release = latest_github_release(
+            &"zed-industries/delve-shim-dap",
+            true,
+            false,
+            delegate.http_client(),
+        )
+        .await?;
+
+        let os = match consts::OS {
+            "macos" => "apple-darwin",
+            "linux" => "unknown-linux-gnu",
+            "windows" => "pc-windows-msvc",
+            other => bail!("Running on unsupported os: {other}"),
+        };
+        let suffix = if consts::OS == "windows" {
+            ".zip"
+        } else {
+            ".tar.gz"
+        };
+        let asset_name = format!("delve-shim-dap-{}-{os}{suffix}", consts::ARCH);
+        let asset = release
+            .assets
+            .iter()
+            .find(|asset| asset.name == asset_name)
+            .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
+
+        Ok(AdapterVersion {
+            tag_name: release.tag_name,
+            url: asset.browser_download_url.clone(),
+        })
+    }
+    async fn install_shim(&self, delegate: &Arc<dyn DapDelegate>) -> anyhow::Result<PathBuf> {
+        if let Some(path) = self.shim_path.get().cloned() {
+            return Ok(path);
+        }
+
+        let asset = Self::fetch_latest_adapter_version(delegate).await?;
+        let ty = if consts::OS == "windows" {
+            DownloadedFileType::Zip
+        } else {
+            DownloadedFileType::GzipTar
+        };
+        download_adapter_from_github(
+            "delve-shim-dap".into(),
+            asset.clone(),
+            ty,
+            delegate.as_ref(),
+        )
+        .await?;
+
+        let path = paths::debug_adapters_dir()
+            .join("delve-shim-dap")
+            .join(format!("delve-shim-dap_{}", asset.tag_name))
+            .join(format!("delve-shim-dap{}", std::env::consts::EXE_SUFFIX));
+        self.shim_path.set(path.clone()).ok();
+
+        Ok(path)
+    }
+}
+
+#[async_trait(?Send)]
+impl DebugAdapter for GoDebugAdapter {
+    fn name(&self) -> DebugAdapterName {
+        DebugAdapterName(Self::ADAPTER_NAME.into())
+    }
+
+    fn adapter_language_name(&self) -> Option<LanguageName> {
+        Some(SharedString::new_static("Go").into())
+    }
+
+    fn dap_schema(&self) -> serde_json::Value {
+        // Create common properties shared between launch and attach
+        let common_properties = json!({
+            "debugAdapter": {
+                "enum": ["legacy", "dlv-dap"],
+                "description": "Select which debug adapter to use with this configuration.",
+                "default": "dlv-dap"
+            },
+            "stopOnEntry": {
+                "type": "boolean",
+                "description": "Automatically stop program after launch or attach.",
+                "default": false
+            },
+            "showLog": {
+                "type": "boolean",
+                "description": "Show log output from the delve debugger. Maps to dlv's `--log` flag.",
+                "default": false
+            },
+            "cwd": {
+                "type": "string",
+                "description": "Workspace relative or absolute path to the working directory of the program being debugged.",
+                "default": "${ZED_WORKTREE_ROOT}"
+            },
+            "dlvFlags": {
+                "type": "array",
+                "description": "Extra flags for `dlv`. See `dlv help` for the full list of supported flags.",
+                "items": {
+                    "type": "string"
+                },
+                "default": []
+            },
+            "port": {
+                "type": "number",
+                "description": "Debug server port. For remote configurations, this is where to connect.",
+                "default": 2345
+            },
+            "host": {
+                "type": "string",
+                "description": "Debug server host. For remote configurations, this is where to connect.",
+                "default": "127.0.0.1"
+            },
+            "substitutePath": {
+                "type": "array",
+                "items": {
+                    "type": "object",
+                    "properties": {
+                        "from": {
+                            "type": "string",
+                            "description": "The absolute local path to be replaced."
+                        },
+                        "to": {
+                            "type": "string",
+                            "description": "The absolute remote path to replace with."
+                        }
+                    }
+                },
+                "description": "Mappings from local to remote paths for debugging.",
+                "default": []
+            },
+            "trace": {
+                "type": "string",
+                "enum": ["verbose", "trace", "log", "info", "warn", "error"],
+                "default": "error",
+                "description": "Debug logging level."
+            },
+            "backend": {
+                "type": "string",
+                "enum": ["default", "native", "lldb", "rr"],
+                "description": "Backend used by delve. Maps to `dlv`'s `--backend` flag."
+            },
+            "logOutput": {
+                "type": "string",
+                "enum": ["debugger", "gdbwire", "lldbout", "debuglineerr", "rpc", "dap"],
+                "description": "Components that should produce debug output.",
+                "default": "debugger"
+            },
+            "logDest": {
+                "type": "string",
+                "description": "Log destination for delve."
+            },
+            "stackTraceDepth": {
+                "type": "number",
+                "description": "Maximum depth of stack traces.",
+                "default": 50
+            },
+            "showGlobalVariables": {
+                "type": "boolean",
+                "default": false,
+                "description": "Show global package variables in variables pane."
+            },
+            "showRegisters": {
+                "type": "boolean",
+                "default": false,
+                "description": "Show register variables in variables pane."
+            },
+            "hideSystemGoroutines": {
+                "type": "boolean",
+                "default": false,
+                "description": "Hide system goroutines from call stack view."
+            },
+            "console": {
+                "default": "internalConsole",
+                "description": "Where to launch the debugger.",
+                "enum": ["internalConsole", "integratedTerminal"]
+            },
+            "asRoot": {
+                "default": false,
+                "description": "Debug with elevated permissions (on Unix).",
+                "type": "boolean"
+            }
+        });
+
+        // Create launch-specific properties
+        let launch_properties = json!({
+            "program": {
+                "type": "string",
+                "description": "Path to the program folder or file to debug.",
+                "default": "${ZED_WORKTREE_ROOT}"
+            },
+            "args": {
+                "type": ["array", "string"],
+                "description": "Command line arguments for the program.",
+                "items": {
+                    "type": "string"
+                },
+                "default": []
+            },
+            "env": {
+                "type": "object",
+                "description": "Environment variables for the debugged program.",
+                "default": {}
+            },
+            "envFile": {
+                "type": ["string", "array"],
+                "items": {
+                    "type": "string"
+                },
+                "description": "Path(s) to files with environment variables.",
+                "default": ""
+            },
+            "buildFlags": {
+                "type": ["string", "array"],
+                "items": {
+                    "type": "string"
+                },
+                "description": "Flags for the Go compiler.",
+                "default": []
+            },
+            "output": {
+                "type": "string",
+                "description": "Output path for the binary.",
+                "default": "debug"
+            },
+            "mode": {
+                "enum": [ "debug", "test", "exec", "replay", "core"],
+                "description": "Debug mode for launch configuration.",
+            },
+            "traceDirPath": {
+                "type": "string",
+                "description": "Directory for record trace (for 'replay' mode).",
+                "default": ""
+            },
+            "coreFilePath": {
+                "type": "string",
+                "description": "Path to core dump file (for 'core' mode).",
+                "default": ""
+            }
+        });
+
+        // Create attach-specific properties
+        let attach_properties = json!({
+            "processId": {
+                "anyOf": [
+                    {
+                        "enum": ["${command:pickProcess}", "${command:pickGoProcess}"],
+                        "description": "Use process picker to select a process."
+                    },
+                    {
+                        "type": "string",
+                        "description": "Process name to attach to."
+                    },
+                    {
+                        "type": "number",
+                        "description": "Process ID to attach to."
+                    }
+                ],
+                "default": 0
+            },
+            "mode": {
+                "enum": ["local", "remote"],
+                "description": "Local or remote debugging.",
+                "default": "local"
+            },
+            "remotePath": {
+                "type": "string",
+                "description": "Path to source on remote machine.",
+                "markdownDeprecationMessage": "Use `substitutePath` instead.",
+                "default": ""
+            }
+        });
+
+        // Create the final schema
+        json!({
+            "oneOf": [
+                {
+                    "allOf": [
+                        {
+                            "type": "object",
+                            "required": ["request"],
+                            "properties": {
+                                "request": {
+                                    "type": "string",
+                                    "enum": ["launch"],
+                                    "description": "Request to launch a new process"
+                                }
+                            }
+                        },
+                        {
+                            "type": "object",
+                            "properties": common_properties
+                        },
+                        {
+                            "type": "object",
+                            "required": ["program", "mode"],
+                            "properties": launch_properties
+                        }
+                    ]
+                },
+                {
+                    "allOf": [
+                        {
+                            "type": "object",
+                            "required": ["request"],
+                            "properties": {
+                                "request": {
+                                    "type": "string",
+                                    "enum": ["attach"],
+                                    "description": "Request to attach to an existing process"
+                                }
+                            }
+                        },
+                        {
+                            "type": "object",
+                            "properties": common_properties
+                        },
+                        {
+                            "type": "object",
+                            "required": ["mode"],
+                            "properties": attach_properties
+                        }
+                    ]
+                }
+            ]
+        })
+    }
+
+    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
+        let mut args = match &zed_scenario.request {
             dap::DebugRequest::Attach(attach_config) => {
                 json!({
+                    "request": "attach",
+                    "mode": "debug",
                     "processId": attach_config.process_id,
                 })
             }
-            dap::DebugRequest::Launch(launch_config) => json!({
-                "program": launch_config.program,
-                "cwd": launch_config.cwd,
-                "args": launch_config.args
-            }),
+            dap::DebugRequest::Launch(launch_config) => {
+                let mode = if launch_config.program != "." {
+                    "exec"
+                } else {
+                    "debug"
+                };
+
+                json!({
+                    "request": "launch",
+                    "mode": mode,
+                    "program": launch_config.program,
+                    "cwd": launch_config.cwd,
+                    "args": launch_config.args,
+                    "env": launch_config.env_json()
+                })
+            }
         };
 
         let map = args.as_object_mut().unwrap();
 
-        if let Some(stop_on_entry) = config.stop_on_entry {
+        if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
             map.insert("stopOnEntry".into(), stop_on_entry.into());
         }
 
-        StartDebuggingRequestArguments {
-            configuration: args,
-            request: config.request.to_dap(),
-        }
-    }
-}
-
-#[async_trait(?Send)]
-impl DebugAdapter for GoDebugAdapter {
-    fn name(&self) -> DebugAdapterName {
-        DebugAdapterName(Self::ADAPTER_NAME.into())
+        Ok(DebugScenario {
+            adapter: zed_scenario.adapter,
+            label: zed_scenario.label,
+            build: None,
+            config: args,
+            tcp_connection: None,
+        })
     }
 
     async fn get_binary(
         &self,
-        delegate: &dyn DapDelegate,
-        config: &DebugTaskDefinition,
-        _user_installed_path: Option<PathBuf>,
+        delegate: &Arc<dyn DapDelegate>,
+        task_definition: &DebugTaskDefinition,
+        user_installed_path: Option<PathBuf>,
+        user_args: Option<Vec<String>>,
         _cx: &mut AsyncApp,
     ) -> Result<DebugAdapterBinary> {
-        let delve_path = delegate
-            .which(OsStr::new("dlv"))
-            .and_then(|p| p.to_str().map(|p| p.to_string()))
-            .ok_or(anyhow!("Dlv not found in path"))?;
+        let adapter_path = paths::debug_adapters_dir().join(&Self::ADAPTER_NAME);
+        let dlv_path = adapter_path.join("dlv");
 
-        let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
-        let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
+        let delve_path = if let Some(path) = user_installed_path {
+            path.to_string_lossy().to_string()
+        } else if let Some(path) = delegate.which(OsStr::new("dlv")).await {
+            path.to_string_lossy().to_string()
+        } else if delegate.fs().is_file(&dlv_path).await {
+            dlv_path.to_string_lossy().to_string()
+        } else {
+            let go = delegate
+                .which(OsStr::new("go"))
+                .await
+                .context("Go not found in path. Please install Go first, then Dlv will be installed automatically.")?;
 
-        Ok(DebugAdapterBinary {
-            command: delve_path,
-            arguments: vec![
-                "dap".into(),
-                "--listen".into(),
-                format!("{}:{}", host, port),
-            ],
-            cwd: None,
-            envs: HashMap::default(),
-            connection: Some(adapters::TcpArguments {
+            let adapter_path = paths::debug_adapters_dir().join(&Self::ADAPTER_NAME);
+
+            let install_output = util::command::new_smol_command(&go)
+                .env("GO111MODULE", "on")
+                .env("GOBIN", &adapter_path)
+                .args(&["install", "github.com/go-delve/delve/cmd/dlv@latest"])
+                .output()
+                .await?;
+
+            if !install_output.status.success() {
+                bail!(
+                    "failed to install dlv via `go install`. stdout: {:?}, stderr: {:?}\n Please try installing it manually using 'go install github.com/go-delve/delve/cmd/dlv@latest'",
+                    String::from_utf8_lossy(&install_output.stdout),
+                    String::from_utf8_lossy(&install_output.stderr)
+                );
+            }
+
+            adapter_path.join("dlv").to_string_lossy().to_string()
+        };
+
+        let cwd = task_definition
+            .config
+            .get("cwd")
+            .and_then(|s| s.as_str())
+            .map(PathBuf::from)
+            .unwrap_or_else(|| delegate.worktree_root_path().to_path_buf());
+
+        let arguments;
+        let command;
+        let connection;
+
+        let mut configuration = task_definition.config.clone();
+        if let Some(configuration) = configuration.as_object_mut() {
+            configuration
+                .entry("cwd")
+                .or_insert_with(|| delegate.worktree_root_path().to_string_lossy().into());
+        }
+
+        if let Some(connection_options) = &task_definition.tcp_connection {
+            command = None;
+            arguments = vec![];
+            let (host, port, timeout) =
+                crate::configure_tcp_connection(connection_options.clone()).await?;
+            connection = Some(TcpArguments {
                 host,
                 port,
                 timeout,
-            }),
-            request_args: self.request_args(config),
+            });
+        } else {
+            let minidelve_path = self.install_shim(delegate).await?;
+            let (host, port, _) =
+                crate::configure_tcp_connection(TcpArgumentsTemplate::default()).await?;
+            command = Some(minidelve_path.to_string_lossy().into_owned());
+            connection = None;
+            arguments = if let Some(mut args) = user_args {
+                args.insert(0, delve_path);
+                args
+            } else if cfg!(windows) {
+                vec![
+                    delve_path,
+                    "dap".into(),
+                    "--listen".into(),
+                    format!("{}:{}", host, port),
+                    "--headless".into(),
+                ]
+            } else {
+                vec![
+                    delve_path,
+                    "dap".into(),
+                    "--listen".into(),
+                    format!("{}:{}", host, port),
+                ]
+            };
+        }
+        Ok(DebugAdapterBinary {
+            command,
+            arguments,
+            cwd: Some(cwd),
+            envs: HashMap::default(),
+            connection,
+            request_args: StartDebuggingRequestArguments {
+                configuration,
+                request: self.request_kind(&task_definition.config).await?,
+            },
         })
     }
 }

crates/dap_adapters/src/javascript.rs 🔗

@@ -1,9 +1,11 @@
 use adapters::latest_github_release;
+use anyhow::Context as _;
 use dap::{StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
 use gpui::AsyncApp;
+use serde_json::Value;
 use std::{collections::HashMap, path::PathBuf, sync::OnceLock};
 use task::DebugRequest;
-use util::ResultExt;
+use util::{ResultExt, maybe};
 
 use crate::*;
 
@@ -17,46 +19,12 @@ impl JsDebugAdapter {
     const ADAPTER_NPM_NAME: &'static str = "vscode-js-debug";
     const ADAPTER_PATH: &'static str = "js-debug/src/dapDebugServer.js";
 
-    fn request_args(&self, config: &DebugTaskDefinition) -> StartDebuggingRequestArguments {
-        let mut args = json!({
-            "type": "pwa-node",
-            "request": match config.request {
-                DebugRequest::Launch(_) => "launch",
-                DebugRequest::Attach(_) => "attach",
-            },
-        });
-        let map = args.as_object_mut().unwrap();
-        match &config.request {
-            DebugRequest::Attach(attach) => {
-                map.insert("processId".into(), attach.process_id.into());
-            }
-            DebugRequest::Launch(launch) => {
-                map.insert("program".into(), launch.program.clone().into());
-
-                if !launch.args.is_empty() {
-                    map.insert("args".into(), launch.args.clone().into());
-                }
-
-                if let Some(stop_on_entry) = config.stop_on_entry {
-                    map.insert("stopOnEntry".into(), stop_on_entry.into());
-                }
-                if let Some(cwd) = launch.cwd.as_ref() {
-                    map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
-                }
-            }
-        }
-        StartDebuggingRequestArguments {
-            configuration: args,
-            request: config.request.to_dap(),
-        }
-    }
-
     async fn fetch_latest_adapter_version(
         &self,
-        delegate: &dyn DapDelegate,
+        delegate: &Arc<dyn DapDelegate>,
     ) -> Result<AdapterVersion> {
         let release = latest_github_release(
-            &format!("{}/{}", "microsoft", Self::ADAPTER_NPM_NAME),
+            &format!("microsoft/{}", Self::ADAPTER_NPM_NAME),
             true,
             false,
             delegate.http_client(),
@@ -71,7 +39,7 @@ impl JsDebugAdapter {
                 .assets
                 .iter()
                 .find(|asset| asset.name == asset_name)
-                .ok_or_else(|| anyhow!("no asset found matching {:?}", asset_name))?
+                .with_context(|| format!("no asset found matching {asset_name:?}"))?
                 .browser_download_url
                 .clone(),
         })
@@ -79,9 +47,10 @@ impl JsDebugAdapter {
 
     async fn get_installed_binary(
         &self,
-        delegate: &dyn DapDelegate,
-        config: &DebugTaskDefinition,
+        delegate: &Arc<dyn DapDelegate>,
+        task_definition: &DebugTaskDefinition,
         user_installed_path: Option<PathBuf>,
+        user_args: Option<Vec<String>>,
         _: &mut AsyncApp,
     ) -> Result<DebugAdapterBinary> {
         let adapter_path = if let Some(user_installed_path) = user_installed_path {
@@ -95,35 +64,110 @@ impl JsDebugAdapter {
                 file_name.starts_with(&file_name_prefix)
             })
             .await
-            .ok_or_else(|| anyhow!("Couldn't find JavaScript dap directory"))?
+            .context("Couldn't find JavaScript dap directory")?
         };
 
-        let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
+        let tcp_connection = task_definition.tcp_connection.clone().unwrap_or_default();
         let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
 
-        Ok(DebugAdapterBinary {
-            command: delegate
-                .node_runtime()
-                .binary_path()
-                .await?
-                .to_string_lossy()
-                .into_owned(),
-            arguments: vec![
+        let mut configuration = task_definition.config.clone();
+        if let Some(configuration) = configuration.as_object_mut() {
+            maybe!({
+                configuration
+                    .get("type")
+                    .filter(|value| value == &"node-terminal")?;
+                let command = configuration.get("command")?.as_str()?.to_owned();
+                let mut args = shlex::split(&command)?.into_iter();
+                let program = args.next()?;
+                configuration.insert("program".to_owned(), program.into());
+                configuration.insert(
+                    "args".to_owned(),
+                    args.map(Value::from).collect::<Vec<_>>().into(),
+                );
+                configuration.insert("console".to_owned(), "externalTerminal".into());
+                Some(())
+            });
+
+            configuration.entry("type").and_modify(normalize_task_type);
+
+            if let Some(program) = configuration
+                .get("program")
+                .cloned()
+                .and_then(|value| value.as_str().map(str::to_owned))
+            {
+                match program.as_str() {
+                    "npm" | "pnpm" | "yarn" | "bun"
+                        if !configuration.contains_key("runtimeExecutable")
+                            && !configuration.contains_key("runtimeArgs") =>
+                    {
+                        configuration.remove("program");
+                        configuration.insert("runtimeExecutable".to_owned(), program.into());
+                        if let Some(args) = configuration.remove("args") {
+                            configuration.insert("runtimeArgs".to_owned(), args);
+                        }
+                    }
+                    _ => {}
+                }
+            }
+
+            configuration
+                .entry("cwd")
+                .or_insert(delegate.worktree_root_path().to_string_lossy().into());
+
+            configuration
+                .entry("console")
+                .or_insert("externalTerminal".into());
+
+            configuration.entry("sourceMaps").or_insert(true.into());
+            configuration
+                .entry("pauseForSourceMap")
+                .or_insert(true.into());
+            configuration
+                .entry("sourceMapRenames")
+                .or_insert(true.into());
+        }
+
+        let arguments = if let Some(mut args) = user_args {
+            args.insert(
+                0,
+                adapter_path
+                    .join(Self::ADAPTER_PATH)
+                    .to_string_lossy()
+                    .to_string(),
+            );
+            args
+        } else {
+            vec![
                 adapter_path
                     .join(Self::ADAPTER_PATH)
                     .to_string_lossy()
                     .to_string(),
                 port.to_string(),
                 host.to_string(),
-            ],
-            cwd: None,
+            ]
+        };
+
+        Ok(DebugAdapterBinary {
+            command: Some(
+                delegate
+                    .node_runtime()
+                    .binary_path()
+                    .await?
+                    .to_string_lossy()
+                    .into_owned(),
+            ),
+            arguments,
+            cwd: Some(delegate.worktree_root_path().to_path_buf()),
             envs: HashMap::default(),
             connection: Some(adapters::TcpArguments {
                 host,
                 port,
                 timeout,
             }),
-            request_args: self.request_args(config),
+            request_args: StartDebuggingRequestArguments {
+                configuration,
+                request: self.request_kind(&task_definition.config).await?,
+            },
         })
     }
 }
@@ -134,11 +178,324 @@ impl DebugAdapter for JsDebugAdapter {
         DebugAdapterName(Self::ADAPTER_NAME.into())
     }
 
+    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
+        let mut args = json!({
+            "type": "pwa-node",
+            "request": match zed_scenario.request {
+                DebugRequest::Launch(_) => "launch",
+                DebugRequest::Attach(_) => "attach",
+            },
+        });
+
+        let map = args.as_object_mut().unwrap();
+        match &zed_scenario.request {
+            DebugRequest::Attach(attach) => {
+                map.insert("processId".into(), attach.process_id.into());
+            }
+            DebugRequest::Launch(launch) => {
+                if launch.program.starts_with("http://") {
+                    map.insert("url".into(), launch.program.clone().into());
+                } else {
+                    map.insert("program".into(), launch.program.clone().into());
+                }
+
+                if !launch.args.is_empty() {
+                    map.insert("args".into(), launch.args.clone().into());
+                }
+                if !launch.env.is_empty() {
+                    map.insert("env".into(), launch.env_json());
+                }
+
+                if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
+                    map.insert("stopOnEntry".into(), stop_on_entry.into());
+                }
+                if let Some(cwd) = launch.cwd.as_ref() {
+                    map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
+                }
+            }
+        };
+
+        Ok(DebugScenario {
+            adapter: zed_scenario.adapter,
+            label: zed_scenario.label,
+            build: None,
+            config: args,
+            tcp_connection: None,
+        })
+    }
+
+    fn dap_schema(&self) -> serde_json::Value {
+        json!({
+            "oneOf": [
+                {
+                    "allOf": [
+                        {
+                            "type": "object",
+                            "required": ["request"],
+                            "properties": {
+                                "request": {
+                                    "type": "string",
+                                    "enum": ["launch"],
+                                    "description": "Request to launch a new process"
+                                }
+                            }
+                        },
+                        {
+                            "type": "object",
+                            "properties": {
+                                "type": {
+                                    "type": "string",
+                                    "enum": ["pwa-node", "node", "chrome", "pwa-chrome", "msedge", "pwa-msedge"],
+                                    "description": "The type of debug session",
+                                    "default": "pwa-node"
+                                },
+                                "program": {
+                                    "type": "string",
+                                    "description": "Path to the program or file to debug"
+                                },
+                                "cwd": {
+                                    "type": "string",
+                                    "description": "Absolute path to the working directory of the program being debugged"
+                                },
+                                "args": {
+                                    "type": ["array", "string"],
+                                    "description": "Command line arguments passed to the program",
+                                    "items": {
+                                        "type": "string"
+                                    },
+                                    "default": []
+                                },
+                                "env": {
+                                    "type": "object",
+                                    "description": "Environment variables passed to the program",
+                                    "default": {}
+                                },
+                                "envFile": {
+                                    "type": ["string", "array"],
+                                    "description": "Path to a file containing environment variable definitions",
+                                    "items": {
+                                        "type": "string"
+                                    }
+                                },
+                                "stopOnEntry": {
+                                    "type": "boolean",
+                                    "description": "Automatically stop program after launch",
+                                    "default": false
+                                },
+                                "runtimeExecutable": {
+                                    "type": ["string", "null"],
+                                    "description": "Runtime to use, an absolute path or the name of a runtime available on PATH",
+                                    "default": "node"
+                                },
+                                "runtimeArgs": {
+                                    "type": ["array", "null"],
+                                    "description": "Arguments passed to the runtime executable",
+                                    "items": {
+                                        "type": "string"
+                                    },
+                                    "default": []
+                                },
+                                "outFiles": {
+                                    "type": "array",
+                                    "description": "Glob patterns for locating generated JavaScript files",
+                                    "items": {
+                                        "type": "string"
+                                    },
+                                    "default": ["${ZED_WORKTREE_ROOT}/**/*.js", "!**/node_modules/**"]
+                                },
+                                "sourceMaps": {
+                                    "type": "boolean",
+                                    "description": "Use JavaScript source maps if they exist",
+                                    "default": true
+                                },
+                                "pauseForSourceMap": {
+                                    "type": "boolean",
+                                    "description": "Wait for source maps to load before setting breakpoints.",
+                                    "default": true
+                                },
+                                "sourceMapRenames": {
+                                    "type": "boolean",
+                                    "description": "Whether to use the \"names\" mapping in sourcemaps.",
+                                    "default": true
+                                },
+                                "sourceMapPathOverrides": {
+                                    "type": "object",
+                                    "description": "Rewrites the locations of source files from what the sourcemap says to their locations on disk",
+                                    "default": {}
+                                },
+                                "restart": {
+                                    "type": ["boolean", "object"],
+                                    "description": "Restart session after Node.js has terminated",
+                                    "default": false
+                                },
+                                "trace": {
+                                    "type": ["boolean", "object"],
+                                    "description": "Enables logging of the Debug Adapter",
+                                    "default": false
+                                },
+                                "console": {
+                                    "type": "string",
+                                    "enum": ["internalConsole", "integratedTerminal"],
+                                    "description": "Where to launch the debug target",
+                                    "default": "internalConsole"
+                                },
+                                // Browser-specific
+                                "url": {
+                                    "type": ["string", "null"],
+                                    "description": "Will navigate to this URL and attach to it (browser debugging)"
+                                },
+                                "webRoot": {
+                                    "type": "string",
+                                    "description": "Workspace absolute path to the webserver root",
+                                    "default": "${ZED_WORKTREE_ROOT}"
+                                },
+                                "userDataDir": {
+                                    "type": ["string", "boolean"],
+                                    "description": "Path to a custom Chrome user profile (browser debugging)",
+                                    "default": true
+                                },
+                                "skipFiles": {
+                                    "type": "array",
+                                    "description": "An array of glob patterns for files to skip when debugging",
+                                    "items": {
+                                        "type": "string"
+                                    },
+                                    "default": ["<node_internals>/**"]
+                                },
+                                "timeout": {
+                                    "type": "number",
+                                    "description": "Retry for this number of milliseconds to connect to the debug adapter",
+                                    "default": 10000
+                                },
+                                "resolveSourceMapLocations": {
+                                    "type": ["array", "null"],
+                                    "description": "A list of minimatch patterns for source map resolution",
+                                    "items": {
+                                        "type": "string"
+                                    }
+                                }
+                            },
+                            "oneOf": [
+                                { "required": ["program"] },
+                                { "required": ["url"] }
+                            ]
+                        }
+                    ]
+                },
+                {
+                    "allOf": [
+                        {
+                            "type": "object",
+                            "required": ["request"],
+                            "properties": {
+                                "request": {
+                                    "type": "string",
+                                    "enum": ["attach"],
+                                    "description": "Request to attach to an existing process"
+                                }
+                            }
+                        },
+                        {
+                            "type": "object",
+                            "properties": {
+                                "type": {
+                                    "type": "string",
+                                    "enum": ["pwa-node", "node", "chrome", "pwa-chrome", "edge", "pwa-edge"],
+                                    "description": "The type of debug session",
+                                    "default": "pwa-node"
+                                },
+                                "processId": {
+                                    "type": ["string", "number"],
+                                    "description": "ID of process to attach to (Node.js debugging)"
+                                },
+                                "port": {
+                                    "type": "number",
+                                    "description": "Debug port to attach to",
+                                    "default": 9229
+                                },
+                                "address": {
+                                    "type": "string",
+                                    "description": "TCP/IP address of the process to be debugged",
+                                    "default": "localhost"
+                                },
+                                "restart": {
+                                    "type": ["boolean", "object"],
+                                    "description": "Restart session after Node.js has terminated",
+                                    "default": false
+                                },
+                                "sourceMaps": {
+                                    "type": "boolean",
+                                    "description": "Use JavaScript source maps if they exist",
+                                    "default": true
+                                },
+                                "sourceMapPathOverrides": {
+                                    "type": "object",
+                                    "description": "Rewrites the locations of source files from what the sourcemap says to their locations on disk",
+                                    "default": {}
+                                },
+                                "outFiles": {
+                                    "type": "array",
+                                    "description": "Glob patterns for locating generated JavaScript files",
+                                    "items": {
+                                        "type": "string"
+                                    },
+                                    "default": ["${ZED_WORKTREE_ROOT}/**/*.js", "!**/node_modules/**"]
+                                },
+                                "url": {
+                                    "type": "string",
+                                    "description": "Will search for a page with this URL and attach to it (browser debugging)"
+                                },
+                                "webRoot": {
+                                    "type": "string",
+                                    "description": "Workspace absolute path to the webserver root",
+                                    "default": "${ZED_WORKTREE_ROOT}"
+                                },
+                                "skipFiles": {
+                                    "type": "array",
+                                    "description": "An array of glob patterns for files to skip when debugging",
+                                    "items": {
+                                        "type": "string"
+                                    },
+                                    "default": ["<node_internals>/**"]
+                                },
+                                "timeout": {
+                                    "type": "number",
+                                    "description": "Retry for this number of milliseconds to connect to the debug adapter",
+                                    "default": 10000
+                                },
+                                "resolveSourceMapLocations": {
+                                    "type": ["array", "null"],
+                                    "description": "A list of minimatch patterns for source map resolution",
+                                    "items": {
+                                        "type": "string"
+                                    }
+                                },
+                                "remoteRoot": {
+                                    "type": ["string", "null"],
+                                    "description": "Path to the remote directory containing the program"
+                                },
+                                "localRoot": {
+                                    "type": ["string", "null"],
+                                    "description": "Path to the local directory containing the program"
+                                }
+                            },
+                            "oneOf": [
+                                { "required": ["processId"] },
+                                { "required": ["port"] }
+                            ]
+                        }
+                    ]
+                }
+            ]
+        })
+    }
+
     async fn get_binary(
         &self,
-        delegate: &dyn DapDelegate,
+        delegate: &Arc<dyn DapDelegate>,
         config: &DebugTaskDefinition,
         user_installed_path: Option<PathBuf>,
+        user_args: Option<Vec<String>>,
         cx: &mut AsyncApp,
     ) -> Result<DebugAdapterBinary> {
         if self.checked.set(()).is_ok() {
@@ -148,13 +505,36 @@ impl DebugAdapter for JsDebugAdapter {
                     self.name(),
                     version,
                     adapters::DownloadedFileType::GzipTar,
-                    delegate,
+                    delegate.as_ref(),
                 )
                 .await?;
+            } else {
+                delegate.output_to_console(format!("{} debug adapter is up to date", self.name()));
             }
         }
 
-        self.get_installed_binary(delegate, &config, user_installed_path, cx)
+        self.get_installed_binary(delegate, &config, user_installed_path, user_args, cx)
             .await
     }
+
+    fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option<String> {
+        let label = args.configuration.get("name")?.as_str()?;
+        Some(label.to_owned())
+    }
+}
+
+fn normalize_task_type(task_type: &mut Value) {
+    let Some(task_type_str) = task_type.as_str() else {
+        return;
+    };
+
+    let new_name = match task_type_str {
+        "node" | "pwa-node" | "node-terminal" => "pwa-node",
+        "chrome" | "pwa-chrome" => "pwa-chrome",
+        "edge" | "msedge" | "pwa-edge" | "pwa-msedge" => "pwa-msedge",
+        _ => task_type_str,
+    }
+    .to_owned();
+
+    *task_type = Value::String(new_name);
 }

crates/dap_adapters/src/php.rs 🔗

@@ -1,6 +1,11 @@
 use adapters::latest_github_release;
+use anyhow::Context as _;
+use anyhow::bail;
+use dap::StartDebuggingRequestArguments;
+use dap::StartDebuggingRequestArgumentsRequest;
 use dap::adapters::{DebugTaskDefinition, TcpArguments};
-use gpui::AsyncApp;
+use gpui::{AsyncApp, SharedString};
+use language::LanguageName;
 use std::{collections::HashMap, path::PathBuf, sync::OnceLock};
 use util::ResultExt;
 
@@ -16,29 +21,9 @@ impl PhpDebugAdapter {
     const ADAPTER_PACKAGE_NAME: &'static str = "vscode-php-debug";
     const ADAPTER_PATH: &'static str = "extension/out/phpDebug.js";
 
-    fn request_args(
-        &self,
-        config: &DebugTaskDefinition,
-    ) -> Result<dap::StartDebuggingRequestArguments> {
-        match &config.request {
-            dap::DebugRequest::Attach(_) => {
-                anyhow::bail!("php adapter does not support attaching")
-            }
-            dap::DebugRequest::Launch(launch_config) => Ok(dap::StartDebuggingRequestArguments {
-                configuration: json!({
-                    "program": launch_config.program,
-                    "cwd": launch_config.cwd,
-                    "args": launch_config.args,
-                    "stopOnEntry": config.stop_on_entry.unwrap_or_default(),
-                }),
-                request: config.request.to_dap(),
-            }),
-        }
-    }
-
     async fn fetch_latest_adapter_version(
         &self,
-        delegate: &dyn DapDelegate,
+        delegate: &Arc<dyn DapDelegate>,
     ) -> Result<AdapterVersion> {
         let release = latest_github_release(
             &format!("{}/{}", "xdebug", Self::ADAPTER_PACKAGE_NAME),
@@ -56,7 +41,7 @@ impl PhpDebugAdapter {
                 .assets
                 .iter()
                 .find(|asset| asset.name == asset_name)
-                .ok_or_else(|| anyhow!("no asset found matching {:?}", asset_name))?
+                .with_context(|| format!("no asset found matching {asset_name:?}"))?
                 .browser_download_url
                 .clone(),
         })
@@ -64,9 +49,10 @@ impl PhpDebugAdapter {
 
     async fn get_installed_binary(
         &self,
-        delegate: &dyn DapDelegate,
-        config: &DebugTaskDefinition,
+        delegate: &Arc<dyn DapDelegate>,
+        task_definition: &DebugTaskDefinition,
         user_installed_path: Option<PathBuf>,
+        user_args: Option<Vec<String>>,
         _: &mut AsyncApp,
     ) -> Result<DebugAdapterBinary> {
         let adapter_path = if let Some(user_installed_path) = user_installed_path {
@@ -80,49 +66,281 @@ impl PhpDebugAdapter {
                 file_name.starts_with(&file_name_prefix)
             })
             .await
-            .ok_or_else(|| anyhow!("Couldn't find PHP dap directory"))?
+            .context("Couldn't find PHP dap directory")?
         };
 
-        let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
+        let tcp_connection = task_definition.tcp_connection.clone().unwrap_or_default();
         let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
 
-        Ok(DebugAdapterBinary {
-            command: delegate
-                .node_runtime()
-                .binary_path()
-                .await?
-                .to_string_lossy()
-                .into_owned(),
-            arguments: vec![
+        let mut configuration = task_definition.config.clone();
+        if let Some(obj) = configuration.as_object_mut() {
+            obj.entry("cwd")
+                .or_insert_with(|| delegate.worktree_root_path().to_string_lossy().into());
+        }
+
+        let arguments = if let Some(mut args) = user_args {
+            args.insert(
+                0,
+                adapter_path
+                    .join(Self::ADAPTER_PATH)
+                    .to_string_lossy()
+                    .to_string(),
+            );
+            args
+        } else {
+            vec![
                 adapter_path
                     .join(Self::ADAPTER_PATH)
                     .to_string_lossy()
                     .to_string(),
                 format!("--server={}", port),
-            ],
+            ]
+        };
+
+        Ok(DebugAdapterBinary {
+            command: Some(
+                delegate
+                    .node_runtime()
+                    .binary_path()
+                    .await?
+                    .to_string_lossy()
+                    .into_owned(),
+            ),
+            arguments,
             connection: Some(TcpArguments {
                 port,
                 host,
                 timeout,
             }),
-            cwd: None,
+            cwd: Some(delegate.worktree_root_path().to_path_buf()),
             envs: HashMap::default(),
-            request_args: self.request_args(config)?,
+            request_args: StartDebuggingRequestArguments {
+                configuration,
+                request: <Self as DebugAdapter>::request_kind(self, &task_definition.config)
+                    .await?,
+            },
         })
     }
 }
 
 #[async_trait(?Send)]
 impl DebugAdapter for PhpDebugAdapter {
+    fn dap_schema(&self) -> serde_json::Value {
+        json!({
+            "properties": {
+                "request": {
+                    "type": "string",
+                    "enum": ["launch"],
+                    "description": "The request type for the PHP debug adapter, always \"launch\"",
+                    "default": "launch"
+                },
+                "hostname": {
+                    "type": "string",
+                    "description": "The address to bind to when listening for Xdebug (default: all IPv6 connections if available, else all IPv4 connections) or Unix Domain socket (prefix with unix://) or Windows Pipe (\\\\?\\pipe\\name) - cannot be combined with port"
+                },
+                "port": {
+                    "type": "integer",
+                    "description": "The port on which to listen for Xdebug (default: 9003). If port is set to 0 a random port is chosen by the system and a placeholder ${port} is replaced with the chosen port in env and runtimeArgs.",
+                    "default": 9003
+                },
+                "program": {
+                    "type": "string",
+                    "description": "The PHP script to debug (typically a path to a file)",
+                    "default": "${file}"
+                },
+                "cwd": {
+                    "type": "string",
+                    "description": "Working directory for the debugged program"
+                },
+                "args": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    },
+                    "description": "Command line arguments to pass to the program"
+                },
+                "env": {
+                    "type": "object",
+                    "description": "Environment variables to pass to the program",
+                    "additionalProperties": {
+                        "type": "string"
+                    }
+                },
+                "stopOnEntry": {
+                    "type": "boolean",
+                    "description": "Whether to break at the beginning of the script",
+                    "default": false
+                },
+                "pathMappings": {
+                    "type": "object",
+                    "description": "A mapping of server paths to local paths.",
+                },
+                "log": {
+                    "type": "boolean",
+                    "description": "Whether to log all communication between editor and the adapter to the debug console",
+                    "default": false
+                },
+                "ignore": {
+                    "type": "array",
+                    "description": "An array of glob patterns that errors should be ignored from (for example **/vendor/**/*.php)",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "ignoreExceptions": {
+                    "type": "array",
+                    "description": "An array of exception class names that should be ignored (for example BaseException, \\NS1\\Exception, \\*\\Exception or \\**\\Exception*)",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "skipFiles": {
+                    "type": "array",
+                    "description": "An array of glob patterns to skip when debugging. Star patterns and negations are allowed.",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "skipEntryPaths": {
+                    "type": "array",
+                    "description": "An array of glob patterns to immediately detach from and ignore for debugging if the entry script matches",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "maxConnections": {
+                    "type": "integer",
+                    "description": "Accept only this number of parallel debugging sessions. Additional connections will be dropped.",
+                    "default": 1
+                },
+                "proxy": {
+                    "type": "object",
+                    "description": "DBGp Proxy settings",
+                    "properties": {
+                        "enable": {
+                            "type": "boolean",
+                            "description": "To enable proxy registration",
+                            "default": false
+                        },
+                        "host": {
+                            "type": "string",
+                            "description": "The address of the proxy. Supports host name, IP address, or Unix domain socket.",
+                            "default": "127.0.0.1"
+                        },
+                        "port": {
+                            "type": "integer",
+                            "description": "The port where the adapter will register with the proxy",
+                            "default": 9001
+                        },
+                        "key": {
+                            "type": "string",
+                            "description": "A unique key that allows the proxy to match requests to your editor",
+                            "default": "vsc"
+                        },
+                        "timeout": {
+                            "type": "integer",
+                            "description": "The number of milliseconds to wait before giving up on the connection to proxy",
+                            "default": 3000
+                        },
+                        "allowMultipleSessions": {
+                            "type": "boolean",
+                            "description": "If the proxy should forward multiple sessions/connections at the same time or not",
+                            "default": true
+                        }
+                    }
+                },
+                "xdebugSettings": {
+                    "type": "object",
+                    "description": "Allows you to override Xdebug's remote debugging settings to fine tune Xdebug to your needs",
+                    "properties": {
+                        "max_children": {
+                            "type": "integer",
+                            "description": "Max number of array or object children to initially retrieve"
+                        },
+                        "max_data": {
+                            "type": "integer",
+                            "description": "Max amount of variable data to initially retrieve"
+                        },
+                        "max_depth": {
+                            "type": "integer",
+                            "description": "Maximum depth that the debugger engine may return when sending arrays, hashes or object structures to the IDE"
+                        },
+                        "show_hidden": {
+                            "type": "integer",
+                            "description": "Whether to show detailed internal information on properties (e.g. private members of classes). Zero means hidden members are not shown.",
+                            "enum": [0, 1]
+                        },
+                        "breakpoint_include_return_value": {
+                            "type": "boolean",
+                            "description": "Determines whether to enable an additional \"return from function\" debugging step, allowing inspection of the return value when a function call returns"
+                        }
+                    }
+                },
+                "xdebugCloudToken": {
+                    "type": "string",
+                    "description": "Instead of listening locally, open a connection and register with Xdebug Cloud and accept debugging sessions on that connection"
+                },
+                "stream": {
+                    "type": "object",
+                    "description": "Allows to influence DBGp streams. Xdebug only supports stdout",
+                    "properties": {
+                        "stdout": {
+                            "type": "integer",
+                            "description": "Redirect stdout stream: 0 (disable), 1 (copy), 2 (redirect)",
+                            "enum": [0, 1, 2],
+                            "default": 0
+                        }
+                    }
+                }
+            },
+            "required": ["request", "program"]
+        })
+    }
+
     fn name(&self) -> DebugAdapterName {
         DebugAdapterName(Self::ADAPTER_NAME.into())
     }
 
+    fn adapter_language_name(&self) -> Option<LanguageName> {
+        Some(SharedString::new_static("PHP").into())
+    }
+
+    async fn request_kind(
+        &self,
+        _: &serde_json::Value,
+    ) -> Result<StartDebuggingRequestArgumentsRequest> {
+        Ok(StartDebuggingRequestArgumentsRequest::Launch)
+    }
+
+    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
+        let obj = match &zed_scenario.request {
+            dap::DebugRequest::Attach(_) => {
+                bail!("Php adapter doesn't support attaching")
+            }
+            dap::DebugRequest::Launch(launch_config) => json!({
+                "program": launch_config.program,
+                "cwd": launch_config.cwd,
+                "args": launch_config.args,
+                "env": launch_config.env_json(),
+                "stopOnEntry": zed_scenario.stop_on_entry.unwrap_or_default(),
+            }),
+        };
+
+        Ok(DebugScenario {
+            adapter: zed_scenario.adapter,
+            label: zed_scenario.label,
+            build: None,
+            config: obj,
+            tcp_connection: None,
+        })
+    }
+
     async fn get_binary(
         &self,
-        delegate: &dyn DapDelegate,
-        config: &DebugTaskDefinition,
+        delegate: &Arc<dyn DapDelegate>,
+        task_definition: &DebugTaskDefinition,
         user_installed_path: Option<PathBuf>,
+        user_args: Option<Vec<String>>,
         cx: &mut AsyncApp,
     ) -> Result<DebugAdapterBinary> {
         if self.checked.set(()).is_ok() {
@@ -132,13 +350,19 @@ impl DebugAdapter for PhpDebugAdapter {
                     self.name(),
                     version,
                     adapters::DownloadedFileType::Vsix,
-                    delegate,
+                    delegate.as_ref(),
                 )
                 .await?;
             }
         }
 
-        self.get_installed_binary(delegate, &config, user_installed_path, cx)
-            .await
+        self.get_installed_binary(
+            delegate,
+            &task_definition,
+            user_installed_path,
+            user_args,
+            cx,
+        )
+        .await
     }
 }

crates/dap_adapters/src/python.rs 🔗

@@ -1,7 +1,18 @@
 use crate::*;
+use anyhow::Context as _;
+use dap::adapters::latest_github_release;
 use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
-use gpui::AsyncApp;
-use std::{collections::HashMap, ffi::OsStr, path::PathBuf, sync::OnceLock};
+use gpui::{AppContext, AsyncApp, SharedString};
+use json_dotpath::DotPaths;
+use language::{LanguageName, Toolchain};
+use serde_json::Value;
+use std::net::Ipv4Addr;
+use std::{
+    collections::HashMap,
+    ffi::OsStr,
+    path::{Path, PathBuf},
+    sync::OnceLock,
+};
 use util::ResultExt;
 
 #[derive(Default)]
@@ -11,66 +22,113 @@ pub(crate) struct PythonDebugAdapter {
 
 impl PythonDebugAdapter {
     const ADAPTER_NAME: &'static str = "Debugpy";
+    const DEBUG_ADAPTER_NAME: DebugAdapterName =
+        DebugAdapterName(SharedString::new_static(Self::ADAPTER_NAME));
     const ADAPTER_PACKAGE_NAME: &'static str = "debugpy";
     const ADAPTER_PATH: &'static str = "src/debugpy/adapter";
     const LANGUAGE_NAME: &'static str = "Python";
 
-    fn request_args(&self, config: &DebugTaskDefinition) -> StartDebuggingRequestArguments {
-        let mut args = json!({
-            "request": match config.request {
-                DebugRequest::Launch(_) => "launch",
-                DebugRequest::Attach(_) => "attach",
-            },
-            "subProcess": true,
-            "redirectOutput": true,
+    async fn generate_debugpy_arguments(
+        host: &Ipv4Addr,
+        port: u16,
+        user_installed_path: Option<&Path>,
+        user_args: Option<Vec<String>>,
+        installed_in_venv: bool,
+    ) -> Result<Vec<String>> {
+        let mut args = if let Some(user_installed_path) = user_installed_path {
+            log::debug!(
+                "Using user-installed debugpy adapter from: {}",
+                user_installed_path.display()
+            );
+            vec![
+                user_installed_path
+                    .join(Self::ADAPTER_PATH)
+                    .to_string_lossy()
+                    .to_string(),
+            ]
+        } else if installed_in_venv {
+            log::debug!("Using venv-installed debugpy");
+            vec!["-m".to_string(), "debugpy.adapter".to_string()]
+        } else {
+            let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
+            let file_name_prefix = format!("{}_", Self::ADAPTER_NAME);
+
+            let debugpy_dir =
+                util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
+                    file_name.starts_with(&file_name_prefix)
+                })
+                .await
+                .context("Debugpy directory not found")?;
+
+            log::debug!(
+                "Using GitHub-downloaded debugpy adapter from: {}",
+                debugpy_dir.display()
+            );
+            vec![
+                debugpy_dir
+                    .join(Self::ADAPTER_PATH)
+                    .to_string_lossy()
+                    .to_string(),
+            ]
+        };
+
+        args.extend(if let Some(args) = user_args {
+            args
+        } else {
+            vec![format!("--host={}", host), format!("--port={}", port)]
         });
-        let map = args.as_object_mut().unwrap();
-        match &config.request {
-            DebugRequest::Attach(attach) => {
-                map.insert("processId".into(), attach.process_id.into());
-            }
-            DebugRequest::Launch(launch) => {
-                map.insert("program".into(), launch.program.clone().into());
-                map.insert("args".into(), launch.args.clone().into());
+        Ok(args)
+    }
 
-                if let Some(stop_on_entry) = config.stop_on_entry {
-                    map.insert("stopOnEntry".into(), stop_on_entry.into());
-                }
-                if let Some(cwd) = launch.cwd.as_ref() {
-                    map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
-                }
+    async fn request_args(
+        &self,
+        delegate: &Arc<dyn DapDelegate>,
+        task_definition: &DebugTaskDefinition,
+    ) -> Result<StartDebuggingRequestArguments> {
+        let request = self.request_kind(&task_definition.config).await?;
+
+        let mut configuration = task_definition.config.clone();
+        if let Ok(console) = configuration.dot_get_mut("console") {
+            // Use built-in Zed terminal if user did not explicitly provide a setting for console.
+            if console.is_null() {
+                *console = Value::String("integratedTerminal".into());
             }
         }
-        StartDebuggingRequestArguments {
-            configuration: args,
-            request: config.request.to_dap(),
+
+        if let Some(obj) = configuration.as_object_mut() {
+            obj.entry("cwd")
+                .or_insert(delegate.worktree_root_path().to_string_lossy().into());
         }
+
+        Ok(StartDebuggingRequestArguments {
+            configuration,
+            request,
+        })
     }
     async fn fetch_latest_adapter_version(
         &self,
-        delegate: &dyn DapDelegate,
+        delegate: &Arc<dyn DapDelegate>,
     ) -> Result<AdapterVersion> {
         let github_repo = GithubRepo {
             repo_name: Self::ADAPTER_PACKAGE_NAME.into(),
             repo_owner: "microsoft".into(),
         };
 
-        adapters::fetch_latest_adapter_version_from_github(github_repo, delegate).await
+        fetch_latest_adapter_version_from_github(github_repo, delegate.as_ref()).await
     }
 
     async fn install_binary(
-        &self,
+        adapter_name: DebugAdapterName,
         version: AdapterVersion,
-        delegate: &dyn DapDelegate,
+        delegate: Arc<dyn DapDelegate>,
     ) -> Result<()> {
         let version_path = adapters::download_adapter_from_github(
-            self.name(),
+            adapter_name,
             version,
-            adapters::DownloadedFileType::Zip,
-            delegate,
+            adapters::DownloadedFileType::GzipTar,
+            delegate.as_ref(),
         )
         .await?;
-
         // only needed when you install the latest version for the first time
         if let Some(debugpy_dir) =
             util::fs::find_file_name_in_dir(version_path.as_path(), |file_name| {
@@ -89,69 +147,63 @@ impl PythonDebugAdapter {
 
     async fn get_installed_binary(
         &self,
-        delegate: &dyn DapDelegate,
+        delegate: &Arc<dyn DapDelegate>,
         config: &DebugTaskDefinition,
         user_installed_path: Option<PathBuf>,
-        cx: &mut AsyncApp,
+        user_args: Option<Vec<String>>,
+        toolchain: Option<Toolchain>,
+        installed_in_venv: bool,
     ) -> Result<DebugAdapterBinary> {
         const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
         let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
         let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
 
-        let debugpy_dir = if let Some(user_installed_path) = user_installed_path {
-            user_installed_path
+        let python_path = if let Some(toolchain) = toolchain {
+            Some(toolchain.path.to_string())
         } else {
-            let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref());
-            let file_name_prefix = format!("{}_", Self::ADAPTER_NAME);
+            let mut name = None;
 
-            util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
-                file_name.starts_with(&file_name_prefix)
-            })
-            .await
-            .ok_or_else(|| anyhow!("Debugpy directory not found"))?
+            for cmd in BINARY_NAMES {
+                name = delegate
+                    .which(OsStr::new(cmd))
+                    .await
+                    .map(|path| path.to_string_lossy().to_string());
+                if name.is_some() {
+                    break;
+                }
+            }
+            name
         };
 
-        let toolchain = delegate
-            .toolchain_store()
-            .active_toolchain(
-                delegate.worktree_id(),
-                Arc::from("".as_ref()),
-                language::LanguageName::new(Self::LANGUAGE_NAME),
-                cx,
-            )
-            .await;
+        let python_command = python_path.context("failed to find binary path for Python")?;
+        log::debug!("Using Python executable: {}", python_command);
 
-        let python_path = if let Some(toolchain) = toolchain {
-            Some(toolchain.path.to_string())
-        } else {
-            BINARY_NAMES
-                .iter()
-                .filter_map(|cmd| {
-                    delegate
-                        .which(OsStr::new(cmd))
-                        .map(|path| path.to_string_lossy().to_string())
-                })
-                .find(|_| true)
-        };
+        let arguments = Self::generate_debugpy_arguments(
+            &host,
+            port,
+            user_installed_path.as_deref(),
+            user_args,
+            installed_in_venv,
+        )
+        .await?;
+
+        log::debug!(
+            "Starting debugpy adapter with command: {} {}",
+            python_command,
+            arguments.join(" ")
+        );
 
         Ok(DebugAdapterBinary {
-            command: python_path.ok_or(anyhow!("failed to find binary path for python"))?,
-            arguments: vec![
-                debugpy_dir
-                    .join(Self::ADAPTER_PATH)
-                    .to_string_lossy()
-                    .to_string(),
-                format!("--port={}", port),
-                format!("--host={}", host),
-            ],
+            command: Some(python_command),
+            arguments,
             connection: Some(adapters::TcpArguments {
                 host,
                 port,
                 timeout,
             }),
-            cwd: None,
+            cwd: Some(delegate.worktree_root_path().to_path_buf()),
             envs: HashMap::default(),
-            request_args: self.request_args(config),
+            request_args: self.request_args(delegate, config).await?,
         })
     }
 }
@@ -159,24 +211,539 @@ impl PythonDebugAdapter {
 #[async_trait(?Send)]
 impl DebugAdapter for PythonDebugAdapter {
     fn name(&self) -> DebugAdapterName {
-        DebugAdapterName(Self::ADAPTER_NAME.into())
+        Self::DEBUG_ADAPTER_NAME
+    }
+
+    fn adapter_language_name(&self) -> Option<LanguageName> {
+        Some(SharedString::new_static("Python").into())
+    }
+
+    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
+        let mut args = json!({
+            "request": match zed_scenario.request {
+                DebugRequest::Launch(_) => "launch",
+                DebugRequest::Attach(_) => "attach",
+            },
+            "subProcess": true,
+            "redirectOutput": true,
+        });
+
+        let map = args.as_object_mut().unwrap();
+        match &zed_scenario.request {
+            DebugRequest::Attach(attach) => {
+                map.insert("processId".into(), attach.process_id.into());
+            }
+            DebugRequest::Launch(launch) => {
+                map.insert("program".into(), launch.program.clone().into());
+                map.insert("args".into(), launch.args.clone().into());
+                if !launch.env.is_empty() {
+                    map.insert("env".into(), launch.env_json());
+                }
+
+                if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
+                    map.insert("stopOnEntry".into(), stop_on_entry.into());
+                }
+                if let Some(cwd) = launch.cwd.as_ref() {
+                    map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
+                }
+            }
+        }
+
+        Ok(DebugScenario {
+            adapter: zed_scenario.adapter,
+            label: zed_scenario.label,
+            config: args,
+            build: None,
+            tcp_connection: None,
+        })
+    }
+
+    fn dap_schema(&self) -> serde_json::Value {
+        json!({
+            "properties": {
+                "request": {
+                    "type": "string",
+                    "enum": ["attach", "launch"],
+                    "description": "Debug adapter request type"
+                },
+                "autoReload": {
+                    "default": {},
+                    "description": "Configures automatic reload of code on edit.",
+                    "properties": {
+                        "enable": {
+                            "default": false,
+                            "description": "Automatically reload code on edit.",
+                            "type": "boolean"
+                        },
+                        "exclude": {
+                            "default": [
+                                "**/.git/**",
+                                "**/.metadata/**",
+                                "**/__pycache__/**",
+                                "**/node_modules/**",
+                                "**/site-packages/**"
+                            ],
+                            "description": "Glob patterns of paths to exclude from auto reload.",
+                            "items": {
+                                "type": "string"
+                            },
+                            "type": "array"
+                        },
+                        "include": {
+                            "default": [
+                                "**/*.py",
+                                "**/*.pyw"
+                            ],
+                            "description": "Glob patterns of paths to include in auto reload.",
+                            "items": {
+                                "type": "string"
+                            },
+                            "type": "array"
+                        }
+                    },
+                    "type": "object"
+                },
+                "debugAdapterPath": {
+                    "description": "Path (fully qualified) to the python debug adapter executable.",
+                    "type": "string"
+                },
+                "django": {
+                    "default": false,
+                    "description": "Django debugging.",
+                    "type": "boolean"
+                },
+                "jinja": {
+                    "default": null,
+                    "description": "Jinja template debugging (e.g. Flask).",
+                    "enum": [
+                        false,
+                        null,
+                        true
+                    ]
+                },
+                "justMyCode": {
+                    "default": true,
+                    "description": "If true, show and debug only user-written code. If false, show and debug all code, including library calls.",
+                    "type": "boolean"
+                },
+                "logToFile": {
+                    "default": false,
+                    "description": "Enable logging of debugger events to a log file. This file can be found in the debugpy extension install folder.",
+                    "type": "boolean"
+                },
+                "pathMappings": {
+                    "default": [],
+                    "items": {
+                        "label": "Path mapping",
+                        "properties": {
+                            "localRoot": {
+                                "default": "${ZED_WORKTREE_ROOT}",
+                                "label": "Local source root.",
+                                "type": "string"
+                            },
+                            "remoteRoot": {
+                                "default": "",
+                                "label": "Remote source root.",
+                                "type": "string"
+                            }
+                        },
+                        "required": [
+                            "localRoot",
+                            "remoteRoot"
+                        ],
+                        "type": "object"
+                    },
+                    "label": "Path mappings.",
+                    "type": "array"
+                },
+                "redirectOutput": {
+                    "default": true,
+                    "description": "Redirect output.",
+                    "type": "boolean"
+                },
+                "showReturnValue": {
+                    "default": true,
+                    "description": "Show return value of functions when stepping.",
+                    "type": "boolean"
+                },
+                "subProcess": {
+                    "default": false,
+                    "description": "Whether to enable Sub Process debugging",
+                    "type": "boolean"
+                },
+                "consoleName": {
+                    "default": "Python Debug Console",
+                    "description": "Display name of the debug console or terminal",
+                    "type": "string"
+                },
+                "clientOS": {
+                    "default": null,
+                    "description": "OS that VS code is using.",
+                    "enum": [
+                        "windows",
+                        null,
+                        "unix"
+                    ]
+                }
+            },
+            "required": ["request"],
+            "allOf": [
+                {
+                    "if": {
+                        "properties": {
+                            "request": {
+                                "enum": ["attach"]
+                            }
+                        }
+                    },
+                    "then": {
+                        "properties": {
+                            "connect": {
+                                "label": "Attach by connecting to debugpy over a socket.",
+                                "properties": {
+                                    "host": {
+                                        "default": "127.0.0.1",
+                                        "description": "Hostname or IP address to connect to.",
+                                        "type": "string"
+                                    },
+                                    "port": {
+                                        "description": "Port to connect to.",
+                                        "type": [
+                                            "number",
+                                            "string"
+                                        ]
+                                    }
+                                },
+                                "required": [
+                                    "port"
+                                ],
+                                "type": "object"
+                            },
+                            "listen": {
+                                "label": "Attach by listening for incoming socket connection from debugpy",
+                                "properties": {
+                                    "host": {
+                                        "default": "127.0.0.1",
+                                        "description": "Hostname or IP address of the interface to listen on.",
+                                        "type": "string"
+                                    },
+                                    "port": {
+                                        "description": "Port to listen on.",
+                                        "type": [
+                                            "number",
+                                            "string"
+                                        ]
+                                    }
+                                },
+                                "required": [
+                                    "port"
+                                ],
+                                "type": "object"
+                            },
+                            "processId": {
+                                "anyOf": [
+                                    {
+                                        "default": "${command:pickProcess}",
+                                        "description": "Use process picker to select a process to attach, or Process ID as integer.",
+                                        "enum": [
+                                            "${command:pickProcess}"
+                                        ]
+                                    },
+                                    {
+                                        "description": "ID of the local process to attach to.",
+                                        "type": "integer"
+                                    }
+                                ]
+                            }
+                        }
+                    }
+                },
+                {
+                    "if": {
+                        "properties": {
+                            "request": {
+                                "enum": ["launch"]
+                            }
+                        }
+                    },
+                    "then": {
+                        "properties": {
+                            "args": {
+                                "default": [],
+                                "description": "Command line arguments passed to the program. For string type arguments, it will pass through the shell as is, and therefore all shell variable expansions will apply. But for the array type, the values will be shell-escaped.",
+                                "items": {
+                                    "type": "string"
+                                },
+                                "anyOf": [
+                                    {
+                                        "default": "${command:pickArgs}",
+                                        "enum": [
+                                            "${command:pickArgs}"
+                                        ]
+                                    },
+                                    {
+                                        "type": [
+                                            "array",
+                                            "string"
+                                        ]
+                                    }
+                                ]
+                            },
+                            "console": {
+                                "default": "integratedTerminal",
+                                "description": "Where to launch the debug target: internal console, integrated terminal, or external terminal.",
+                                "enum": [
+                                    "externalTerminal",
+                                    "integratedTerminal",
+                                    "internalConsole"
+                                ]
+                            },
+                            "cwd": {
+                                "default": "${ZED_WORKTREE_ROOT}",
+                                "description": "Absolute path to the working directory of the program being debugged. Default is the root directory of the file (leave empty).",
+                                "type": "string"
+                            },
+                            "autoStartBrowser": {
+                                "default": false,
+                                "description": "Open external browser to launch the application",
+                                "type": "boolean"
+                            },
+                            "env": {
+                                "additionalProperties": {
+                                    "type": "string"
+                                },
+                                "default": {},
+                                "description": "Environment variables defined as a key value pair. Property ends up being the Environment Variable and the value of the property ends up being the value of the Env Variable.",
+                                "type": "object"
+                            },
+                            "envFile": {
+                                "default": "${ZED_WORKTREE_ROOT}/.env",
+                                "description": "Absolute path to a file containing environment variable definitions.",
+                                "type": "string"
+                            },
+                            "gevent": {
+                                "default": false,
+                                "description": "Enable debugging of gevent monkey-patched code.",
+                                "type": "boolean"
+                            },
+                            "module": {
+                                "default": "",
+                                "description": "Name of the module to be debugged.",
+                                "type": "string"
+                            },
+                            "program": {
+                                "default": "${ZED_FILE}",
+                                "description": "Absolute path to the program.",
+                                "type": "string"
+                            },
+                            "purpose": {
+                                "default": [],
+                                "description": "Tells extension to use this configuration for test debugging, or when using debug-in-terminal command.",
+                                "items": {
+                                    "enum": [
+                                        "debug-test",
+                                        "debug-in-terminal"
+                                    ],
+                                    "enumDescriptions": [
+                                        "Use this configuration while debugging tests using test view or test debug commands.",
+                                        "Use this configuration while debugging a file using debug in terminal button in the editor."
+                                    ]
+                                },
+                                "type": "array"
+                            },
+                            "pyramid": {
+                                "default": false,
+                                "description": "Whether debugging Pyramid applications.",
+                                "type": "boolean"
+                            },
+                            "python": {
+                                "default": "${command:python.interpreterPath}",
+                                "description": "Absolute path to the Python interpreter executable; overrides workspace configuration if set.",
+                                "type": "string"
+                            },
+                            "pythonArgs": {
+                                "default": [],
+                                "description": "Command-line arguments passed to the Python interpreter. To pass arguments to the debug target, use \"args\".",
+                                "items": {
+                                    "type": "string"
+                                },
+                                "type": "array"
+                            },
+                            "stopOnEntry": {
+                                "default": false,
+                                "description": "Automatically stop after launch.",
+                                "type": "boolean"
+                            },
+                            "sudo": {
+                                "default": false,
+                                "description": "Running debug program under elevated permissions (on Unix).",
+                                "type": "boolean"
+                            },
+                            "guiEventLoop": {
+                                "default": "matplotlib",
+                                "description": "The GUI event loop that's going to run. Possible values: \"matplotlib\", \"wx\", \"qt\", \"none\", or a custom function that'll be imported and run.",
+                                "type": "string"
+                            }
+                        }
+                    }
+                }
+            ]
+        })
     }
 
     async fn get_binary(
         &self,
-        delegate: &dyn DapDelegate,
+        delegate: &Arc<dyn DapDelegate>,
         config: &DebugTaskDefinition,
         user_installed_path: Option<PathBuf>,
+        user_args: Option<Vec<String>>,
         cx: &mut AsyncApp,
     ) -> Result<DebugAdapterBinary> {
+        if let Some(local_path) = &user_installed_path {
+            log::debug!(
+                "Using user-installed debugpy adapter from: {}",
+                local_path.display()
+            );
+            return self
+                .get_installed_binary(
+                    delegate,
+                    &config,
+                    Some(local_path.clone()),
+                    user_args,
+                    None,
+                    false,
+                )
+                .await;
+        }
+
+        let toolchain = delegate
+            .toolchain_store()
+            .active_toolchain(
+                delegate.worktree_id(),
+                Arc::from("".as_ref()),
+                language::LanguageName::new(Self::LANGUAGE_NAME),
+                cx,
+            )
+            .await;
+
+        if let Some(toolchain) = &toolchain {
+            if let Some(path) = Path::new(&toolchain.path.to_string()).parent() {
+                let debugpy_path = path.join("debugpy");
+                if delegate.fs().is_file(&debugpy_path).await {
+                    log::debug!(
+                        "Found debugpy in toolchain environment: {}",
+                        debugpy_path.display()
+                    );
+                    return self
+                        .get_installed_binary(
+                            delegate,
+                            &config,
+                            None,
+                            user_args,
+                            Some(toolchain.clone()),
+                            true,
+                        )
+                        .await;
+                }
+            }
+        }
+
         if self.checked.set(()).is_ok() {
             delegate.output_to_console(format!("Checking latest version of {}...", self.name()));
             if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() {
-                self.install_binary(version, delegate).await?;
+                cx.background_spawn(Self::install_binary(self.name(), version, delegate.clone()))
+                    .await
+                    .context("Failed to install debugpy")?;
             }
         }
 
-        self.get_installed_binary(delegate, &config, user_installed_path, cx)
+        self.get_installed_binary(delegate, &config, None, user_args, toolchain, false)
             .await
     }
 }
+
+async fn fetch_latest_adapter_version_from_github(
+    github_repo: GithubRepo,
+    delegate: &dyn DapDelegate,
+) -> Result<AdapterVersion> {
+    let release = latest_github_release(
+        &format!("{}/{}", github_repo.repo_owner, github_repo.repo_name),
+        false,
+        false,
+        delegate.http_client(),
+    )
+    .await?;
+
+    Ok(AdapterVersion {
+        tag_name: release.tag_name,
+        url: release.tarball_url,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::{net::Ipv4Addr, path::PathBuf};
+
+    #[gpui::test]
+    async fn test_debugpy_install_path_cases() {
+        let host = Ipv4Addr::new(127, 0, 0, 1);
+        let port = 5678;
+
+        // Case 1: User-defined debugpy path (highest precedence)
+        let user_path = PathBuf::from("/custom/path/to/debugpy");
+        let user_args = PythonDebugAdapter::generate_debugpy_arguments(
+            &host,
+            port,
+            Some(&user_path),
+            None,
+            false,
+        )
+        .await
+        .unwrap();
+
+        // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
+        let venv_args =
+            PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, None, true)
+                .await
+                .unwrap();
+
+        assert!(user_args[0].ends_with("src/debugpy/adapter"));
+        assert_eq!(user_args[1], "--host=127.0.0.1");
+        assert_eq!(user_args[2], "--port=5678");
+
+        assert_eq!(venv_args[0], "-m");
+        assert_eq!(venv_args[1], "debugpy.adapter");
+        assert_eq!(venv_args[2], "--host=127.0.0.1");
+        assert_eq!(venv_args[3], "--port=5678");
+
+        // The same cases, with arguments overridden by the user
+        let user_args = PythonDebugAdapter::generate_debugpy_arguments(
+            &host,
+            port,
+            Some(&user_path),
+            Some(vec!["foo".into()]),
+            false,
+        )
+        .await
+        .unwrap();
+        let venv_args = PythonDebugAdapter::generate_debugpy_arguments(
+            &host,
+            port,
+            None,
+            Some(vec!["foo".into()]),
+            true,
+        )
+        .await
+        .unwrap();
+
+        assert!(user_args[0].ends_with("src/debugpy/adapter"));
+        assert_eq!(user_args[1], "foo");
+
+        assert_eq!(venv_args[0], "-m");
+        assert_eq!(venv_args[1], "debugpy.adapter");
+        assert_eq!(venv_args[2], "foo");
+
+        // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
+    }
+}

crates/dap_adapters/src/ruby.rs 🔗

@@ -1,17 +1,21 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Result, bail};
 use async_trait::async_trait;
+use collections::FxHashMap;
 use dap::{
-    DebugRequest, StartDebuggingRequestArguments,
+    DebugRequest, StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest,
     adapters::{
-        self, DapDelegate, DebugAdapter, DebugAdapterBinary, DebugAdapterName, DebugTaskDefinition,
+        DapDelegate, DebugAdapter, DebugAdapterBinary, DebugAdapterName, DebugTaskDefinition,
     },
 };
-use gpui::AsyncApp;
+use gpui::{AsyncApp, SharedString};
+use language::LanguageName;
+use serde::{Deserialize, Serialize};
+use serde_json::json;
 use std::path::PathBuf;
+use std::{ffi::OsStr, sync::Arc};
+use task::{DebugScenario, ZedDebugConfig};
 use util::command::new_smol_command;
 
-use crate::ToDap;
-
 #[derive(Default)]
 pub(crate) struct RubyDebugAdapter;
 
@@ -19,23 +23,109 @@ impl RubyDebugAdapter {
     const ADAPTER_NAME: &'static str = "Ruby";
 }
 
+#[derive(Serialize, Deserialize)]
+struct RubyDebugConfig {
+    script_or_command: Option<String>,
+    script: Option<String>,
+    command: Option<String>,
+    #[serde(default)]
+    args: Vec<String>,
+    #[serde(default)]
+    env: FxHashMap<String, String>,
+    cwd: Option<PathBuf>,
+}
+
 #[async_trait(?Send)]
 impl DebugAdapter for RubyDebugAdapter {
     fn name(&self) -> DebugAdapterName {
         DebugAdapterName(Self::ADAPTER_NAME.into())
     }
 
+    fn adapter_language_name(&self) -> Option<LanguageName> {
+        Some(SharedString::new_static("Ruby").into())
+    }
+
+    async fn request_kind(
+        &self,
+        _: &serde_json::Value,
+    ) -> Result<StartDebuggingRequestArgumentsRequest> {
+        Ok(StartDebuggingRequestArgumentsRequest::Launch)
+    }
+
+    fn dap_schema(&self) -> serde_json::Value {
+        json!({
+            "type": "object",
+            "properties": {
+                "command": {
+                    "type": "string",
+                    "description": "Command name (ruby, rake, bin/rails, bundle exec ruby, etc)",
+                },
+                "script": {
+                    "type": "string",
+                    "description": "Absolute path to a Ruby file."
+                },
+                "cwd": {
+                    "type": "string",
+                    "description": "Directory to execute the program in",
+                    "default": "${ZED_WORKTREE_ROOT}"
+                },
+                "args": {
+                    "type": "array",
+                    "description": "Command line arguments passed to the program",
+                    "items": {
+                        "type": "string"
+                    },
+                    "default": []
+                },
+                "env": {
+                    "type": "object",
+                    "description": "Additional environment variables to pass to the debugging (and debugged) process",
+                    "default": {}
+                },
+            }
+        })
+    }
+
+    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
+        match zed_scenario.request {
+            DebugRequest::Launch(launch) => {
+                let config = RubyDebugConfig {
+                    script_or_command: Some(launch.program),
+                    script: None,
+                    command: None,
+                    args: launch.args,
+                    env: launch.env,
+                    cwd: launch.cwd.clone(),
+                };
+
+                let config = serde_json::to_value(config)?;
+
+                Ok(DebugScenario {
+                    adapter: zed_scenario.adapter,
+                    label: zed_scenario.label,
+                    config,
+                    tcp_connection: None,
+                    build: None,
+                })
+            }
+            DebugRequest::Attach(_) => {
+                anyhow::bail!("Attach requests are unsupported");
+            }
+        }
+    }
+
     async fn get_binary(
         &self,
-        delegate: &dyn DapDelegate,
+        delegate: &Arc<dyn DapDelegate>,
         definition: &DebugTaskDefinition,
         _user_installed_path: Option<PathBuf>,
+        _user_args: Option<Vec<String>>,
         _cx: &mut AsyncApp,
     ) -> Result<DebugAdapterBinary> {
         let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref());
         let mut rdbg_path = adapter_path.join("rdbg");
         if !delegate.fs().is_file(&rdbg_path).await {
-            match delegate.which("rdbg".as_ref()) {
+            match delegate.which("rdbg".as_ref()).await {
                 Some(path) => rdbg_path = path,
                 None => {
                     delegate.output_to_console(
@@ -49,53 +139,69 @@ impl DebugAdapter for RubyDebugAdapter {
                         .arg("debug")
                         .output()
                         .await?;
-                    if !output.status.success() {
-                        return Err(anyhow!(
-                            "Failed to install rdbg:\n{}",
-                            String::from_utf8_lossy(&output.stderr).to_string()
-                        ));
-                    }
+                    anyhow::ensure!(
+                        output.status.success(),
+                        "Failed to install rdbg:\n{}",
+                        String::from_utf8_lossy(&output.stderr).to_string()
+                    );
                 }
             }
         }
 
         let tcp_connection = definition.tcp_connection.clone().unwrap_or_default();
         let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
-
-        let DebugRequest::Launch(mut launch) = definition.request.clone() else {
-            anyhow::bail!("rdbg does not yet support attaching");
-        };
+        let ruby_config = serde_json::from_value::<RubyDebugConfig>(definition.config.clone())?;
 
         let mut arguments = vec![
             "--open".to_string(),
             format!("--port={}", port),
             format!("--host={}", host),
         ];
-        if launch.args.is_empty() {
-            let program = launch.program.clone();
-            let mut split = program.split(" ");
-            launch.program = split.next().unwrap().to_string();
-            launch.args = split.map(|s| s.to_string()).collect();
+
+        if let Some(script) = &ruby_config.script {
+            arguments.push(script.clone());
+        } else if let Some(command) = &ruby_config.command {
+            arguments.push("--command".to_string());
+            arguments.push(command.clone());
+        } else if let Some(command_or_script) = &ruby_config.script_or_command {
+            if delegate
+                .which(OsStr::new(&command_or_script))
+                .await
+                .is_some()
+            {
+                arguments.push("--command".to_string());
+            }
+            arguments.push(command_or_script.clone());
+        } else {
+            bail!("Ruby debug config must have 'script' or 'command' args");
         }
-        if delegate.which(launch.program.as_ref()).is_some() {
-            arguments.push("--command".to_string())
+
+        arguments.extend(ruby_config.args);
+
+        let mut configuration = definition.config.clone();
+        if let Some(configuration) = configuration.as_object_mut() {
+            configuration
+                .entry("cwd")
+                .or_insert_with(|| delegate.worktree_root_path().to_string_lossy().into());
         }
-        arguments.push(launch.program);
-        arguments.extend(launch.args);
 
         Ok(DebugAdapterBinary {
-            command: rdbg_path.to_string_lossy().to_string(),
+            command: Some(rdbg_path.to_string_lossy().to_string()),
             arguments,
-            connection: Some(adapters::TcpArguments {
+            connection: Some(dap::adapters::TcpArguments {
                 host,
                 port,
                 timeout,
             }),
-            cwd: launch.cwd,
-            envs: launch.env.into_iter().collect(),
+            cwd: Some(
+                ruby_config
+                    .cwd
+                    .unwrap_or(delegate.worktree_root_path().to_owned()),
+            ),
+            envs: ruby_config.env.into_iter().collect(),
             request_args: StartDebuggingRequestArguments {
-                configuration: serde_json::Value::Object(Default::default()),
-                request: definition.request.to_dap(),
+                request: self.request_kind(&definition.config).await?,
+                configuration,
             },
         })
     }

crates/db/src/db.rs 🔗

@@ -74,7 +74,7 @@ pub async fn open_db<M: Migrator + 'static>(db_dir: &Path, scope: &str) -> Threa
 }
 
 async fn open_main_db<M: Migrator>(db_path: &Path) -> Option<ThreadSafeConnection> {
-    log::info!("Opening main db");
+    log::info!("Opening database {}", db_path.display());
     ThreadSafeConnection::builder::<M>(db_path.to_string_lossy().as_ref(), true)
         .with_db_initialization_query(DB_INITIALIZE_QUERY)
         .with_connection_initialize_query(CONNECTION_INITIALIZE_QUERY)
@@ -84,7 +84,7 @@ async fn open_main_db<M: Migrator>(db_path: &Path) -> Option<ThreadSafeConnectio
 }
 
 async fn open_fallback_db<M: Migrator>() -> ThreadSafeConnection {
-    log::info!("Opening fallback db");
+    log::warn!("Opening fallback in-memory database");
     ThreadSafeConnection::builder::<M>(FALLBACK_DB_NAME, false)
         .with_db_initialization_query(DB_INITIALIZE_QUERY)
         .with_connection_initialize_query(CONNECTION_INITIALIZE_QUERY)

crates/db/src/kvp.rs 🔗

@@ -1,6 +1,8 @@
+use gpui::App;
 use sqlez_macros::sql;
+use util::ResultExt as _;
 
-use crate::{define_connection, query};
+use crate::{define_connection, query, write_and_log};
 
 define_connection!(pub static ref KEY_VALUE_STORE: KeyValueStore<()> =
     &[sql!(
@@ -11,6 +13,29 @@ define_connection!(pub static ref KEY_VALUE_STORE: KeyValueStore<()> =
     )];
 );
 
+pub trait Dismissable {
+    const KEY: &'static str;
+
+    fn dismissed() -> bool {
+        KEY_VALUE_STORE
+            .read_kvp(Self::KEY)
+            .log_err()
+            .map_or(false, |s| s.is_some())
+    }
+
+    fn set_dismissed(is_dismissed: bool, cx: &mut App) {
+        write_and_log(cx, move || async move {
+            if is_dismissed {
+                KEY_VALUE_STORE
+                    .write_kvp(Self::KEY.into(), "1".into())
+                    .await
+            } else {
+                KEY_VALUE_STORE.delete_kvp(Self::KEY.into()).await
+            }
+        })
+    }
+}
+
 impl KeyValueStore {
     query! {
         pub fn read_kvp(key: &str) -> Result<Option<String>> {

crates/debug_adapter_extension/Cargo.toml 🔗

@@ -0,0 +1,23 @@
+[package]
+name = "debug_adapter_extension"
+version = "0.1.0"
+license = "GPL-3.0-or-later"
+publish.workspace = true
+edition.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+async-trait.workspace = true
+dap.workspace = true
+extension.workspace = true
+gpui.workspace = true
+serde_json.workspace = true
+util.workspace = true
+task.workspace = true
+workspace-hack = { version = "0.1", path = "../../tooling/workspace-hack" }
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/debug_adapter_extension.rs"

crates/debug_adapter_extension/src/debug_adapter_extension.rs 🔗

@@ -0,0 +1,66 @@
+mod extension_dap_adapter;
+mod extension_locator_adapter;
+
+use std::{path::Path, sync::Arc};
+
+use dap::DapRegistry;
+use extension::{ExtensionDebugAdapterProviderProxy, ExtensionHostProxy};
+use extension_dap_adapter::ExtensionDapAdapter;
+use gpui::App;
+use util::ResultExt;
+
+use crate::extension_locator_adapter::ExtensionLocatorAdapter;
+
+pub fn init(extension_host_proxy: Arc<ExtensionHostProxy>, cx: &mut App) {
+    let language_server_registry_proxy = DebugAdapterRegistryProxy::new(cx);
+    extension_host_proxy.register_debug_adapter_proxy(language_server_registry_proxy);
+}
+
+#[derive(Clone)]
+struct DebugAdapterRegistryProxy {
+    debug_adapter_registry: DapRegistry,
+}
+
+impl DebugAdapterRegistryProxy {
+    fn new(cx: &mut App) -> Self {
+        Self {
+            debug_adapter_registry: DapRegistry::global(cx).clone(),
+        }
+    }
+}
+
+impl ExtensionDebugAdapterProviderProxy for DebugAdapterRegistryProxy {
+    fn register_debug_adapter(
+        &self,
+        extension: Arc<dyn extension::Extension>,
+        debug_adapter_name: Arc<str>,
+        schema_path: &Path,
+    ) {
+        if let Some(adapter) =
+            ExtensionDapAdapter::new(extension, debug_adapter_name, schema_path).log_err()
+        {
+            self.debug_adapter_registry.add_adapter(Arc::new(adapter));
+        }
+    }
+
+    fn register_debug_locator(
+        &self,
+        extension: Arc<dyn extension::Extension>,
+        locator_name: Arc<str>,
+    ) {
+        self.debug_adapter_registry
+            .add_locator(Arc::new(ExtensionLocatorAdapter::new(
+                extension,
+                locator_name,
+            )));
+    }
+
+    fn unregister_debug_adapter(&self, debug_adapter_name: Arc<str>) {
+        self.debug_adapter_registry
+            .remove_adapter(&debug_adapter_name);
+    }
+
+    fn unregister_debug_locator(&self, locator_name: Arc<str>) {
+        self.debug_adapter_registry.remove_locator(&locator_name);
+    }
+}

crates/debug_adapter_extension/src/extension_dap_adapter.rs 🔗

@@ -0,0 +1,117 @@
+use std::{
+    path::{Path, PathBuf},
+    str::FromStr,
+    sync::Arc,
+};
+
+use anyhow::{Context, Result};
+use async_trait::async_trait;
+use dap::{
+    StartDebuggingRequestArgumentsRequest,
+    adapters::{
+        DapDelegate, DebugAdapter, DebugAdapterBinary, DebugAdapterName, DebugTaskDefinition,
+    },
+};
+use extension::{Extension, WorktreeDelegate};
+use gpui::AsyncApp;
+use task::{DebugScenario, ZedDebugConfig};
+
+pub(crate) struct ExtensionDapAdapter {
+    extension: Arc<dyn Extension>,
+    debug_adapter_name: Arc<str>,
+    schema: serde_json::Value,
+}
+
+impl ExtensionDapAdapter {
+    pub(crate) fn new(
+        extension: Arc<dyn extension::Extension>,
+        debug_adapter_name: Arc<str>,
+        schema_path: &Path,
+    ) -> Result<Self> {
+        let schema = std::fs::read_to_string(&schema_path).with_context(|| {
+            format!(
+                "Failed to read debug adapter schema for {debug_adapter_name} (from path: `{schema_path:?}`)"
+            )
+        })?;
+        let schema = serde_json::Value::from_str(&schema).with_context(|| {
+            format!("Debug adapter schema for {debug_adapter_name} is not a valid JSON")
+        })?;
+        Ok(Self {
+            extension,
+            debug_adapter_name,
+            schema,
+        })
+    }
+}
+
+/// An adapter that allows an [`dap::adapters::DapDelegate`] to be used as a [`WorktreeDelegate`].
+struct WorktreeDelegateAdapter(pub Arc<dyn DapDelegate>);
+
+#[async_trait]
+impl WorktreeDelegate for WorktreeDelegateAdapter {
+    fn id(&self) -> u64 {
+        self.0.worktree_id().to_proto()
+    }
+
+    fn root_path(&self) -> String {
+        self.0.worktree_root_path().to_string_lossy().to_string()
+    }
+
+    async fn read_text_file(&self, path: PathBuf) -> Result<String> {
+        self.0.read_text_file(path).await
+    }
+
+    async fn which(&self, binary_name: String) -> Option<String> {
+        self.0
+            .which(binary_name.as_ref())
+            .await
+            .map(|path| path.to_string_lossy().to_string())
+    }
+
+    async fn shell_env(&self) -> Vec<(String, String)> {
+        self.0.shell_env().await.into_iter().collect()
+    }
+}
+
+#[async_trait(?Send)]
+impl DebugAdapter for ExtensionDapAdapter {
+    fn name(&self) -> DebugAdapterName {
+        self.debug_adapter_name.as_ref().into()
+    }
+
+    fn dap_schema(&self) -> serde_json::Value {
+        self.schema.clone()
+    }
+
+    async fn get_binary(
+        &self,
+        delegate: &Arc<dyn DapDelegate>,
+        config: &DebugTaskDefinition,
+        user_installed_path: Option<PathBuf>,
+        // TODO support user args in the extension API
+        _user_args: Option<Vec<String>>,
+        _cx: &mut AsyncApp,
+    ) -> Result<DebugAdapterBinary> {
+        self.extension
+            .get_dap_binary(
+                self.debug_adapter_name.clone(),
+                config.clone(),
+                user_installed_path,
+                Arc::new(WorktreeDelegateAdapter(delegate.clone())),
+            )
+            .await
+    }
+
+    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
+        self.extension.dap_config_to_scenario(zed_scenario).await
+    }
+
+    async fn request_kind(
+        &self,
+        config: &serde_json::Value,
+    ) -> Result<StartDebuggingRequestArgumentsRequest> {
+        self.extension
+            .dap_request_kind(self.debug_adapter_name.clone(), config.clone())
+            .await
+    }
+}

crates/debug_adapter_extension/src/extension_locator_adapter.rs 🔗

@@ -0,0 +1,50 @@
+use anyhow::Result;
+use async_trait::async_trait;
+use dap::{DapLocator, DebugRequest, adapters::DebugAdapterName};
+use extension::Extension;
+use gpui::SharedString;
+use std::sync::Arc;
+use task::{DebugScenario, SpawnInTerminal, TaskTemplate};
+
+pub(crate) struct ExtensionLocatorAdapter {
+    extension: Arc<dyn Extension>,
+    locator_name: SharedString,
+}
+
+impl ExtensionLocatorAdapter {
+    pub(crate) fn new(extension: Arc<dyn extension::Extension>, locator_name: Arc<str>) -> Self {
+        Self {
+            extension,
+            locator_name: SharedString::from(locator_name),
+        }
+    }
+}
+
+#[async_trait]
+impl DapLocator for ExtensionLocatorAdapter {
+    fn name(&self) -> SharedString {
+        self.locator_name.clone()
+    }
+    /// Determines whether this locator can generate debug target for given task.
+    async fn create_scenario(
+        &self,
+        build_config: &TaskTemplate,
+        resolved_label: &str,
+        adapter: &DebugAdapterName,
+    ) -> Option<DebugScenario> {
+        self.extension
+            .dap_locator_create_scenario(
+                self.locator_name.as_ref().to_owned(),
+                build_config.clone(),
+                resolved_label.to_owned(),
+                adapter.0.as_ref().to_owned(),
+            )
+            .await
+            .ok()
+            .flatten()
+    }
+
+    async fn run(&self, _build_config: SpawnInTerminal) -> Result<DebugRequest> {
+        Err(anyhow::anyhow!("Not implemented"))
+    }
+}

crates/debugger_tools/src/dap_log.rs 🔗

@@ -1,4 +1,5 @@
 use dap::{
+    adapters::DebugAdapterName,
     client::SessionId,
     debugger_settings::DebuggerSettings,
     transport::{IoKind, LogKind},
@@ -31,6 +32,13 @@ use workspace::{
     ui::{Button, Clickable, ContextMenu, Label, LabelCommon, PopoverMenu, h_flex},
 };
 
+// TODO:
+// - [x] stop sorting by session ID
+// - [x] pick the most recent session by default (logs if available, RPC messages otherwise)
+// - [ ] dump the launch/attach request somewhere (logs?)
+
+const MAX_SESSIONS: usize = 10;
+
 struct DapLogView {
     editor: Entity<Editor>,
     focus_handle: FocusHandle,
@@ -43,9 +51,9 @@ struct DapLogView {
 
 pub struct LogStore {
     projects: HashMap<WeakEntity<Project>, ProjectState>,
-    debug_clients: HashMap<SessionId, DebugAdapterState>,
-    rpc_tx: UnboundedSender<(SessionId, IoKind, String)>,
-    adapter_log_tx: UnboundedSender<(SessionId, IoKind, String)>,
+    debug_sessions: VecDeque<DebugAdapterState>,
+    rpc_tx: UnboundedSender<(SessionId, IoKind, Option<SharedString>, SharedString)>,
+    adapter_log_tx: UnboundedSender<(SessionId, IoKind, Option<SharedString>, SharedString)>,
 }
 
 struct ProjectState {
@@ -53,13 +61,19 @@ struct ProjectState {
 }
 
 struct DebugAdapterState {
-    log_messages: VecDeque<String>,
+    id: SessionId,
+    log_messages: VecDeque<SharedString>,
     rpc_messages: RpcMessages,
+    adapter_name: DebugAdapterName,
+    has_adapter_logs: bool,
+    is_terminated: bool,
 }
 
 struct RpcMessages {
-    messages: VecDeque<String>,
+    messages: VecDeque<SharedString>,
     last_message_kind: Option<MessageKind>,
+    initialization_sequence: Vec<SharedString>,
+    last_init_message_kind: Option<MessageKind>,
 }
 
 impl RpcMessages {
@@ -68,7 +82,9 @@ impl RpcMessages {
     fn new() -> Self {
         Self {
             last_message_kind: None,
+            last_init_message_kind: None,
             messages: VecDeque::with_capacity(Self::MESSAGE_QUEUE_LIMIT),
+            initialization_sequence: Vec::new(),
         }
     }
 }
@@ -92,22 +108,27 @@ impl MessageKind {
 }
 
 impl DebugAdapterState {
-    fn new() -> Self {
+    fn new(id: SessionId, adapter_name: DebugAdapterName, has_adapter_logs: bool) -> Self {
         Self {
+            id,
             log_messages: VecDeque::new(),
             rpc_messages: RpcMessages::new(),
+            adapter_name,
+            has_adapter_logs,
+            is_terminated: false,
         }
     }
 }
 
 impl LogStore {
     pub fn new(cx: &Context<Self>) -> Self {
-        let (rpc_tx, mut rpc_rx) = unbounded::<(SessionId, IoKind, String)>();
+        let (rpc_tx, mut rpc_rx) =
+            unbounded::<(SessionId, IoKind, Option<SharedString>, SharedString)>();
         cx.spawn(async move |this, cx| {
-            while let Some((client_id, io_kind, message)) = rpc_rx.next().await {
+            while let Some((session_id, io_kind, command, message)) = rpc_rx.next().await {
                 if let Some(this) = this.upgrade() {
                     this.update(cx, |this, cx| {
-                        this.on_rpc_log(client_id, io_kind, &message, cx);
+                        this.add_debug_adapter_message(session_id, io_kind, command, message, cx);
                     })?;
                 }
 
@@ -117,12 +138,13 @@ impl LogStore {
         })
         .detach_and_log_err(cx);
 
-        let (adapter_log_tx, mut adapter_log_rx) = unbounded::<(SessionId, IoKind, String)>();
+        let (adapter_log_tx, mut adapter_log_rx) =
+            unbounded::<(SessionId, IoKind, Option<SharedString>, SharedString)>();
         cx.spawn(async move |this, cx| {
-            while let Some((client_id, io_kind, message)) = adapter_log_rx.next().await {
+            while let Some((session_id, io_kind, _, message)) = adapter_log_rx.next().await {
                 if let Some(this) = this.upgrade() {
                     this.update(cx, |this, cx| {
-                        this.on_adapter_log(client_id, io_kind, &message, cx);
+                        this.add_debug_adapter_log(session_id, io_kind, message, cx);
                     })?;
                 }
 
@@ -135,30 +157,10 @@ impl LogStore {
             rpc_tx,
             adapter_log_tx,
             projects: HashMap::new(),
-            debug_clients: HashMap::new(),
+            debug_sessions: Default::default(),
         }
     }
 
-    fn on_rpc_log(
-        &mut self,
-        client_id: SessionId,
-        io_kind: IoKind,
-        message: &str,
-        cx: &mut Context<Self>,
-    ) {
-        self.add_debug_client_message(client_id, io_kind, message.to_string(), cx);
-    }
-
-    fn on_adapter_log(
-        &mut self,
-        client_id: SessionId,
-        io_kind: IoKind,
-        message: &str,
-        cx: &mut Context<Self>,
-    ) {
-        self.add_debug_client_log(client_id, io_kind, message.to_string(), cx);
-    }
-
     pub fn add_project(&mut self, project: &Entity<Project>, cx: &mut Context<Self>) {
         let weak_project = project.downgrade();
         self.projects.insert(
@@ -174,13 +176,15 @@ impl LogStore {
                             dap_store::DapStoreEvent::DebugClientStarted(session_id) => {
                                 let session = dap_store.read(cx).session_by_id(session_id);
                                 if let Some(session) = session {
-                                    this.add_debug_client(*session_id, session, cx);
+                                    this.add_debug_session(*session_id, session, cx);
                                 }
                             }
                             dap_store::DapStoreEvent::DebugClientShutdown(session_id) => {
-                                this.remove_debug_client(*session_id, cx);
+                                this.get_debug_adapter_state(*session_id)
+                                    .iter_mut()
+                                    .for_each(|state| state.is_terminated = true);
+                                this.clean_sessions(cx);
                             }
-
                             _ => {}
                         },
                     ),
@@ -190,63 +194,88 @@ impl LogStore {
     }
 
     fn get_debug_adapter_state(&mut self, id: SessionId) -> Option<&mut DebugAdapterState> {
-        self.debug_clients.get_mut(&id)
+        self.debug_sessions
+            .iter_mut()
+            .find(|adapter_state| adapter_state.id == id)
     }
 
-    fn add_debug_client_message(
+    fn add_debug_adapter_message(
         &mut self,
         id: SessionId,
         io_kind: IoKind,
-        message: String,
+        command: Option<SharedString>,
+        message: SharedString,
         cx: &mut Context<Self>,
     ) {
         let Some(debug_client_state) = self.get_debug_adapter_state(id) else {
             return;
         };
 
+        let is_init_seq = command.as_ref().is_some_and(|command| {
+            matches!(
+                command.as_ref(),
+                "attach" | "launch" | "initialize" | "configurationDone"
+            )
+        });
+
         let kind = match io_kind {
             IoKind::StdOut | IoKind::StdErr => MessageKind::Receive,
             IoKind::StdIn => MessageKind::Send,
         };
 
         let rpc_messages = &mut debug_client_state.rpc_messages;
+
+        // Push a separator if the kind has changed
         if rpc_messages.last_message_kind != Some(kind) {
-            Self::add_debug_client_entry(
+            Self::get_debug_adapter_entry(
                 &mut rpc_messages.messages,
                 id,
-                kind.label().to_string(),
+                kind.label().into(),
                 LogKind::Rpc,
                 cx,
             );
             rpc_messages.last_message_kind = Some(kind);
         }
-        Self::add_debug_client_entry(&mut rpc_messages.messages, id, message, LogKind::Rpc, cx);
+
+        let entry = Self::get_debug_adapter_entry(
+            &mut rpc_messages.messages,
+            id,
+            message,
+            LogKind::Rpc,
+            cx,
+        );
+
+        if is_init_seq {
+            if rpc_messages.last_init_message_kind != Some(kind) {
+                rpc_messages
+                    .initialization_sequence
+                    .push(SharedString::from(kind.label()));
+                rpc_messages.last_init_message_kind = Some(kind);
+            }
+            rpc_messages.initialization_sequence.push(entry);
+        }
 
         cx.notify();
     }
 
-    fn add_debug_client_log(
+    fn add_debug_adapter_log(
         &mut self,
         id: SessionId,
         io_kind: IoKind,
-        message: String,
+        message: SharedString,
         cx: &mut Context<Self>,
     ) {
-        let Some(debug_client_state) = self.get_debug_adapter_state(id) else {
+        let Some(debug_adapter_state) = self.get_debug_adapter_state(id) else {
             return;
         };
 
         let message = match io_kind {
-            IoKind::StdErr => {
-                let mut message = message.clone();
-                message.insert_str(0, "stderr: ");
-                message
-            }
+            IoKind::StdErr => format!("stderr: {message}").into(),
             _ => message,
         };
 
-        Self::add_debug_client_entry(
-            &mut debug_client_state.log_messages,
+        Self::get_debug_adapter_entry(
+            &mut debug_adapter_state.log_messages,
             id,
             message,
             LogKind::Adapter,
@@ -255,13 +284,13 @@ impl LogStore {
         cx.notify();
     }
 
-    fn add_debug_client_entry(
-        log_lines: &mut VecDeque<String>,
+    fn get_debug_adapter_entry(
+        log_lines: &mut VecDeque<SharedString>,
         id: SessionId,
-        message: String,
+        message: SharedString,
         kind: LogKind,
         cx: &mut Context<Self>,
-    ) {
+    ) -> SharedString {
         while log_lines.len() >= RpcMessages::MESSAGE_QUEUE_LIMIT {
             log_lines.pop_front();
         }
@@ -275,33 +304,69 @@ impl LogStore {
                 )
                 .ok()
             })
+            .map(SharedString::from)
             .unwrap_or(message)
         } else {
             message
         };
         log_lines.push_back(entry.clone());
 
-        cx.emit(Event::NewLogEntry { id, entry, kind });
+        cx.emit(Event::NewLogEntry {
+            id,
+            entry: entry.clone(),
+            kind,
+        });
+
+        entry
     }
 
-    fn add_debug_client(
+    fn add_debug_session(
         &mut self,
-        client_id: SessionId,
-        client: Entity<Session>,
-        cx: &App,
-    ) -> Option<&mut DebugAdapterState> {
-        let client_state = self
-            .debug_clients
-            .entry(client_id)
-            .or_insert_with(DebugAdapterState::new);
+        session_id: SessionId,
+        session: Entity<Session>,
+        cx: &mut Context<Self>,
+    ) {
+        if self
+            .debug_sessions
+            .iter_mut()
+            .any(|adapter_state| adapter_state.id == session_id)
+        {
+            return;
+        }
+
+        let (adapter_name, has_adapter_logs) = session.read_with(cx, |session, _| {
+            (
+                session.adapter(),
+                session
+                    .adapter_client()
+                    .map(|client| client.has_adapter_logs())
+                    .unwrap_or(false),
+            )
+        });
+
+        self.debug_sessions.push_back(DebugAdapterState::new(
+            session_id,
+            adapter_name,
+            has_adapter_logs,
+        ));
+
+        self.clean_sessions(cx);
 
         let io_tx = self.rpc_tx.clone();
 
-        let client = client.read(cx).adapter_client()?;
+        let Some(client) = session.read(cx).adapter_client() else {
+            return;
+        };
+
         client.add_log_handler(
-            move |io_kind, message| {
+            move |io_kind, command, message| {
                 io_tx
-                    .unbounded_send((client_id, io_kind, message.to_string()))
+                    .unbounded_send((
+                        session_id,
+                        io_kind,
+                        command.map(|command| command.to_owned().into()),
+                        message.to_owned().into(),
+                    ))
                     .ok();
             },
             LogKind::Rpc,
@@ -309,34 +374,66 @@ impl LogStore {
 
         let log_io_tx = self.adapter_log_tx.clone();
         client.add_log_handler(
-            move |io_kind, message| {
+            move |io_kind, command, message| {
                 log_io_tx
-                    .unbounded_send((client_id, io_kind, message.to_string()))
+                    .unbounded_send((
+                        session_id,
+                        io_kind,
+                        command.map(|command| command.to_owned().into()),
+                        message.to_owned().into(),
+                    ))
                     .ok();
             },
             LogKind::Adapter,
         );
-
-        Some(client_state)
     }
 
-    fn remove_debug_client(&mut self, client_id: SessionId, cx: &mut Context<Self>) {
-        self.debug_clients.remove(&client_id);
+    fn clean_sessions(&mut self, cx: &mut Context<Self>) {
+        let mut to_remove = self.debug_sessions.len().saturating_sub(MAX_SESSIONS);
+        self.debug_sessions.retain(|session| {
+            if to_remove > 0 && session.is_terminated {
+                to_remove -= 1;
+                return false;
+            }
+            true
+        });
         cx.notify();
     }
 
-    fn log_messages_for_client(&mut self, client_id: SessionId) -> Option<&mut VecDeque<String>> {
-        Some(&mut self.debug_clients.get_mut(&client_id)?.log_messages)
+    fn log_messages_for_session(
+        &mut self,
+        session_id: SessionId,
+    ) -> Option<&mut VecDeque<SharedString>> {
+        self.debug_sessions
+            .iter_mut()
+            .find(|session| session.id == session_id)
+            .map(|state| &mut state.log_messages)
+    }
+
+    fn rpc_messages_for_session(
+        &mut self,
+        session_id: SessionId,
+    ) -> Option<&mut VecDeque<SharedString>> {
+        self.debug_sessions.iter_mut().find_map(|state| {
+            if state.id == session_id {
+                Some(&mut state.rpc_messages.messages)
+            } else {
+                None
+            }
+        })
     }
 
-    fn rpc_messages_for_client(&mut self, client_id: SessionId) -> Option<&mut VecDeque<String>> {
-        Some(
-            &mut self
-                .debug_clients
-                .get_mut(&client_id)?
-                .rpc_messages
-                .messages,
-        )
+    fn initialization_sequence_for_session(
+        &mut self,
+        session_id: SessionId,
+    ) -> Option<&mut Vec<SharedString>> {
+        self.debug_sessions.iter_mut().find_map(|state| {
+            if state.id == session_id {
+                Some(&mut state.rpc_messages.initialization_sequence)
+            } else {
+                None
+            }
+        })
     }
 }
 
@@ -356,18 +453,15 @@ impl Render for DapLogToolbarItemView {
             return Empty.into_any_element();
         };
 
-        let (menu_rows, current_client_id) = log_view.update(cx, |log_view, cx| {
+        let (menu_rows, current_session_id) = log_view.update(cx, |log_view, cx| {
             (
-                log_view.menu_items(cx).unwrap_or_default(),
-                log_view.current_view.map(|(client_id, _)| client_id),
+                log_view.menu_items(cx),
+                log_view.current_view.map(|(session_id, _)| session_id),
             )
         });
 
-        let current_client = current_client_id.and_then(|current_client_id| {
-            menu_rows
-                .iter()
-                .find(|row| row.client_id == current_client_id)
-        });
+        let current_client = current_session_id
+            .and_then(|session_id| menu_rows.iter().find(|row| row.session_id == session_id));
 
         let dap_menu: PopoverMenu<_> = PopoverMenu::new("DapLogView")
             .anchor(gpui::Corner::TopLeft)
@@ -377,8 +471,8 @@ impl Render for DapLogToolbarItemView {
                     .map(|sub_item| {
                         Cow::Owned(format!(
                             "{} ({}) - {}",
-                            sub_item.client_name,
-                            sub_item.client_id.0,
+                            sub_item.adapter_name,
+                            sub_item.session_id.0,
                             match sub_item.selected_entry {
                                 LogKind::Adapter => ADAPTER_LOGS,
                                 LogKind::Rpc => RPC_MESSAGES,
@@ -397,9 +491,10 @@ impl Render for DapLogToolbarItemView {
                                 .w_full()
                                 .pl_2()
                                 .child(
-                                    Label::new(
-                                        format!("{}. {}", row.client_id.0, row.client_name,),
-                                    )
+                                    Label::new(format!(
+                                        "{}. {}",
+                                        row.session_id.0, row.adapter_name,
+                                    ))
                                     .color(workspace::ui::Color::Muted),
                                 )
                                 .into_any_element()
@@ -415,23 +510,40 @@ impl Render for DapLogToolbarItemView {
                                         .into_any_element()
                                 },
                                 window.handler_for(&log_view, move |view, window, cx| {
-                                    view.show_log_messages_for_adapter(row.client_id, window, cx);
+                                    view.show_log_messages_for_adapter(row.session_id, window, cx);
                                 }),
                             );
                         }
 
-                        menu = menu.custom_entry(
-                            move |_window, _cx| {
-                                div()
-                                    .w_full()
-                                    .pl_4()
-                                    .child(Label::new(RPC_MESSAGES))
-                                    .into_any_element()
-                            },
-                            window.handler_for(&log_view, move |view, window, cx| {
-                                view.show_rpc_trace_for_server(row.client_id, window, cx);
-                            }),
-                        );
+                        menu = menu
+                            .custom_entry(
+                                move |_window, _cx| {
+                                    div()
+                                        .w_full()
+                                        .pl_4()
+                                        .child(Label::new(RPC_MESSAGES))
+                                        .into_any_element()
+                                },
+                                window.handler_for(&log_view, move |view, window, cx| {
+                                    view.show_rpc_trace_for_server(row.session_id, window, cx);
+                                }),
+                            )
+                            .custom_entry(
+                                move |_window, _cx| {
+                                    div()
+                                        .w_full()
+                                        .pl_4()
+                                        .child(Label::new(INITIALIZATION_SEQUENCE))
+                                        .into_any_element()
+                                },
+                                window.handler_for(&log_view, move |view, window, cx| {
+                                    view.show_initialization_sequence_for_server(
+                                        row.session_id,
+                                        window,
+                                        cx,
+                                    );
+                                }),
+                            );
                     }
 
                     menu
@@ -518,7 +630,13 @@ impl DapLogView {
             }
         });
 
-        Self {
+        let state_info = log_store
+            .read(cx)
+            .debug_sessions
+            .back()
+            .map(|session| (session.id, session.has_adapter_logs));
+
+        let mut this = Self {
             editor,
             focus_handle,
             project,
@@ -526,7 +644,17 @@ impl DapLogView {
             editor_subscriptions,
             current_view: None,
             _subscriptions: vec![events_subscriptions],
+        };
+
+        if let Some((session_id, have_adapter_logs)) = state_info {
+            if have_adapter_logs {
+                this.show_log_messages_for_adapter(session_id, window, cx);
+            } else {
+                this.show_rpc_trace_for_server(session_id, window, cx);
+            }
         }
+
+        this
     }
 
     fn editor_for_logs(
@@ -559,42 +687,34 @@ impl DapLogView {
         (editor, vec![editor_subscription, search_subscription])
     }
 
-    fn menu_items(&self, cx: &App) -> Option<Vec<DapMenuItem>> {
-        let mut menu_items = self
-            .project
+    fn menu_items(&self, cx: &App) -> Vec<DapMenuItem> {
+        self.log_store
             .read(cx)
-            .dap_store()
-            .read(cx)
-            .sessions()
-            .filter_map(|session| {
-                let session = session.read(cx);
-                session.adapter();
-                let client = session.adapter_client()?;
-                Some(DapMenuItem {
-                    client_id: client.id(),
-                    client_name: session.adapter().to_string(),
-                    has_adapter_logs: client.has_adapter_logs(),
-                    selected_entry: self.current_view.map_or(LogKind::Adapter, |(_, kind)| kind),
-                })
+            .debug_sessions
+            .iter()
+            .rev()
+            .map(|state| DapMenuItem {
+                session_id: state.id,
+                adapter_name: state.adapter_name.clone(),
+                has_adapter_logs: state.has_adapter_logs,
+                selected_entry: self.current_view.map_or(LogKind::Adapter, |(_, kind)| kind),
             })
-            .collect::<Vec<_>>();
-        menu_items.sort_by_key(|item| item.client_id.0);
-        Some(menu_items)
+            .collect::<Vec<_>>()
     }
 
     fn show_rpc_trace_for_server(
         &mut self,
-        client_id: SessionId,
+        session_id: SessionId,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         let rpc_log = self.log_store.update(cx, |log_store, _| {
             log_store
-                .rpc_messages_for_client(client_id)
-                .map(|state| log_contents(&state))
+                .rpc_messages_for_session(session_id)
+                .map(|state| log_contents(state.iter().cloned()))
         });
         if let Some(rpc_log) = rpc_log {
-            self.current_view = Some((client_id, LogKind::Rpc));
+            self.current_view = Some((session_id, LogKind::Rpc));
             let (editor, editor_subscriptions) = Self::editor_for_logs(rpc_log, window, cx);
             let language = self.project.read(cx).languages().language_for_name("JSON");
             editor
@@ -626,17 +746,17 @@ impl DapLogView {
 
     fn show_log_messages_for_adapter(
         &mut self,
-        client_id: SessionId,
+        session_id: SessionId,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         let message_log = self.log_store.update(cx, |log_store, _| {
             log_store
-                .log_messages_for_client(client_id)
-                .map(|state| log_contents(&state))
+                .log_messages_for_session(session_id)
+                .map(|state| log_contents(state.iter().cloned()))
         });
         if let Some(message_log) = message_log {
-            self.current_view = Some((client_id, LogKind::Adapter));
+            self.current_view = Some((session_id, LogKind::Adapter));
             let (editor, editor_subscriptions) = Self::editor_for_logs(message_log, window, cx);
             editor
                 .read(cx)
@@ -652,14 +772,53 @@ impl DapLogView {
 
         cx.focus_self(window);
     }
+
+    fn show_initialization_sequence_for_server(
+        &mut self,
+        session_id: SessionId,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let rpc_log = self.log_store.update(cx, |log_store, _| {
+            log_store
+                .initialization_sequence_for_session(session_id)
+                .map(|state| log_contents(state.iter().cloned()))
+        });
+        if let Some(rpc_log) = rpc_log {
+            self.current_view = Some((session_id, LogKind::Rpc));
+            let (editor, editor_subscriptions) = Self::editor_for_logs(rpc_log, window, cx);
+            let language = self.project.read(cx).languages().language_for_name("JSON");
+            editor
+                .read(cx)
+                .buffer()
+                .read(cx)
+                .as_singleton()
+                .expect("log buffer should be a singleton")
+                .update(cx, |_, cx| {
+                    cx.spawn({
+                        let buffer = cx.entity();
+                        async move |_, cx| {
+                            let language = language.await.ok();
+                            buffer.update(cx, |buffer, cx| {
+                                buffer.set_language(language, cx);
+                            })
+                        }
+                    })
+                    .detach_and_log_err(cx);
+                });
+
+            self.editor = editor;
+            self.editor_subscriptions = editor_subscriptions;
+            cx.notify();
+        }
+
+        cx.focus_self(window);
+    }
 }
 
-fn log_contents(lines: &VecDeque<String>) -> String {
-    let (a, b) = lines.as_slices();
-    let a = a.iter().map(move |v| v.as_ref());
-    let b = b.iter().map(move |v| v.as_ref());
-    a.chain(b).fold(String::new(), |mut acc, el| {
-        acc.push_str(el);
+fn log_contents(lines: impl Iterator<Item = SharedString>) -> String {
+    lines.fold(String::new(), |mut acc, el| {
+        acc.push_str(&el);
         acc.push('\n');
         acc
     })
@@ -667,14 +826,15 @@ fn log_contents(lines: &VecDeque<String>) -> String {
 
 #[derive(Clone, PartialEq)]
 pub(crate) struct DapMenuItem {
-    pub client_id: SessionId,
-    pub client_name: String,
+    pub session_id: SessionId,
+    pub adapter_name: DebugAdapterName,
     pub has_adapter_logs: bool,
     pub selected_entry: LogKind,
 }
 
 const ADAPTER_LOGS: &str = "Adapter Logs";
 const RPC_MESSAGES: &str = "RPC Messages";
+const INITIALIZATION_SEQUENCE: &str = "Initialization Sequence";
 
 impl Render for DapLogView {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
@@ -684,7 +844,7 @@ impl Render for DapLogView {
     }
 }
 
-actions!(debug, [OpenDebuggerAdapterLogs]);
+actions!(dev, [OpenDebugAdapterLogs]);
 
 pub fn init(cx: &mut App) {
     let log_store = cx.new(|cx| LogStore::new(cx));
@@ -702,7 +862,7 @@ pub fn init(cx: &mut App) {
         }
 
         let log_store = log_store.clone();
-        workspace.register_action(move |workspace, _: &OpenDebuggerAdapterLogs, window, cx| {
+        workspace.register_action(move |workspace, _: &OpenDebugAdapterLogs, window, cx| {
             let project = workspace.project().read(cx);
             if project.is_local() {
                 workspace.add_item_to_active_pane(
@@ -836,7 +996,7 @@ impl Focusable for DapLogView {
 pub enum Event {
     NewLogEntry {
         id: SessionId,
-        entry: String,
+        entry: SharedString,
         kind: LogKind,
     },
 }
@@ -849,12 +1009,16 @@ impl EventEmitter<SearchEvent> for DapLogView {}
 #[cfg(any(test, feature = "test-support"))]
 impl LogStore {
     pub fn contained_session_ids(&self) -> Vec<SessionId> {
-        self.debug_clients.keys().cloned().collect()
+        self.debug_sessions
+            .iter()
+            .map(|session| session.id)
+            .collect()
     }
 
-    pub fn rpc_messages_for_session_id(&self, session_id: SessionId) -> Vec<String> {
-        self.debug_clients
-            .get(&session_id)
+    pub fn rpc_messages_for_session_id(&self, session_id: SessionId) -> Vec<SharedString> {
+        self.debug_sessions
+            .iter()
+            .find(|adapter_state| adapter_state.id == session_id)
             .expect("This session should exist if a test is calling")
             .rpc_messages
             .messages
@@ -862,9 +1026,10 @@ impl LogStore {
             .into()
     }
 
-    pub fn log_messages_for_session_id(&self, session_id: SessionId) -> Vec<String> {
-        self.debug_clients
-            .get(&session_id)
+    pub fn log_messages_for_session_id(&self, session_id: SessionId) -> Vec<SharedString> {
+        self.debug_sessions
+            .iter()
+            .find(|adapter_state| adapter_state.id == session_id)
             .expect("This session should exist if a test is calling")
             .log_messages
             .clone()

crates/debugger_ui/Cargo.toml 🔗

@@ -21,13 +21,14 @@ test-support = [
     "project/test-support",
     "util/test-support",
     "workspace/test-support",
-    "env_logger",
     "unindent",
     "debugger_tools"
 ]
 
 [dependencies]
+alacritty_terminal.workspace = true
 anyhow.workspace = true
+bitflags.workspace = true
 client.workspace = true
 collections.workspace = true
 command_palette_hooks.workspace = true
@@ -39,11 +40,12 @@ debugger_tools = { workspace = true, optional = true }
 editor.workspace = true
 env_logger = { workspace = true, optional = true }
 feature_flags.workspace = true
+file_icons.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
+itertools.workspace = true
 language.workspace = true
-linkme.workspace = true
 log.workspace = true
 menu.workspace = true
 parking_lot.workspace = true
@@ -54,26 +56,33 @@ project.workspace = true
 rpc.workspace = true
 serde.workspace = true
 serde_json.workspace = true
+serde_json_lenient.workspace = true
 settings.workspace = true
+shlex.workspace = true
 sysinfo.workspace = true
 task.workspace = true
 tasks_ui.workspace = true
+telemetry.workspace = true
 terminal_view.workspace = true
 theme.workspace = true
+tree-sitter.workspace = true
+tree-sitter-json.workspace = true
 ui.workspace = true
 unindent = { workspace = true, optional = true }
 util.workspace = true
 workspace-hack.workspace = true
 workspace.workspace = true
+zed_actions.workspace = true
 
 [dev-dependencies]
 dap = { workspace = true, features = ["test-support"] }
 dap_adapters = { workspace = true, features = ["test-support"] }
 debugger_tools = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
-env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 project = { workspace = true, features = ["test-support"] }
 unindent.workspace = true
 util = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
+zlog.workspace = true
+tree-sitter-go.workspace = true

crates/debugger_ui/src/attach_modal.rs 🔗

@@ -1,15 +1,15 @@
-use dap::DebugRequest;
-use dap::adapters::DebugTaskDefinition;
+use dap::{DapRegistry, DebugRequest};
 use fuzzy::{StringMatch, StringMatchCandidate};
-use gpui::{DismissEvent, Entity, EventEmitter, Focusable, Render};
+use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Render};
 use gpui::{Subscription, WeakEntity};
 use picker::{Picker, PickerDelegate};
+use task::ZedDebugConfig;
+use util::debug_panic;
 
 use std::sync::Arc;
 use sysinfo::System;
 use ui::{Context, Tooltip, prelude::*};
 use ui::{ListItem, ListItemSpacing};
-use util::debug_panic;
 use workspace::{ModalView, Workspace};
 
 use crate::debugger_panel::DebugPanel;
@@ -25,7 +25,7 @@ pub(crate) struct AttachModalDelegate {
     selected_index: usize,
     matches: Vec<StringMatch>,
     placeholder_text: Arc<str>,
-    pub(crate) definition: DebugTaskDefinition,
+    pub(crate) definition: ZedDebugConfig,
     workspace: WeakEntity<Workspace>,
     candidates: Arc<[Candidate]>,
 }
@@ -33,7 +33,7 @@ pub(crate) struct AttachModalDelegate {
 impl AttachModalDelegate {
     fn new(
         workspace: WeakEntity<Workspace>,
-        definition: DebugTaskDefinition,
+        definition: ZedDebugConfig,
         candidates: Arc<[Candidate]>,
     ) -> Self {
         Self {
@@ -54,7 +54,7 @@ pub struct AttachModal {
 
 impl AttachModal {
     pub fn new(
-        definition: DebugTaskDefinition,
+        definition: ZedDebugConfig,
         workspace: WeakEntity<Workspace>,
         modal: bool,
         window: &mut Window,
@@ -83,7 +83,7 @@ impl AttachModal {
 
     pub(super) fn with_processes(
         workspace: WeakEntity<Workspace>,
-        definition: DebugTaskDefinition,
+        definition: ZedDebugConfig,
         processes: Arc<[Candidate]>,
         modal: bool,
         window: &mut Window,
@@ -158,7 +158,7 @@ impl PickerDelegate for AttachModalDelegate {
     ) -> gpui::Task<()> {
         cx.spawn(async move |this, cx| {
             let Some(processes) = this
-                .update(cx, |this, _| this.delegate.candidates.clone())
+                .read_with(cx, |this, _| this.delegate.candidates.clone())
                 .ok()
             else {
                 return;
@@ -183,6 +183,7 @@ impl PickerDelegate for AttachModalDelegate {
                     .collect::<Vec<_>>(),
                 &query,
                 true,
+                true,
                 100,
                 &Default::default(),
                 cx.background_executor().clone(),
@@ -228,20 +229,36 @@ impl PickerDelegate for AttachModalDelegate {
             }
         }
 
-        let scenario = self.definition.to_scenario();
+        let Some(adapter) = cx.read_global::<DapRegistry, _>(|registry, _| {
+            registry.adapter(&self.definition.adapter)
+        }) else {
+            return;
+        };
 
-        let panel = self
-            .workspace
-            .update(cx, |workspace, cx| workspace.panel::<DebugPanel>(cx))
-            .ok()
-            .flatten();
-        if let Some(panel) = panel {
-            panel.update(cx, |panel, cx| {
-                panel.start_session(scenario, Default::default(), None, None, window, cx);
-            });
-        }
+        let workspace = self.workspace.clone();
+        let definition = self.definition.clone();
+        cx.spawn_in(window, async move |this, cx| {
+            let Ok(scenario) = adapter.config_from_zed_format(definition).await else {
+                return;
+            };
 
-        cx.emit(DismissEvent);
+            let panel = workspace
+                .update(cx, |workspace, cx| workspace.panel::<DebugPanel>(cx))
+                .ok()
+                .flatten();
+            if let Some(panel) = panel {
+                panel
+                    .update_in(cx, |panel, window, cx| {
+                        panel.start_session(scenario, Default::default(), None, None, window, cx);
+                    })
+                    .ok();
+            }
+            this.update(cx, |_, cx| {
+                cx.emit(DismissEvent);
+            })
+            .ok();
+        })
+        .detach();
     }
 
     fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
@@ -303,7 +320,7 @@ impl PickerDelegate for AttachModalDelegate {
 
 #[cfg(any(test, feature = "test-support"))]
 pub(crate) fn _process_names(modal: &AttachModal, cx: &mut Context<AttachModal>) -> Vec<String> {
-    modal.picker.update(cx, |picker, _| {
+    modal.picker.read_with(cx, |picker, _| {
         picker
             .delegate
             .matches

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -1,40 +1,45 @@
 use crate::persistence::DebuggerPaneItem;
 use crate::session::DebugSession;
+use crate::session::running::RunningState;
+use crate::session::running::breakpoint_list::BreakpointList;
 use crate::{
-    ClearAllBreakpoints, Continue, Detach, FocusBreakpointList, FocusConsole, FocusFrames,
-    FocusLoadedSources, FocusModules, FocusTerminal, FocusVariables, Pause, Restart, StepBack,
-    StepInto, StepOut, StepOver, Stop, ToggleIgnoreBreakpoints, persistence,
+    ClearAllBreakpoints, Continue, CopyDebugAdapterArguments, Detach, FocusBreakpointList,
+    FocusConsole, FocusFrames, FocusLoadedSources, FocusModules, FocusTerminal, FocusVariables,
+    NewProcessModal, NewProcessMode, Pause, Restart, StepInto, StepOut, StepOver, Stop,
+    ToggleExpandItem, ToggleSessionPicker, ToggleThreadPicker, persistence, spawn_task_or_modal,
 };
-use anyhow::{Result, anyhow};
-use command_palette_hooks::CommandPaletteFilter;
-use dap::StartDebuggingRequestArguments;
+use anyhow::{Context as _, Result, anyhow};
 use dap::adapters::DebugAdapterName;
 use dap::debugger_settings::DebugPanelDockPosition;
 use dap::{
     ContinuedEvent, LoadedSourceEvent, ModuleEvent, OutputEvent, StoppedEvent, ThreadEvent,
     client::SessionId, debugger_settings::DebuggerSettings,
 };
+use dap::{DapRegistry, StartDebuggingRequestArguments};
 use gpui::{
-    Action, App, AsyncWindowContext, Context, DismissEvent, Entity, EntityId, EventEmitter,
-    FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, Subscription, Task, WeakEntity,
-    actions, anchored, deferred,
+    Action, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity, EntityId,
+    EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, Subscription, Task,
+    WeakEntity, anchored, deferred,
 };
 
+use itertools::Itertools as _;
 use language::Buffer;
 use project::debugger::session::{Session, SessionStateEvent};
-use project::{Fs, WorktreeId};
+use project::{Fs, ProjectPath, WorktreeId};
 use project::{Project, debugger::session::ThreadStatus};
 use rpc::proto::{self};
 use settings::Settings;
-use std::any::TypeId;
-use std::sync::Arc;
+use std::sync::{Arc, LazyLock};
 use task::{DebugScenario, TaskContext};
-use ui::{ContextMenu, Divider, DropdownMenu, Tooltip, prelude::*};
+use tree_sitter::{Query, StreamingIterator as _};
+use ui::{ContextMenu, Divider, PopoverMenuHandle, Tooltip, prelude::*};
+use util::maybe;
 use workspace::SplitDirection;
 use workspace::{
     Pane, Workspace,
     dock::{DockPosition, Panel, PanelEvent},
 };
+use zed_actions::ToggleFocus;
 
 pub enum DebugPanelEvent {
     Exited(SessionId),
@@ -53,8 +58,6 @@ pub enum DebugPanelEvent {
     CapabilitiesChanged(SessionId),
 }
 
-actions!(debug_panel, [ToggleFocus]);
-
 pub struct DebugPanel {
     size: Pixels,
     sessions: Vec<Entity<DebugSession>>,
@@ -63,106 +66,86 @@ pub struct DebugPanel {
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
     context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
+    debug_scenario_scheduled_last: bool,
+    pub(crate) thread_picker_menu_handle: PopoverMenuHandle<ContextMenu>,
+    pub(crate) session_picker_menu_handle: PopoverMenuHandle<ContextMenu>,
     fs: Arc<dyn Fs>,
+    is_zoomed: bool,
+    _subscriptions: [Subscription; 1],
+    breakpoint_list: Entity<BreakpointList>,
 }
 
 impl DebugPanel {
     pub fn new(
         workspace: &Workspace,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &mut Context<Workspace>,
     ) -> Entity<Self> {
         cx.new(|cx| {
             let project = workspace.project().clone();
+            let focus_handle = cx.focus_handle();
+            let thread_picker_menu_handle = PopoverMenuHandle::default();
+            let session_picker_menu_handle = PopoverMenuHandle::default();
+
+            let focus_subscription = cx.on_focus(
+                &focus_handle,
+                window,
+                |this: &mut DebugPanel, window, cx| {
+                    this.focus_active_item(window, cx);
+                },
+            );
 
-            let debug_panel = Self {
+            Self {
                 size: px(300.),
                 sessions: vec![],
                 active_session: None,
-                focus_handle: cx.focus_handle(),
+                focus_handle,
+                breakpoint_list: BreakpointList::new(
+                    None,
+                    workspace.weak_handle(),
+                    &project,
+                    window,
+                    cx,
+                ),
                 project,
                 workspace: workspace.weak_handle(),
                 context_menu: None,
                 fs: workspace.app_state().fs.clone(),
-            };
-
-            debug_panel
+                thread_picker_menu_handle,
+                session_picker_menu_handle,
+                is_zoomed: false,
+                _subscriptions: [focus_subscription],
+                debug_scenario_scheduled_last: true,
+            }
         })
     }
 
-    fn filter_action_types(&self, cx: &mut App) {
-        let (has_active_session, supports_restart, support_step_back, status) = self
-            .active_session()
-            .map(|item| {
-                let running = item.read(cx).running_state().clone();
-                let caps = running.read(cx).capabilities(cx);
-                (
-                    !running.read(cx).session().read(cx).is_terminated(),
-                    caps.supports_restart_request.unwrap_or_default(),
-                    caps.supports_step_back.unwrap_or_default(),
-                    running.read(cx).thread_status(cx),
-                )
-            })
-            .unwrap_or((false, false, false, None));
-
-        let filter = CommandPaletteFilter::global_mut(cx);
-        let debugger_action_types = [
-            TypeId::of::<Detach>(),
-            TypeId::of::<Stop>(),
-            TypeId::of::<ToggleIgnoreBreakpoints>(),
-        ];
-
-        let running_action_types = [TypeId::of::<Pause>()];
-
-        let stopped_action_type = [
-            TypeId::of::<Continue>(),
-            TypeId::of::<StepOver>(),
-            TypeId::of::<StepInto>(),
-            TypeId::of::<StepOut>(),
-            TypeId::of::<editor::actions::DebuggerRunToCursor>(),
-            TypeId::of::<editor::actions::DebuggerEvaluateSelectedText>(),
-        ];
-
-        let step_back_action_type = [TypeId::of::<StepBack>()];
-        let restart_action_type = [TypeId::of::<Restart>()];
-
-        if has_active_session {
-            filter.show_action_types(debugger_action_types.iter());
-
-            if supports_restart {
-                filter.show_action_types(restart_action_type.iter());
-            } else {
-                filter.hide_action_types(&restart_action_type);
-            }
+    pub(crate) fn focus_active_item(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(session) = self.active_session.clone() else {
+            return;
+        };
+        let active_pane = session
+            .read(cx)
+            .running_state()
+            .read(cx)
+            .active_pane()
+            .clone();
+        active_pane.update(cx, |pane, cx| {
+            pane.focus_active_item(window, cx);
+        });
+    }
 
-            if support_step_back {
-                filter.show_action_types(step_back_action_type.iter());
-            } else {
-                filter.hide_action_types(&step_back_action_type);
-            }
+    pub(crate) fn sessions(&self) -> Vec<Entity<DebugSession>> {
+        self.sessions.clone()
+    }
 
-            match status {
-                Some(ThreadStatus::Running) => {
-                    filter.show_action_types(running_action_types.iter());
-                    filter.hide_action_types(&stopped_action_type);
-                }
-                Some(ThreadStatus::Stopped) => {
-                    filter.show_action_types(stopped_action_type.iter());
-                    filter.hide_action_types(&running_action_types);
-                }
-                _ => {
-                    filter.hide_action_types(&running_action_types);
-                    filter.hide_action_types(&stopped_action_type);
-                }
-            }
-        } else {
-            // show only the `debug: start`
-            filter.hide_action_types(&debugger_action_types);
-            filter.hide_action_types(&step_back_action_type);
-            filter.hide_action_types(&restart_action_type);
-            filter.hide_action_types(&running_action_types);
-            filter.hide_action_types(&stopped_action_type);
-        }
+    pub fn active_session(&self) -> Option<Entity<DebugSession>> {
+        self.active_session.clone()
+    }
+
+    pub(crate) fn running_state(&self, cx: &mut App) -> Option<Entity<RunningState>> {
+        self.active_session()
+            .map(|session| session.read(cx).running_state().clone())
     }
 
     pub fn load(
@@ -182,17 +165,6 @@ impl DebugPanel {
                     )
                 });
 
-                cx.observe_new::<DebugPanel>(|debug_panel, _, cx| {
-                    Self::filter_action_types(debug_panel, cx);
-                })
-                .detach();
-
-                cx.observe(&debug_panel, |_, debug_panel, cx| {
-                    debug_panel.update(cx, |debug_panel, cx| {
-                        Self::filter_action_types(debug_panel, cx);
-                    });
-                })
-                .detach();
                 workspace.set_debugger_provider(DebuggerProvider(debug_panel.clone()));
 
                 debug_panel
@@ -214,15 +186,42 @@ impl DebugPanel {
             dap_store.new_session(
                 scenario.label.clone(),
                 DebugAdapterName(scenario.adapter.clone()),
+                task_context.clone(),
                 None,
                 cx,
             )
         });
+        let worktree = worktree_id.or_else(|| {
+            active_buffer
+                .as_ref()
+                .and_then(|buffer| buffer.read(cx).file())
+                .map(|f| f.worktree_id(cx))
+        });
+        let Some(worktree) = worktree
+            .and_then(|id| self.project.read(cx).worktree_for_id(id, cx))
+            .or_else(|| self.project.read(cx).visible_worktrees(cx).next())
+        else {
+            log::debug!("Could not find a worktree to spawn the debug session in");
+            return;
+        };
+        self.debug_scenario_scheduled_last = true;
+        if let Some(inventory) = self
+            .project
+            .read(cx)
+            .task_store()
+            .read(cx)
+            .task_inventory()
+            .cloned()
+        {
+            inventory.update(cx, |inventory, _| {
+                inventory.scenario_scheduled(scenario.clone());
+            })
+        }
         let task = cx.spawn_in(window, {
             let session = session.clone();
             async move |this, cx| {
                 let debug_session =
-                    Self::register_session(this.clone(), session.clone(), cx).await?;
+                    Self::register_session(this.clone(), session.clone(), true, cx).await?;
                 let definition = debug_session
                     .update_in(cx, |debug_session, window, cx| {
                         debug_session.running_state().update(cx, |running, cx| {
@@ -237,10 +236,9 @@ impl DebugPanel {
                         })
                     })?
                     .await?;
-
                 dap_store
                     .update(cx, |dap_store, cx| {
-                        dap_store.boot_session(session.clone(), definition, cx)
+                        dap_store.boot_session(session.clone(), definition, worktree, cx)
                     })?
                     .await
             }
@@ -248,6 +246,7 @@ impl DebugPanel {
 
         cx.spawn(async move |_, cx| {
             if let Err(error) = task.await {
+                log::error!("{error}");
                 session
                     .update(cx, |session, cx| {
                         session
@@ -263,111 +262,126 @@ impl DebugPanel {
         .detach_and_log_err(cx);
     }
 
-    async fn register_session(
+    pub(crate) fn rerun_last_session(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let task_store = workspace.project().read(cx).task_store().clone();
+        let Some(task_inventory) = task_store.read(cx).task_inventory() else {
+            return;
+        };
+        let workspace = self.workspace.clone();
+        let Some(scenario) = task_inventory.read(cx).last_scheduled_scenario().cloned() else {
+            window.defer(cx, move |window, cx| {
+                workspace
+                    .update(cx, |workspace, cx| {
+                        NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx);
+                    })
+                    .ok();
+            });
+            return;
+        };
+
+        cx.spawn_in(window, async move |this, cx| {
+            let task_contexts = workspace
+                .update_in(cx, |workspace, window, cx| {
+                    tasks_ui::task_contexts(workspace, window, cx)
+                })?
+                .await;
+
+            let task_context = task_contexts.active_context().cloned().unwrap_or_default();
+            let worktree_id = task_contexts.worktree();
+
+            this.update_in(cx, |this, window, cx| {
+                this.start_session(
+                    scenario.clone(),
+                    task_context,
+                    None,
+                    worktree_id,
+                    window,
+                    cx,
+                );
+            })
+        })
+        .detach();
+    }
+
+    pub(crate) async fn register_session(
         this: WeakEntity<Self>,
         session: Entity<Session>,
+        focus: bool,
         cx: &mut AsyncWindowContext,
     ) -> Result<Entity<DebugSession>> {
-        let adapter_name = session.update(cx, |session, _| session.adapter())?;
-        this.update_in(cx, |_, window, cx| {
-            cx.subscribe_in(
-                &session,
-                window,
-                move |this, session, event: &SessionStateEvent, window, cx| match event {
-                    SessionStateEvent::Restart => {
-                        this.handle_restart_request(session.clone(), window, cx);
-                    }
-                    SessionStateEvent::SpawnChildSession { request } => {
-                        this.handle_start_debugging_request(request, session.clone(), window, cx);
-                    }
-                    _ => {}
-                },
-            )
-            .detach();
-        })
-        .ok();
+        let debug_session = register_session_inner(&this, session, cx).await?;
 
-        let serialized_layout = persistence::get_serialized_layout(adapter_name).await;
-
-        let (debug_session, workspace) = this.update_in(cx, |this, window, cx| {
-            this.sessions.retain(|session| {
-                !session
-                    .read(cx)
-                    .running_state()
-                    .read(cx)
-                    .session()
-                    .read(cx)
-                    .is_terminated()
-            });
-
-            let debug_session = DebugSession::running(
-                this.project.clone(),
-                this.workspace.clone(),
-                session,
-                cx.weak_entity(),
-                serialized_layout,
-                this.position(window, cx).axis(),
-                window,
-                cx,
-            );
-
-            // We might want to make this an event subscription and only notify when a new thread is selected
-            // This is used to filter the command menu correctly
-            cx.observe(
-                &debug_session.read(cx).running_state().clone(),
-                |_, _, cx| cx.notify(),
-            )
-            .detach();
-
-            this.sessions.push(debug_session.clone());
-            this.activate_session(debug_session.clone(), window, cx);
+        let workspace = this.update_in(cx, |this, window, cx| {
+            if focus {
+                this.activate_session(debug_session.clone(), window, cx);
+            }
 
-            (debug_session, this.workspace.clone())
+            this.workspace.clone()
         })?;
-
         workspace.update_in(cx, |workspace, window, cx| {
             workspace.focus_panel::<Self>(window, cx);
         })?;
-
         Ok(debug_session)
     }
 
-    fn handle_restart_request(
+    pub(crate) fn handle_restart_request(
         &mut self,
         mut curr_session: Entity<Session>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        while let Some(parent_session) =
-            curr_session.read_with(cx, |session, _| session.parent_session().cloned())
-        {
+        while let Some(parent_session) = curr_session.read(cx).parent_session().cloned() {
             curr_session = parent_session;
         }
 
         let Some(worktree) = curr_session.read(cx).worktree() else {
-            log::error!("Attempted to start a child session from non local debug session");
+            log::error!("Attempted to restart a non-running session");
             return;
         };
 
         let dap_store_handle = self.project.read(cx).dap_store().clone();
         let label = curr_session.read(cx).label().clone();
         let adapter = curr_session.read(cx).adapter().clone();
-        let binary = curr_session.read(cx).binary().clone();
+        let binary = curr_session.read(cx).binary().cloned().unwrap();
         let task = curr_session.update(cx, |session, cx| session.shutdown(cx));
+        let task_context = curr_session.read(cx).task_context().clone();
 
         cx.spawn_in(window, async move |this, cx| {
             task.await;
 
             let (session, task) = dap_store_handle.update(cx, |dap_store, cx| {
-                let session = dap_store.new_session(label, adapter, None, cx);
+                let session = dap_store.new_session(label, adapter, task_context, None, cx);
 
                 let task = session.update(cx, |session, cx| {
                     session.boot(binary, worktree, dap_store_handle.downgrade(), cx)
                 });
                 (session, task)
             })?;
-            Self::register_session(this, session, cx).await?;
-            task.await
+            Self::register_session(this.clone(), session.clone(), true, cx).await?;
+
+            if let Err(error) = task.await {
+                session
+                    .update(cx, |session, cx| {
+                        session
+                            .console_output(cx)
+                            .unbounded_send(format!(
+                                "Session failed to restart with error: {}",
+                                error
+                            ))
+                            .ok();
+                        session.shutdown(cx)
+                    })?
+                    .await;
+
+                return Err(error);
+            };
+
+            Ok(())
         })
         .detach_and_log_err(cx);
     }
@@ -380,36 +394,50 @@ impl DebugPanel {
         cx: &mut Context<Self>,
     ) {
         let Some(worktree) = parent_session.read(cx).worktree() else {
-            log::error!("Attempted to start a child session from non local debug session");
+            log::error!("Attempted to start a child-session from a non-running session");
             return;
         };
 
         let dap_store_handle = self.project.read(cx).dap_store().clone();
-        let label = parent_session.read(cx).label().clone();
+        let label = self.label_for_child_session(&parent_session, request, cx);
         let adapter = parent_session.read(cx).adapter().clone();
-        let mut binary = parent_session.read(cx).binary().clone();
+        let Some(mut binary) = parent_session.read(cx).binary().cloned() else {
+            log::error!("Attempted to start a child-session without a binary");
+            return;
+        };
+        let task_context = parent_session.read(cx).task_context().clone();
         binary.request_args = request.clone();
-
         cx.spawn_in(window, async move |this, cx| {
             let (session, task) = dap_store_handle.update(cx, |dap_store, cx| {
-                let session =
-                    dap_store.new_session(label, adapter, Some(parent_session.clone()), cx);
+                let session = dap_store.new_session(
+                    label,
+                    adapter,
+                    task_context,
+                    Some(parent_session.clone()),
+                    cx,
+                );
 
                 let task = session.update(cx, |session, cx| {
                     session.boot(binary, worktree, dap_store_handle.downgrade(), cx)
                 });
                 (session, task)
             })?;
-            Self::register_session(this, session, cx).await?;
+            // Focus child sessions if the parent has never emitted a stopped event;
+            // this improves our JavaScript experience, as it always spawns a "main" session that then spawns subsessions.
+            let parent_ever_stopped =
+                parent_session.update(cx, |this, _| this.has_ever_stopped())?;
+            Self::register_session(this, session, !parent_ever_stopped, cx).await?;
             task.await
         })
         .detach_and_log_err(cx);
     }
 
-    pub fn active_session(&self) -> Option<Entity<DebugSession>> {
-        self.active_session.clone()
-    }
-    fn close_session(&mut self, entity_id: EntityId, window: &mut Window, cx: &mut Context<Self>) {
+    pub(crate) fn close_session(
+        &mut self,
+        entity_id: EntityId,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         let Some(session) = self
             .sessions
             .iter()
@@ -463,93 +491,8 @@ impl DebugPanel {
         })
         .detach();
     }
-    fn sessions_drop_down_menu(
-        &self,
-        active_session: &Entity<DebugSession>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> DropdownMenu {
-        let sessions = self.sessions.clone();
-        let weak = cx.weak_entity();
-        let label = active_session.read(cx).label_element(cx);
-
-        DropdownMenu::new_with_element(
-            "debugger-session-list",
-            label,
-            ContextMenu::build(window, cx, move |mut this, _, cx| {
-                let context_menu = cx.weak_entity();
-                for session in sessions.into_iter() {
-                    let weak_session = session.downgrade();
-                    let weak_session_id = weak_session.entity_id();
-
-                    this = this.custom_entry(
-                        {
-                            let weak = weak.clone();
-                            let context_menu = context_menu.clone();
-                            move |_, cx| {
-                                weak_session
-                                    .read_with(cx, |session, cx| {
-                                        let context_menu = context_menu.clone();
-                                        let id: SharedString =
-                                            format!("debug-session-{}", session.session_id(cx).0)
-                                                .into();
-                                        h_flex()
-                                            .w_full()
-                                            .group(id.clone())
-                                            .justify_between()
-                                            .child(session.label_element(cx))
-                                            .child(
-                                                IconButton::new(
-                                                    "close-debug-session",
-                                                    IconName::Close,
-                                                )
-                                                .visible_on_hover(id.clone())
-                                                .icon_size(IconSize::Small)
-                                                .on_click({
-                                                    let weak = weak.clone();
-                                                    move |_, window, cx| {
-                                                        weak.update(cx, |panel, cx| {
-                                                            panel.close_session(
-                                                                weak_session_id,
-                                                                window,
-                                                                cx,
-                                                            );
-                                                        })
-                                                        .ok();
-                                                        context_menu
-                                                            .update(cx, |this, cx| {
-                                                                this.cancel(
-                                                                    &Default::default(),
-                                                                    window,
-                                                                    cx,
-                                                                );
-                                                            })
-                                                            .ok();
-                                                    }
-                                                }),
-                                            )
-                                            .into_any_element()
-                                    })
-                                    .unwrap_or_else(|_| div().into_any_element())
-                            }
-                        },
-                        {
-                            let weak = weak.clone();
-                            move |window, cx| {
-                                weak.update(cx, |panel, cx| {
-                                    panel.activate_session(session.clone(), window, cx);
-                                })
-                                .ok();
-                            }
-                        },
-                    );
-                }
-                this
-            }),
-        )
-    }
 
-    fn deploy_context_menu(
+    pub(crate) fn deploy_context_menu(
         &mut self,
         position: Point<Pixels>,
         window: &mut Window,
@@ -600,7 +543,31 @@ impl DebugPanel {
         }
     }
 
-    fn top_controls_strip(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<Div> {
+    fn copy_debug_adapter_arguments(
+        &mut self,
+        _: &CopyDebugAdapterArguments,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let content = maybe!({
+            let mut session = self.active_session()?.read(cx).session(cx);
+            while let Some(parent) = session.read(cx).parent_session().cloned() {
+                session = parent;
+            }
+            let binary = session.read(cx).binary()?;
+            let content = serde_json::to_string_pretty(&binary).ok()?;
+            Some(content)
+        });
+        if let Some(content) = content {
+            cx.write_to_clipboard(ClipboardItem::new_string(content));
+        }
+    }
+
+    pub(crate) fn top_controls_strip(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<Div> {
         let active_session = self.active_session.clone();
         let focus_handle = self.focus_handle.clone();
         let is_side = self.position(window, cx).axis() == gpui::Axis::Horizontal;
@@ -625,6 +592,12 @@ impl DebugPanel {
                     }
                 })
         };
+        let documentation_button = || {
+            IconButton::new("debug-open-documentation", IconName::CircleHelp)
+                .icon_size(IconSize::Small)
+                .on_click(move |_, _, cx| cx.open_url("https://zed.dev/docs/debugger"))
+                .tooltip(Tooltip::text("Open Documentation"))
+        };
 
         Some(
             div.border_b_1()
@@ -640,12 +613,14 @@ impl DebugPanel {
                                 active_session
                                     .as_ref()
                                     .map(|session| session.read(cx).running_state()),
-                                |this, running_session| {
+                                |this, running_state| {
                                     let thread_status =
-                                        running_session.read(cx).thread_status(cx).unwrap_or(
+                                        running_state.read(cx).thread_status(cx).unwrap_or(
                                             project::debugger::session::ThreadStatus::Exited,
                                         );
-                                    let capabilities = running_session.read(cx).capabilities(cx);
+                                    let capabilities = running_state.read(cx).capabilities(cx);
+                                    let supports_detach =
+                                        running_state.read(cx).session().read(cx).is_attached();
                                     this.map(|this| {
                                         if thread_status == ThreadStatus::Running {
                                             this.child(
@@ -656,7 +631,7 @@ impl DebugPanel {
                                                 .icon_size(IconSize::XSmall)
                                                 .shape(ui::IconButtonShape::Square)
                                                 .on_click(window.listener_for(
-                                                    &running_session,
+                                                    &running_state,
                                                     |this, _, _window, cx| {
                                                         this.pause_thread(cx);
                                                     },
@@ -683,7 +658,7 @@ impl DebugPanel {
                                                 .icon_size(IconSize::XSmall)
                                                 .shape(ui::IconButtonShape::Square)
                                                 .on_click(window.listener_for(
-                                                    &running_session,
+                                                    &running_state,
                                                     |this, _, _window, cx| this.continue_thread(cx),
                                                 ))
                                                 .disabled(thread_status != ThreadStatus::Stopped)
@@ -707,7 +682,7 @@ impl DebugPanel {
                                             .icon_size(IconSize::XSmall)
                                             .shape(ui::IconButtonShape::Square)
                                             .on_click(window.listener_for(
-                                                &running_session,
+                                                &running_state,
                                                 |this, _, _window, cx| {
                                                     this.step_over(cx);
                                                 },
@@ -726,30 +701,6 @@ impl DebugPanel {
                                                 }
                                             }),
                                     )
-                                    .child(
-                                        IconButton::new("debug-step-out", IconName::ArrowUpRight)
-                                            .icon_size(IconSize::XSmall)
-                                            .shape(ui::IconButtonShape::Square)
-                                            .on_click(window.listener_for(
-                                                &running_session,
-                                                |this, _, _window, cx| {
-                                                    this.step_out(cx);
-                                                },
-                                            ))
-                                            .disabled(thread_status != ThreadStatus::Stopped)
-                                            .tooltip({
-                                                let focus_handle = focus_handle.clone();
-                                                move |window, cx| {
-                                                    Tooltip::for_action_in(
-                                                        "Step out",
-                                                        &StepOut,
-                                                        &focus_handle,
-                                                        window,
-                                                        cx,
-                                                    )
-                                                }
-                                            }),
-                                    )
                                     .child(
                                         IconButton::new(
                                             "debug-step-into",
@@ -758,7 +709,7 @@ impl DebugPanel {
                                         .icon_size(IconSize::XSmall)
                                         .shape(ui::IconButtonShape::Square)
                                         .on_click(window.listener_for(
-                                            &running_session,
+                                            &running_state,
                                             |this, _, _window, cx| {
                                                 this.step_in(cx);
                                             },
@@ -777,61 +728,36 @@ impl DebugPanel {
                                             }
                                         }),
                                     )
-                                    .child(Divider::vertical())
-                                    .child(
-                                        IconButton::new(
-                                            "debug-enable-breakpoint",
-                                            IconName::DebugDisabledBreakpoint,
-                                        )
-                                        .icon_size(IconSize::XSmall)
-                                        .shape(ui::IconButtonShape::Square)
-                                        .disabled(thread_status != ThreadStatus::Stopped),
-                                    )
-                                    .child(
-                                        IconButton::new(
-                                            "debug-disable-breakpoint",
-                                            IconName::CircleOff,
-                                        )
-                                        .icon_size(IconSize::XSmall)
-                                        .shape(ui::IconButtonShape::Square)
-                                        .disabled(thread_status != ThreadStatus::Stopped),
-                                    )
                                     .child(
-                                        IconButton::new(
-                                            "debug-disable-all-breakpoints",
-                                            IconName::BugOff,
-                                        )
-                                        .icon_size(IconSize::XSmall)
-                                        .shape(ui::IconButtonShape::Square)
-                                        .disabled(
-                                            thread_status == ThreadStatus::Exited
-                                                || thread_status == ThreadStatus::Ended,
-                                        )
-                                        .on_click(window.listener_for(
-                                            &running_session,
-                                            |this, _, _window, cx| {
-                                                this.toggle_ignore_breakpoints(cx);
-                                            },
-                                        ))
-                                        .tooltip({
-                                            let focus_handle = focus_handle.clone();
-                                            move |window, cx| {
-                                                Tooltip::for_action_in(
-                                                    "Disable all breakpoints",
-                                                    &ToggleIgnoreBreakpoints,
-                                                    &focus_handle,
-                                                    window,
-                                                    cx,
-                                                )
-                                            }
-                                        }),
+                                        IconButton::new("debug-step-out", IconName::ArrowUpRight)
+                                            .icon_size(IconSize::XSmall)
+                                            .shape(ui::IconButtonShape::Square)
+                                            .on_click(window.listener_for(
+                                                &running_state,
+                                                |this, _, _window, cx| {
+                                                    this.step_out(cx);
+                                                },
+                                            ))
+                                            .disabled(thread_status != ThreadStatus::Stopped)
+                                            .tooltip({
+                                                let focus_handle = focus_handle.clone();
+                                                move |window, cx| {
+                                                    Tooltip::for_action_in(
+                                                        "Step out",
+                                                        &StepOut,
+                                                        &focus_handle,
+                                                        window,
+                                                        cx,
+                                                    )
+                                                }
+                                            }),
                                     )
                                     .child(Divider::vertical())
                                     .child(
                                         IconButton::new("debug-restart", IconName::DebugRestart)
                                             .icon_size(IconSize::XSmall)
                                             .on_click(window.listener_for(
-                                                &running_session,
+                                                &running_state,
                                                 |this, _, _window, cx| {
                                                     this.restart_session(cx);
                                                 },
@@ -853,7 +779,7 @@ impl DebugPanel {
                                         IconButton::new("debug-stop", IconName::Power)
                                             .icon_size(IconSize::XSmall)
                                             .on_click(window.listener_for(
-                                                &running_session,
+                                                &running_state,
                                                 |this, _, _window, cx| {
                                                     this.stop_thread(cx);
                                                 },
@@ -883,33 +809,48 @@ impl DebugPanel {
                                                 }
                                             }),
                                     )
-                                    .child(
-                                        IconButton::new("debug-disconnect", IconName::DebugDetach)
-                                            .icon_size(IconSize::XSmall)
-                                            .on_click(window.listener_for(
-                                                &running_session,
-                                                |this, _, _, cx| {
-                                                    this.detach_client(cx);
-                                                },
-                                            ))
-                                            .tooltip({
-                                                let focus_handle = focus_handle.clone();
-                                                move |window, cx| {
-                                                    Tooltip::for_action_in(
-                                                        "Detach",
-                                                        &Detach,
-                                                        &focus_handle,
-                                                        window,
-                                                        cx,
-                                                    )
-                                                }
-                                            }),
+                                    .when(
+                                        supports_detach,
+                                        |div| {
+                                            div.child(
+                                                IconButton::new(
+                                                    "debug-disconnect",
+                                                    IconName::DebugDetach,
+                                                )
+                                                .disabled(
+                                                    thread_status != ThreadStatus::Stopped
+                                                        && thread_status != ThreadStatus::Running,
+                                                )
+                                                .icon_size(IconSize::XSmall)
+                                                .on_click(window.listener_for(
+                                                    &running_state,
+                                                    |this, _, _, cx| {
+                                                        this.detach_client(cx);
+                                                    },
+                                                ))
+                                                .tooltip({
+                                                    let focus_handle = focus_handle.clone();
+                                                    move |window, cx| {
+                                                        Tooltip::for_action_in(
+                                                            "Detach",
+                                                            &Detach,
+                                                            &focus_handle,
+                                                            window,
+                                                            cx,
+                                                        )
+                                                    }
+                                                }),
+                                            )
+                                        },
                                     )
                                 },
                             ),
                         )
                         .justify_around()
-                        .when(is_side, |this| this.child(new_session_button())),
+                        .when(is_side, |this| {
+                            this.child(new_session_button())
+                                .child(documentation_button())
+                        }),
                 )
                 .child(
                     h_flex()
@@ -921,30 +862,50 @@ impl DebugPanel {
                                     .as_ref()
                                     .map(|session| session.read(cx).running_state())
                                     .cloned(),
-                                |this, session| {
-                                    this.child(
-                                        session.update(cx, |this, cx| {
-                                            this.thread_dropdown(window, cx)
-                                        }),
-                                    )
+                                |this, running_state| {
+                                    this.children({
+                                        let running_state = running_state.clone();
+                                        let threads =
+                                            running_state.update(cx, |running_state, cx| {
+                                                let session = running_state.session();
+                                                session.read(cx).is_running().then(|| {
+                                                    session.update(cx, |session, cx| {
+                                                        session.threads(cx)
+                                                    })
+                                                })
+                                            });
+
+                                        threads.and_then(|threads| {
+                                            self.render_thread_dropdown(
+                                                &running_state,
+                                                threads,
+                                                window,
+                                                cx,
+                                            )
+                                        })
+                                    })
                                     .when(!is_side, |this| this.gap_2().child(Divider::vertical()))
                                 },
                             ),
                         )
                         .child(
                             h_flex()
-                                .when_some(active_session.as_ref(), |this, session| {
-                                    let context_menu =
-                                        self.sessions_drop_down_menu(session, window, cx);
-                                    this.child(context_menu).gap_2().child(Divider::vertical())
-                                })
-                                .when(!is_side, |this| this.child(new_session_button())),
+                                .children(self.render_session_menu(
+                                    self.active_session(),
+                                    self.running_state(cx),
+                                    window,
+                                    cx,
+                                ))
+                                .when(!is_side, |this| {
+                                    this.child(new_session_button())
+                                        .child(documentation_button())
+                                }),
                         ),
                 ),
         )
     }
 
-    fn activate_pane_in_direction(
+    pub(crate) fn activate_pane_in_direction(
         &mut self,
         direction: SplitDirection,
         window: &mut Window,

crates/debugger_ui/src/debugger_ui.rs 🔗

@@ -1,20 +1,30 @@
+use std::any::TypeId;
+
 use dap::debugger_settings::DebuggerSettings;
-use debugger_panel::{DebugPanel, ToggleFocus};
+use debugger_panel::DebugPanel;
 use editor::Editor;
-use feature_flags::{DebuggerFeatureFlag, FeatureFlagViewExt};
-use gpui::{App, EntityInputHandler, actions};
-use new_session_modal::NewSessionModal;
-use project::debugger::{self, breakpoint_store::SourceBreakpoint};
+use gpui::{App, DispatchPhase, EntityInputHandler, actions};
+use new_process_modal::{NewProcessModal, NewProcessMode};
+use onboarding_modal::DebuggerOnboardingModal;
+use project::debugger::{self, breakpoint_store::SourceBreakpoint, session::ThreadStatus};
 use session::DebugSession;
 use settings::Settings;
+use stack_trace_view::StackTraceView;
+use tasks_ui::{Spawn, TaskOverrides};
+use ui::{FluentBuilder, InteractiveElement};
 use util::maybe;
-use workspace::{ShutdownDebugAdapters, Workspace};
+use workspace::{ItemHandle, ShutdownDebugAdapters, Workspace};
+use zed_actions::ToggleFocus;
+use zed_actions::debugger::OpenOnboardingModal;
 
 pub mod attach_modal;
 pub mod debugger_panel;
-mod new_session_modal;
+mod dropdown_menus;
+mod new_process_modal;
+mod onboarding_modal;
 mod persistence;
 pub(crate) mod session;
+mod stack_trace_view;
 
 #[cfg(any(test, feature = "test-support"))]
 pub mod tests;
@@ -41,199 +51,332 @@ actions!(
         FocusModules,
         FocusLoadedSources,
         FocusTerminal,
+        ShowStackTrace,
+        ToggleThreadPicker,
+        ToggleSessionPicker,
+        RerunLastSession,
+        ToggleExpandItem,
     ]
 );
 
+actions!(dev, [CopyDebugAdapterArguments]);
+
 pub fn init(cx: &mut App) {
     DebuggerSettings::register(cx);
     workspace::FollowableViewRegistry::register::<DebugSession>(cx);
 
-    cx.observe_new(|_: &mut Workspace, window, cx| {
-        let Some(window) = window else {
-            return;
-        };
+    cx.observe_new(|workspace: &mut Workspace, _, _| {
+        workspace
+            .register_action(spawn_task_or_modal)
+            .register_action(|workspace, _: &ToggleFocus, window, cx| {
+                workspace.toggle_panel_focus::<DebugPanel>(window, cx);
+            })
+            .register_action(|workspace: &mut Workspace, _: &Start, window, cx| {
+                NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx);
+            })
+            .register_action(
+                |workspace: &mut Workspace, _: &RerunLastSession, window, cx| {
+                    let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) else {
+                        return;
+                    };
+
+                    debug_panel.update(cx, |debug_panel, cx| {
+                        debug_panel.rerun_last_session(workspace, window, cx);
+                    })
+                },
+            )
+            .register_action(
+                |workspace: &mut Workspace, _: &ShutdownDebugAdapters, _window, cx| {
+                    workspace.project().update(cx, |project, cx| {
+                        project.dap_store().update(cx, |store, cx| {
+                            store.shutdown_sessions(cx).detach();
+                        })
+                    })
+                },
+            )
+            .register_action(|workspace, _: &OpenOnboardingModal, window, cx| {
+                DebuggerOnboardingModal::toggle(workspace, window, cx)
+            })
+            .register_action_renderer(|div, workspace, _, cx| {
+                let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) else {
+                    return div;
+                };
+                let Some(active_item) = debug_panel
+                    .read(cx)
+                    .active_session()
+                    .map(|session| session.read(cx).running_state().clone())
+                else {
+                    return div;
+                };
+                let running_state = active_item.read(cx);
+                if running_state.session().read(cx).is_terminated() {
+                    return div;
+                }
+
+                let caps = running_state.capabilities(cx);
+                let supports_step_back = caps.supports_step_back.unwrap_or_default();
+                let supports_detach = running_state.session().read(cx).is_attached();
+                let status = running_state.thread_status(cx);
 
-        cx.when_flag_enabled::<DebuggerFeatureFlag>(window, |workspace, _, _| {
-            workspace
-                .register_action(|workspace, _: &ToggleFocus, window, cx| {
-                    workspace.toggle_panel_focus::<DebugPanel>(window, cx);
+                let active_item = active_item.downgrade();
+                div.when(status == Some(ThreadStatus::Running), |div| {
+                    let active_item = active_item.clone();
+                    div.on_action(move |_: &Pause, _, cx| {
+                        active_item
+                            .update(cx, |item, cx| item.pause_thread(cx))
+                            .ok();
+                    })
                 })
-                .register_action(|workspace, _: &Pause, _, cx| {
-                    if let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) {
-                        if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| {
-                            panel
-                                .active_session()
-                                .map(|session| session.read(cx).running_state().clone())
-                        }) {
-                            active_item.update(cx, |item, cx| item.pause_thread(cx))
+                .when(status == Some(ThreadStatus::Stopped), |div| {
+                    div.on_action({
+                        let active_item = active_item.clone();
+                        move |_: &StepInto, _, cx| {
+                            active_item.update(cx, |item, cx| item.step_in(cx)).ok();
                         }
-                    }
-                })
-                .register_action(|workspace, _: &Restart, _, cx| {
-                    if let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) {
-                        if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| {
-                            panel
-                                .active_session()
-                                .map(|session| session.read(cx).running_state().clone())
-                        }) {
-                            active_item.update(cx, |item, cx| item.restart_session(cx))
+                    })
+                    .on_action({
+                        let active_item = active_item.clone();
+                        move |_: &StepOver, _, cx| {
+                            active_item.update(cx, |item, cx| item.step_over(cx)).ok();
                         }
-                    }
-                })
-                .register_action(|workspace, _: &StepInto, _, cx| {
-                    if let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) {
-                        if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| {
-                            panel
-                                .active_session()
-                                .map(|session| session.read(cx).running_state().clone())
-                        }) {
-                            active_item.update(cx, |item, cx| item.step_in(cx))
+                    })
+                    .on_action({
+                        let active_item = active_item.clone();
+                        move |_: &StepOut, _, cx| {
+                            active_item.update(cx, |item, cx| item.step_out(cx)).ok();
                         }
-                    }
-                })
-                .register_action(|workspace, _: &StepOver, _, cx| {
-                    if let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) {
-                        if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| {
-                            panel
-                                .active_session()
-                                .map(|session| session.read(cx).running_state().clone())
-                        }) {
-                            active_item.update(cx, |item, cx| item.step_over(cx))
+                    })
+                    .when(supports_step_back, |div| {
+                        let active_item = active_item.clone();
+                        div.on_action(move |_: &StepBack, _, cx| {
+                            active_item.update(cx, |item, cx| item.step_back(cx)).ok();
+                        })
+                    })
+                    .on_action({
+                        let active_item = active_item.clone();
+                        move |_: &Continue, _, cx| {
+                            active_item
+                                .update(cx, |item, cx| item.continue_thread(cx))
+                                .ok();
                         }
-                    }
+                    })
+                    .on_action(cx.listener(
+                        |workspace, _: &ShowStackTrace, window, cx| {
+                            let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) else {
+                                return;
+                            };
+
+                            if let Some(existing) = workspace.item_of_type::<StackTraceView>(cx) {
+                                let is_active = workspace
+                                    .active_item(cx)
+                                    .is_some_and(|item| item.item_id() == existing.item_id());
+                                workspace.activate_item(&existing, true, !is_active, window, cx);
+                            } else {
+                                let Some(active_session) = debug_panel.read(cx).active_session()
+                                else {
+                                    return;
+                                };
+
+                                let project = workspace.project();
+
+                                let stack_trace_view = active_session.update(cx, |session, cx| {
+                                    session.stack_trace_view(project, window, cx).clone()
+                                });
+
+                                workspace.add_item_to_active_pane(
+                                    Box::new(stack_trace_view),
+                                    None,
+                                    true,
+                                    window,
+                                    cx,
+                                );
+                            }
+                        },
+                    ))
                 })
-                .register_action(|workspace, _: &StepBack, _, cx| {
-                    if let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) {
-                        if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| {
-                            panel
-                                .active_session()
-                                .map(|session| session.read(cx).running_state().clone())
-                        }) {
-                            active_item.update(cx, |item, cx| item.step_back(cx))
-                        }
+                .when(supports_detach, |div| {
+                    let active_item = active_item.clone();
+                    div.on_action(move |_: &Detach, _, cx| {
+                        active_item
+                            .update(cx, |item, cx| item.detach_client(cx))
+                            .ok();
+                    })
+                })
+                .on_action({
+                    let active_item = active_item.clone();
+                    move |_: &Restart, _, cx| {
+                        active_item
+                            .update(cx, |item, cx| item.restart_session(cx))
+                            .ok();
                     }
                 })
-                .register_action(|workspace, _: &Stop, _, cx| {
-                    if let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) {
-                        if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| {
-                            panel
-                                .active_session()
-                                .map(|session| session.read(cx).running_state().clone())
-                        }) {
-                            cx.defer(move |cx| {
-                                active_item.update(cx, |item, cx| item.stop_thread(cx))
-                            })
-                        }
+                .on_action({
+                    let active_item = active_item.clone();
+                    move |_: &Stop, _, cx| {
+                        active_item.update(cx, |item, cx| item.stop_thread(cx)).ok();
                     }
                 })
-                .register_action(|workspace, _: &ToggleIgnoreBreakpoints, _, cx| {
-                    if let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) {
-                        if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| {
-                            panel
-                                .active_session()
-                                .map(|session| session.read(cx).running_state().clone())
-                        }) {
-                            active_item.update(cx, |item, cx| item.toggle_ignore_breakpoints(cx))
-                        }
+                .on_action({
+                    let active_item = active_item.clone();
+                    move |_: &ToggleIgnoreBreakpoints, _, cx| {
+                        active_item
+                            .update(cx, |item, cx| item.toggle_ignore_breakpoints(cx))
+                            .ok();
                     }
                 })
-                .register_action(
-                    |workspace: &mut Workspace, _: &ShutdownDebugAdapters, _window, cx| {
-                        workspace.project().update(cx, |project, cx| {
-                            project.dap_store().update(cx, |store, cx| {
-                                store.shutdown_sessions(cx).detach();
-                            })
-                        })
-                    },
-                )
-                .register_action(|workspace: &mut Workspace, _: &Start, window, cx| {
-                    NewSessionModal::show(workspace, window, cx);
-                });
-        })
+            });
     })
     .detach();
 
     cx.observe_new({
-        move |editor: &mut Editor, _, cx| {
+        move |editor: &mut Editor, _, _| {
             editor
-                .register_action(cx.listener(
-                    move |editor, _: &editor::actions::DebuggerRunToCursor, _, cx| {
-                        maybe!({
-                            let debug_panel =
-                                editor.workspace()?.read(cx).panel::<DebugPanel>(cx)?;
-                            let cursor_point: language::Point = editor.selections.newest(cx).head();
-                            let active_session = debug_panel.read(cx).active_session()?;
-
-                            let (buffer, position, _) = editor
-                                .buffer()
-                                .read(cx)
-                                .point_to_buffer_point(cursor_point, cx)?;
-
-                            let path =
+                .register_action_renderer(move |editor, window, cx| {
+                    let Some(workspace) = editor.workspace() else {
+                        return;
+                    };
+                    let Some(debug_panel) = workspace.read(cx).panel::<DebugPanel>(cx) else {
+                        return;
+                    };
+                    let Some(active_session) = debug_panel
+                        .clone()
+                        .update(cx, |panel, _| panel.active_session())
+                    else {
+                        return;
+                    };
+                    let editor = cx.entity().downgrade();
+                    window.on_action(TypeId::of::<editor::actions::RunToCursor>(), {
+                        let editor = editor.clone();
+                        let active_session = active_session.clone();
+                        move |_, phase, _, cx| {
+                            if phase != DispatchPhase::Bubble {
+                                return;
+                            }
+                            maybe!({
+                                let (buffer, position, _) = editor
+                                    .update(cx, |editor, cx| {
+                                        let cursor_point: language::Point =
+                                            editor.selections.newest(cx).head();
+
+                                        editor
+                                            .buffer()
+                                            .read(cx)
+                                            .point_to_buffer_point(cursor_point, cx)
+                                    })
+                                    .ok()??;
+
+                                let path =
                                 debugger::breakpoint_store::BreakpointStore::abs_path_from_buffer(
                                     &buffer, cx,
                                 )?;
 
-                            let source_breakpoint = SourceBreakpoint {
-                                row: position.row,
-                                path,
-                                message: None,
-                                condition: None,
-                                hit_condition: None,
-                                state: debugger::breakpoint_store::BreakpointState::Enabled,
-                            };
+                                let source_breakpoint = SourceBreakpoint {
+                                    row: position.row,
+                                    path,
+                                    message: None,
+                                    condition: None,
+                                    hit_condition: None,
+                                    state: debugger::breakpoint_store::BreakpointState::Enabled,
+                                };
 
-                            active_session.update(cx, |session, cx| {
-                                session.running_state().update(cx, |state, cx| {
-                                    if let Some(thread_id) = state.selected_thread_id() {
-                                        state.session().update(cx, |session, cx| {
-                                            session.run_to_position(
-                                                source_breakpoint,
-                                                thread_id,
-                                                cx,
-                                            );
-                                        })
-                                    }
+                                active_session.update(cx, |session, cx| {
+                                    session.running_state().update(cx, |state, cx| {
+                                        if let Some(thread_id) = state.selected_thread_id() {
+                                            state.session().update(cx, |session, cx| {
+                                                session.run_to_position(
+                                                    source_breakpoint,
+                                                    thread_id,
+                                                    cx,
+                                                );
+                                            })
+                                        }
+                                    });
                                 });
+
+                                Some(())
                             });
+                        }
+                    });
 
-                            Some(())
-                        });
-                    },
-                ))
-                .detach();
+                    window.on_action(
+                        TypeId::of::<editor::actions::EvaluateSelectedText>(),
+                        move |_, _, window, cx| {
+                            maybe!({
+                                let text = editor
+                                    .update(cx, |editor, cx| {
+                                        editor.text_for_range(
+                                            editor.selections.newest(cx).range(),
+                                            &mut None,
+                                            window,
+                                            cx,
+                                        )
+                                    })
+                                    .ok()??;
 
-            editor
-                .register_action(cx.listener(
-                    move |editor, _: &editor::actions::DebuggerEvaluateSelectedText, window, cx| {
-                        maybe!({
-                            let debug_panel =
-                                editor.workspace()?.read(cx).panel::<DebugPanel>(cx)?;
-                            let active_session = debug_panel.read(cx).active_session()?;
-
-                            let text = editor.text_for_range(
-                                editor.selections.newest(cx).range(),
-                                &mut None,
-                                window,
-                                cx,
-                            )?;
-
-                            active_session.update(cx, |session, cx| {
-                                session.running_state().update(cx, |state, cx| {
-                                    let stack_id = state.selected_stack_frame_id(cx);
-
-                                    state.session().update(cx, |session, cx| {
-                                        session.evaluate(text, None, stack_id, None, cx).detach();
+                                active_session.update(cx, |session, cx| {
+                                    session.running_state().update(cx, |state, cx| {
+                                        let stack_id = state.selected_stack_frame_id(cx);
+
+                                        state.session().update(cx, |session, cx| {
+                                            session
+                                                .evaluate(text, None, stack_id, None, cx)
+                                                .detach();
+                                        });
                                     });
                                 });
-                            });
 
-                            Some(())
-                        });
-                    },
-                ))
+                                Some(())
+                            });
+                        },
+                    );
+                })
                 .detach();
         }
     })
     .detach();
 }
+
+fn spawn_task_or_modal(
+    workspace: &mut Workspace,
+    action: &Spawn,
+    window: &mut ui::Window,
+    cx: &mut ui::Context<Workspace>,
+) {
+    match action {
+        Spawn::ByName {
+            task_name,
+            reveal_target,
+        } => {
+            let overrides = reveal_target.map(|reveal_target| TaskOverrides {
+                reveal_target: Some(reveal_target),
+            });
+            let name = task_name.clone();
+            tasks_ui::spawn_tasks_filtered(
+                move |(_, task)| task.label.eq(&name),
+                overrides,
+                window,
+                cx,
+            )
+            .detach_and_log_err(cx)
+        }
+        Spawn::ByTag {
+            task_tag,
+            reveal_target,
+        } => {
+            let overrides = reveal_target.map(|reveal_target| TaskOverrides {
+                reveal_target: Some(reveal_target),
+            });
+            let tag = task_tag.clone();
+            tasks_ui::spawn_tasks_filtered(
+                move |(_, task)| task.tags.contains(&tag),
+                overrides,
+                window,
+                cx,
+            )
+            .detach_and_log_err(cx)
+        }
+        Spawn::ViaModal { reveal_target } => {
+            NewProcessModal::show(workspace, window, NewProcessMode::Task, *reveal_target, cx);
+        }
+    }
+}

crates/debugger_ui/src/dropdown_menus.rs 🔗

@@ -0,0 +1,223 @@
+use std::time::Duration;
+
+use collections::HashMap;
+use gpui::{Animation, AnimationExt as _, Entity, Transformation, percentage};
+use project::debugger::session::{ThreadId, ThreadStatus};
+use ui::{ContextMenu, DropdownMenu, DropdownStyle, Indicator, prelude::*};
+
+use crate::{
+    debugger_panel::DebugPanel,
+    session::{DebugSession, running::RunningState},
+};
+
+impl DebugPanel {
+    fn dropdown_label(label: impl Into<SharedString>) -> Label {
+        Label::new(label).size(LabelSize::Small)
+    }
+
+    pub fn render_session_menu(
+        &mut self,
+        active_session: Option<Entity<DebugSession>>,
+        running_state: Option<Entity<RunningState>>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<impl IntoElement> {
+        if let Some(running_state) = running_state {
+            let sessions = self.sessions().clone();
+            let weak = cx.weak_entity();
+            let running_state = running_state.read(cx);
+            let label = if let Some(active_session) = active_session.clone() {
+                active_session.read(cx).session(cx).read(cx).label()
+            } else {
+                SharedString::new_static("Unknown Session")
+            };
+
+            let is_terminated = running_state.session().read(cx).is_terminated();
+            let is_started = active_session
+                .is_some_and(|session| session.read(cx).session(cx).read(cx).is_started());
+
+            let session_state_indicator = if is_terminated {
+                Indicator::dot().color(Color::Error).into_any_element()
+            } else if !is_started {
+                Icon::new(IconName::ArrowCircle)
+                    .size(IconSize::Small)
+                    .color(Color::Muted)
+                    .with_animation(
+                        "arrow-circle",
+                        Animation::new(Duration::from_secs(2)).repeat(),
+                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
+                    )
+                    .into_any_element()
+            } else {
+                match running_state.thread_status(cx).unwrap_or_default() {
+                    ThreadStatus::Stopped => {
+                        Indicator::dot().color(Color::Conflict).into_any_element()
+                    }
+                    _ => Indicator::dot().color(Color::Success).into_any_element(),
+                }
+            };
+
+            let trigger = h_flex()
+                .gap_2()
+                .child(session_state_indicator)
+                .justify_between()
+                .child(
+                    DebugPanel::dropdown_label(label)
+                        .when(is_terminated, |this| this.strikethrough()),
+                )
+                .into_any_element();
+
+            Some(
+                DropdownMenu::new_with_element(
+                    "debugger-session-list",
+                    trigger,
+                    ContextMenu::build(window, cx, move |mut this, _, cx| {
+                        let context_menu = cx.weak_entity();
+                        let mut session_depths = HashMap::default();
+                        for session in sessions.into_iter() {
+                            let weak_session = session.downgrade();
+                            let weak_session_id = weak_session.entity_id();
+                            let session_id = session.read(cx).session_id(cx);
+                            let parent_depth = session
+                                .read(cx)
+                                .session(cx)
+                                .read(cx)
+                                .parent_id(cx)
+                                .and_then(|parent_id| session_depths.get(&parent_id).cloned());
+                            let self_depth =
+                                *session_depths.entry(session_id).or_insert_with(|| {
+                                    parent_depth.map(|depth| depth + 1).unwrap_or(0usize)
+                                });
+                            this = this.custom_entry(
+                                {
+                                    let weak = weak.clone();
+                                    let context_menu = context_menu.clone();
+                                    move |_, cx| {
+                                        weak_session
+                                            .read_with(cx, |session, cx| {
+                                                let context_menu = context_menu.clone();
+
+                                                let id: SharedString =
+                                                    format!("debug-session-{}", session_id.0)
+                                                        .into();
+
+                                                h_flex()
+                                                    .w_full()
+                                                    .group(id.clone())
+                                                    .justify_between()
+                                                    .child(session.label_element(self_depth, cx))
+                                                    .child(
+                                                        IconButton::new(
+                                                            "close-debug-session",
+                                                            IconName::Close,
+                                                        )
+                                                        .visible_on_hover(id.clone())
+                                                        .icon_size(IconSize::Small)
+                                                        .on_click({
+                                                            let weak = weak.clone();
+                                                            move |_, window, cx| {
+                                                                weak.update(cx, |panel, cx| {
+                                                                    panel.close_session(
+                                                                        weak_session_id,
+                                                                        window,
+                                                                        cx,
+                                                                    );
+                                                                })
+                                                                .ok();
+                                                                context_menu
+                                                                    .update(cx, |this, cx| {
+                                                                        this.cancel(
+                                                                            &Default::default(),
+                                                                            window,
+                                                                            cx,
+                                                                        );
+                                                                    })
+                                                                    .ok();
+                                                            }
+                                                        }),
+                                                    )
+                                                    .into_any_element()
+                                            })
+                                            .unwrap_or_else(|_| div().into_any_element())
+                                    }
+                                },
+                                {
+                                    let weak = weak.clone();
+                                    move |window, cx| {
+                                        weak.update(cx, |panel, cx| {
+                                            panel.activate_session(session.clone(), window, cx);
+                                        })
+                                        .ok();
+                                    }
+                                },
+                            );
+                        }
+                        this
+                    }),
+                )
+                .style(DropdownStyle::Ghost)
+                .handle(self.session_picker_menu_handle.clone()),
+            )
+        } else {
+            None
+        }
+    }
+
+    pub(crate) fn render_thread_dropdown(
+        &self,
+        running_state: &Entity<RunningState>,
+        threads: Vec<(dap::Thread, ThreadStatus)>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<DropdownMenu> {
+        let running_state = running_state.clone();
+        let running_state_read = running_state.read(cx);
+        let thread_id = running_state_read.thread_id();
+        let session = running_state_read.session();
+        let session_id = session.read(cx).session_id();
+        let session_terminated = session.read(cx).is_terminated();
+        let selected_thread_name = threads
+            .iter()
+            .find(|(thread, _)| thread_id.map(|id| id.0) == Some(thread.id))
+            .map(|(thread, _)| {
+                thread
+                    .name
+                    .is_empty()
+                    .then(|| format!("Tid: {}", thread.id))
+                    .unwrap_or_else(|| thread.name.clone())
+            });
+
+        if let Some(selected_thread_name) = selected_thread_name {
+            let trigger = DebugPanel::dropdown_label(selected_thread_name).into_any_element();
+            Some(
+                DropdownMenu::new_with_element(
+                    ("thread-list", session_id.0),
+                    trigger,
+                    ContextMenu::build(window, cx, move |mut this, _, _| {
+                        for (thread, _) in threads {
+                            let running_state = running_state.clone();
+                            let thread_id = thread.id;
+                            let entry_name = thread
+                                .name
+                                .is_empty()
+                                .then(|| format!("Tid: {}", thread.id))
+                                .unwrap_or_else(|| thread.name);
+
+                            this = this.entry(entry_name, None, move |window, cx| {
+                                running_state.update(cx, |running_state, cx| {
+                                    running_state.select_thread(ThreadId(thread_id), window, cx);
+                                });
+                            });
+                        }
+                        this
+                    }),
+                )
+                .disabled(session_terminated)
+                .style(DropdownStyle::Ghost)
+                .handle(self.thread_picker_menu_handle.clone()),
+            )
+        } else {
+            None
+        }
+    }
+}

crates/debugger_ui/src/new_process_modal.rs 🔗

@@ -0,0 +1,1583 @@
+use anyhow::bail;
+use collections::{FxHashMap, HashMap};
+use language::LanguageRegistry;
+use paths::local_debug_file_relative_path;
+use std::{
+    borrow::Cow,
+    path::{Path, PathBuf},
+    sync::Arc,
+    time::Duration,
+    usize,
+};
+use tasks_ui::{TaskOverrides, TasksModal};
+
+use dap::{
+    DapRegistry, DebugRequest, TelemetrySpawnLocation, adapters::DebugAdapterName, send_telemetry,
+};
+use editor::{Editor, EditorElement, EditorStyle};
+use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{
+    Action, App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    HighlightStyle, InteractiveText, KeyContext, PromptButton, PromptLevel, Render, StyledText,
+    Subscription, Task, TextStyle, UnderlineStyle, WeakEntity,
+};
+use itertools::Itertools as _;
+use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch};
+use project::{ProjectPath, TaskContexts, TaskSourceKind, task_store::TaskStore};
+use settings::{Settings, initial_local_debug_tasks_content};
+use task::{DebugScenario, RevealTarget, ZedDebugConfig};
+use theme::ThemeSettings;
+use ui::{
+    ActiveTheme, CheckboxWithLabel, Clickable, Context, ContextMenu, Disableable, DropdownMenu,
+    FluentBuilder, IconWithIndicator, Indicator, IntoElement, KeyBinding, ListItem,
+    ListItemSpacing, ParentElement, StyledExt, ToggleButton, ToggleState, Toggleable, Tooltip,
+    Window, div, prelude::*, px, relative, rems,
+};
+use util::ResultExt;
+use workspace::{ModalView, Workspace, pane};
+
+use crate::{attach_modal::AttachModal, debugger_panel::DebugPanel};
+
+#[allow(unused)]
+enum SaveScenarioState {
+    Saving,
+    Saved((ProjectPath, SharedString)),
+    Failed(SharedString),
+}
+
+pub(super) struct NewProcessModal {
+    workspace: WeakEntity<Workspace>,
+    debug_panel: WeakEntity<DebugPanel>,
+    mode: NewProcessMode,
+    debug_picker: Entity<Picker<DebugDelegate>>,
+    attach_mode: Entity<AttachMode>,
+    configure_mode: Entity<ConfigureMode>,
+    task_mode: TaskMode,
+    debugger: Option<DebugAdapterName>,
+    save_scenario_state: Option<SaveScenarioState>,
+    _subscriptions: [Subscription; 3],
+}
+
+fn suggested_label(request: &DebugRequest, debugger: &str) -> SharedString {
+    match request {
+        DebugRequest::Launch(config) => {
+            let last_path_component = Path::new(&config.program)
+                .file_name()
+                .map(|name| name.to_string_lossy())
+                .unwrap_or_else(|| Cow::Borrowed(&config.program));
+
+            format!("{} ({debugger})", last_path_component).into()
+        }
+        DebugRequest::Attach(config) => format!(
+            "pid: {} ({debugger})",
+            config.process_id.unwrap_or(u32::MAX)
+        )
+        .into(),
+    }
+}
+
+impl NewProcessModal {
+    pub(super) fn show(
+        workspace: &mut Workspace,
+        window: &mut Window,
+        mode: NewProcessMode,
+        reveal_target: Option<RevealTarget>,
+        cx: &mut Context<Workspace>,
+    ) {
+        let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) else {
+            return;
+        };
+        let task_store = workspace.project().read(cx).task_store().clone();
+        let languages = workspace.app_state().languages.clone();
+
+        cx.spawn_in(window, async move |workspace, cx| {
+            let task_contexts = workspace.update_in(cx, |workspace, window, cx| {
+                tasks_ui::task_contexts(workspace, window, cx)
+            })?;
+            workspace.update_in(cx, |workspace, window, cx| {
+                let workspace_handle = workspace.weak_handle();
+                workspace.toggle_modal(window, cx, |window, cx| {
+                    let attach_mode = AttachMode::new(None, workspace_handle.clone(), window, cx);
+
+                    let debug_picker = cx.new(|cx| {
+                        let delegate =
+                            DebugDelegate::new(debug_panel.downgrade(), task_store.clone());
+                        Picker::uniform_list(delegate, window, cx).modal(false)
+                    });
+
+                    let configure_mode = ConfigureMode::new(window, cx);
+
+                    let task_overrides = Some(TaskOverrides { reveal_target });
+
+                    let task_mode = TaskMode {
+                        task_modal: cx.new(|cx| {
+                            TasksModal::new(
+                                task_store.clone(),
+                                Arc::new(TaskContexts::default()),
+                                task_overrides,
+                                false,
+                                workspace_handle.clone(),
+                                window,
+                                cx,
+                            )
+                        }),
+                    };
+
+                    let _subscriptions = [
+                        cx.subscribe(&debug_picker, |_, _, _, cx| {
+                            cx.emit(DismissEvent);
+                        }),
+                        cx.subscribe(
+                            &attach_mode.read(cx).attach_picker.clone(),
+                            |_, _, _, cx| {
+                                cx.emit(DismissEvent);
+                            },
+                        ),
+                        cx.subscribe(&task_mode.task_modal, |_, _, _: &DismissEvent, cx| {
+                            cx.emit(DismissEvent)
+                        }),
+                    ];
+
+                    cx.spawn_in(window, {
+                        let debug_picker = debug_picker.downgrade();
+                        let configure_mode = configure_mode.downgrade();
+                        let task_modal = task_mode.task_modal.downgrade();
+                        let workspace = workspace_handle.clone();
+
+                        async move |this, cx| {
+                            let task_contexts = task_contexts.await;
+                            let task_contexts = Arc::new(task_contexts);
+                            let lsp_task_sources = task_contexts.lsp_task_sources.clone();
+                            let task_position = task_contexts.latest_selection;
+                            // Get LSP tasks and filter out based on language vs lsp preference
+                            let (lsp_tasks, prefer_lsp) =
+                                workspace.update(cx, |workspace, cx| {
+                                    let lsp_tasks = editor::lsp_tasks(
+                                        workspace.project().clone(),
+                                        &lsp_task_sources,
+                                        task_position,
+                                        cx,
+                                    );
+                                    let prefer_lsp = workspace
+                                        .active_item(cx)
+                                        .and_then(|item| item.downcast::<Editor>())
+                                        .map(|editor| {
+                                            editor
+                                                .read(cx)
+                                                .buffer()
+                                                .read(cx)
+                                                .language_settings(cx)
+                                                .tasks
+                                                .prefer_lsp
+                                        })
+                                        .unwrap_or(false);
+                                    (lsp_tasks, prefer_lsp)
+                                })?;
+
+                            let lsp_tasks = lsp_tasks.await;
+                            let add_current_language_tasks = !prefer_lsp || lsp_tasks.is_empty();
+
+                            let lsp_tasks = lsp_tasks
+                                .into_iter()
+                                .flat_map(|(kind, tasks_with_locations)| {
+                                    tasks_with_locations
+                                        .into_iter()
+                                        .sorted_by_key(|(location, task)| {
+                                            (location.is_none(), task.resolved_label.clone())
+                                        })
+                                        .map(move |(_, task)| (kind.clone(), task))
+                                })
+                                .collect::<Vec<_>>();
+
+                            let Some(task_inventory) = task_store
+                                .update(cx, |task_store, _| task_store.task_inventory().cloned())?
+                            else {
+                                return Ok(());
+                            };
+
+                            let (used_tasks, current_resolved_tasks) = task_inventory
+                                .update(cx, |task_inventory, cx| {
+                                    task_inventory
+                                        .used_and_current_resolved_tasks(task_contexts.clone(), cx)
+                                })?
+                                .await;
+
+                            if let Ok(task) = debug_picker.update(cx, |picker, cx| {
+                                picker.delegate.tasks_loaded(
+                                    task_contexts.clone(),
+                                    languages,
+                                    lsp_tasks.clone(),
+                                    current_resolved_tasks.clone(),
+                                    add_current_language_tasks,
+                                    cx,
+                                )
+                            }) {
+                                task.await;
+                                debug_picker
+                                    .update_in(cx, |picker, window, cx| {
+                                        picker.refresh(window, cx);
+                                        cx.notify();
+                                    })
+                                    .ok();
+                            }
+
+                            if let Some(active_cwd) = task_contexts
+                                .active_context()
+                                .and_then(|context| context.cwd.clone())
+                            {
+                                configure_mode
+                                    .update_in(cx, |configure_mode, window, cx| {
+                                        configure_mode.load(active_cwd, window, cx);
+                                    })
+                                    .ok();
+                            }
+
+                            task_modal
+                                .update_in(cx, |task_modal, window, cx| {
+                                    task_modal.tasks_loaded(
+                                        task_contexts,
+                                        lsp_tasks,
+                                        used_tasks,
+                                        current_resolved_tasks,
+                                        add_current_language_tasks,
+                                        window,
+                                        cx,
+                                    );
+                                })
+                                .ok();
+
+                            this.update(cx, |_, cx| {
+                                cx.notify();
+                            })
+                            .ok();
+
+                            anyhow::Ok(())
+                        }
+                    })
+                    .detach();
+
+                    Self {
+                        debug_picker,
+                        attach_mode,
+                        configure_mode,
+                        task_mode,
+                        debugger: None,
+                        mode,
+                        debug_panel: debug_panel.downgrade(),
+                        workspace: workspace_handle,
+                        save_scenario_state: None,
+                        _subscriptions,
+                    }
+                });
+            })?;
+
+            anyhow::Ok(())
+        })
+        .detach();
+    }
+
+    fn render_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
+        let dap_menu = self.adapter_drop_down_menu(window, cx);
+        match self.mode {
+            NewProcessMode::Task => self
+                .task_mode
+                .task_modal
+                .read(cx)
+                .picker
+                .clone()
+                .into_any_element(),
+            NewProcessMode::Attach => self.attach_mode.update(cx, |this, cx| {
+                this.clone().render(window, cx).into_any_element()
+            }),
+            NewProcessMode::Launch => self.configure_mode.update(cx, |this, cx| {
+                this.clone().render(dap_menu, window, cx).into_any_element()
+            }),
+            NewProcessMode::Debug => v_flex()
+                .w(rems(34.))
+                .child(self.debug_picker.clone())
+                .into_any_element(),
+        }
+    }
+
+    fn mode_focus_handle(&self, cx: &App) -> FocusHandle {
+        match self.mode {
+            NewProcessMode::Task => self.task_mode.task_modal.focus_handle(cx),
+            NewProcessMode::Attach => self.attach_mode.read(cx).attach_picker.focus_handle(cx),
+            NewProcessMode::Launch => self.configure_mode.read(cx).program.focus_handle(cx),
+            NewProcessMode::Debug => self.debug_picker.focus_handle(cx),
+        }
+    }
+
+    fn debug_scenario(&self, debugger: &str, cx: &App) -> Task<Option<DebugScenario>> {
+        let request = match self.mode {
+            NewProcessMode::Launch => {
+                DebugRequest::Launch(self.configure_mode.read(cx).debug_request(cx))
+            }
+            NewProcessMode::Attach => {
+                DebugRequest::Attach(self.attach_mode.read(cx).debug_request())
+            }
+            _ => return Task::ready(None),
+        };
+        let label = suggested_label(&request, debugger);
+
+        let stop_on_entry = if let NewProcessMode::Launch = &self.mode {
+            Some(self.configure_mode.read(cx).stop_on_entry.selected())
+        } else {
+            None
+        };
+
+        let session_scenario = ZedDebugConfig {
+            adapter: debugger.to_owned().into(),
+            label,
+            request,
+            stop_on_entry,
+        };
+
+        let adapter = cx
+            .global::<DapRegistry>()
+            .adapter(&session_scenario.adapter);
+
+        cx.spawn(async move |_| adapter?.config_from_zed_format(session_scenario).await.ok())
+    }
+
+    fn start_new_session(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if self.debugger.as_ref().is_none() {
+            return;
+        }
+
+        if let NewProcessMode::Debug = &self.mode {
+            self.debug_picker.update(cx, |picker, cx| {
+                picker.delegate.confirm(false, window, cx);
+            });
+            return;
+        }
+
+        if let NewProcessMode::Launch = &self.mode {
+            if self.configure_mode.read(cx).save_to_debug_json.selected() {
+                self.save_debug_scenario(window, cx);
+            }
+        }
+
+        let Some(debugger) = self.debugger.clone() else {
+            return;
+        };
+
+        let debug_panel = self.debug_panel.clone();
+        let Some(task_contexts) = self.task_contexts(cx) else {
+            return;
+        };
+
+        let task_context = task_contexts.active_context().cloned().unwrap_or_default();
+        let worktree_id = task_contexts.worktree();
+        let mode = self.mode;
+        cx.spawn_in(window, async move |this, cx| {
+            let Some(config) = this
+                .update(cx, |this, cx| this.debug_scenario(&debugger, cx))?
+                .await
+            else {
+                bail!("debug config not found in mode: {mode}");
+            };
+
+            debug_panel.update_in(cx, |debug_panel, window, cx| {
+                send_telemetry(&config, TelemetrySpawnLocation::Custom, cx);
+                debug_panel.start_session(config, task_context, None, worktree_id, window, cx)
+            })?;
+            this.update(cx, |_, cx| {
+                cx.emit(DismissEvent);
+            })
+            .ok();
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn update_attach_picker(
+        attach: &Entity<AttachMode>,
+        adapter: &DebugAdapterName,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        attach.update(cx, |this, cx| {
+            if adapter.0 != this.definition.adapter {
+                this.definition.adapter = adapter.0.clone();
+
+                this.attach_picker.update(cx, |this, cx| {
+                    this.picker.update(cx, |this, cx| {
+                        this.delegate.definition.adapter = adapter.0.clone();
+                        this.focus(window, cx);
+                    })
+                });
+            }
+
+            cx.notify();
+        })
+    }
+
+    fn task_contexts(&self, cx: &App) -> Option<Arc<TaskContexts>> {
+        self.debug_picker.read(cx).delegate.task_contexts.clone()
+    }
+
+    fn save_debug_scenario(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let task_contents = self.task_contexts(cx);
+        let Some(adapter) = self.debugger.as_ref() else {
+            return;
+        };
+        let scenario = self.debug_scenario(&adapter, cx);
+
+        self.save_scenario_state = Some(SaveScenarioState::Saving);
+
+        cx.spawn_in(window, async move |this, cx| {
+            let Some((scenario, worktree_id)) = scenario
+                .await
+                .zip(task_contents.and_then(|tcx| tcx.worktree()))
+            else {
+                this.update(cx, |this, _| {
+                    this.save_scenario_state = Some(SaveScenarioState::Failed(
+                        "Couldn't get scenario or task contents".into(),
+                    ))
+                })
+                .ok();
+                return;
+            };
+
+            let Some(save_scenario) = this
+                .update_in(cx, |this, window, cx| {
+                    this.debug_panel
+                        .update(cx, |panel, cx| {
+                            panel.save_scenario(&scenario, worktree_id, window, cx)
+                        })
+                        .ok()
+                })
+                .ok()
+                .flatten()
+            else {
+                return;
+            };
+            let res = save_scenario.await;
+
+            this.update(cx, |this, _| match res {
+                Ok(saved_file) => {
+                    this.save_scenario_state = Some(SaveScenarioState::Saved((
+                        saved_file,
+                        scenario.label.clone(),
+                    )))
+                }
+                Err(error) => {
+                    this.save_scenario_state =
+                        Some(SaveScenarioState::Failed(error.to_string().into()))
+                }
+            })
+            .ok();
+
+            cx.background_executor().timer(Duration::from_secs(3)).await;
+            this.update(cx, |this, _| this.save_scenario_state.take())
+                .ok();
+        })
+        .detach();
+    }
+
+    fn adapter_drop_down_menu(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> ui::DropdownMenu {
+        let workspace = self.workspace.clone();
+        let weak = cx.weak_entity();
+        let active_buffer = self.task_contexts(cx).and_then(|tc| {
+            tc.active_item_context
+                .as_ref()
+                .and_then(|aic| aic.1.as_ref().map(|l| l.buffer.clone()))
+        });
+
+        let active_buffer_language = active_buffer
+            .and_then(|buffer| buffer.read(cx).language())
+            .cloned();
+
+        let mut available_adapters = workspace
+            .update(cx, |_, cx| DapRegistry::global(cx).enumerate_adapters())
+            .unwrap_or_default();
+        if let Some(language) = active_buffer_language {
+            available_adapters.sort_by_key(|adapter| {
+                language
+                    .config()
+                    .debuggers
+                    .get_index_of(adapter.0.as_ref())
+                    .unwrap_or(usize::MAX)
+            });
+            if self.debugger.is_none() {
+                self.debugger = available_adapters.first().cloned();
+            }
+        }
+
+        let label = self
+            .debugger
+            .as_ref()
+            .map(|d| d.0.clone())
+            .unwrap_or_else(|| SELECT_DEBUGGER_LABEL.clone());
+
+        DropdownMenu::new(
+            "dap-adapter-picker",
+            label,
+            ContextMenu::build(window, cx, move |mut menu, _, _| {
+                let setter_for_name = |name: DebugAdapterName| {
+                    let weak = weak.clone();
+                    move |window: &mut Window, cx: &mut App| {
+                        weak.update(cx, |this, cx| {
+                            this.debugger = Some(name.clone());
+                            cx.notify();
+                            if let NewProcessMode::Attach = &this.mode {
+                                Self::update_attach_picker(&this.attach_mode, &name, window, cx);
+                            }
+                        })
+                        .ok();
+                    }
+                };
+
+                for adapter in available_adapters.into_iter() {
+                    menu = menu.entry(adapter.0.clone(), None, setter_for_name(adapter.clone()));
+                }
+
+                menu
+            }),
+        )
+    }
+
+    fn open_debug_json(&self, window: &mut Window, cx: &mut Context<NewProcessModal>) {
+        let this = cx.entity();
+        window
+            .spawn(cx, async move |cx| {
+                let worktree_id = this.update(cx, |this, cx| {
+                    let tcx = this.task_contexts(cx);
+                    tcx?.worktree()
+                })?;
+
+                let Some(worktree_id) = worktree_id else {
+                    let _ = cx.prompt(
+                        PromptLevel::Critical,
+                        "Cannot open debug.json",
+                        Some("You must have at least one project open"),
+                        &[PromptButton::ok("Ok")],
+                    );
+                    return Ok(());
+                };
+
+                let editor = this
+                    .update_in(cx, |this, window, cx| {
+                        this.workspace.update(cx, |workspace, cx| {
+                            workspace.open_path(
+                                ProjectPath {
+                                    worktree_id,
+                                    path: local_debug_file_relative_path().into(),
+                                },
+                                None,
+                                true,
+                                window,
+                                cx,
+                            )
+                        })
+                    })??
+                    .await?;
+
+                cx.update(|_window, cx| {
+                    if let Some(editor) = editor.act_as::<Editor>(cx) {
+                        editor.update(cx, |editor, cx| {
+                            editor.buffer().update(cx, |buffer, cx| {
+                                if let Some(singleton) = buffer.as_singleton() {
+                                    singleton.update(cx, |buffer, cx| {
+                                        if buffer.is_empty() {
+                                            buffer.edit(
+                                                [(0..0, initial_local_debug_tasks_content())],
+                                                None,
+                                                cx,
+                                            );
+                                        }
+                                    })
+                                }
+                            })
+                        });
+                    }
+                })
+                .ok();
+
+                this.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
+
+                anyhow::Ok(())
+            })
+            .detach();
+    }
+}
+
+static SELECT_DEBUGGER_LABEL: SharedString = SharedString::new_static("Select Debugger");
+
+#[derive(Clone, Copy)]
+pub(crate) enum NewProcessMode {
+    Task,
+    Launch,
+    Attach,
+    Debug,
+}
+
+impl std::fmt::Display for NewProcessMode {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let mode = match self {
+            NewProcessMode::Task => "Run",
+            NewProcessMode::Debug => "Debug",
+            NewProcessMode::Attach => "Attach",
+            NewProcessMode::Launch => "Launch",
+        };
+
+        write!(f, "{}", mode)
+    }
+}
+
+impl Focusable for NewProcessMode {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        cx.focus_handle()
+    }
+}
+
+fn render_editor(editor: &Entity<Editor>, window: &mut Window, cx: &App) -> impl IntoElement {
+    let settings = ThemeSettings::get_global(cx);
+    let theme = cx.theme();
+
+    let text_style = TextStyle {
+        color: cx.theme().colors().text,
+        font_family: settings.buffer_font.family.clone(),
+        font_features: settings.buffer_font.features.clone(),
+        font_size: settings.buffer_font_size(cx).into(),
+        font_weight: settings.buffer_font.weight,
+        line_height: relative(settings.buffer_line_height.value()),
+        background_color: Some(theme.colors().editor_background),
+        ..Default::default()
+    };
+
+    let element = EditorElement::new(
+        editor,
+        EditorStyle {
+            background: theme.colors().editor_background,
+            local_player: theme.players().local(),
+            text: text_style,
+            ..Default::default()
+        },
+    );
+
+    div()
+        .rounded_md()
+        .p_1()
+        .border_1()
+        .border_color(theme.colors().border_variant)
+        .when(
+            editor.focus_handle(cx).contains_focused(window, cx),
+            |this| this.border_color(theme.colors().border_focused),
+        )
+        .child(element)
+        .bg(theme.colors().editor_background)
+}
+
+impl Render for NewProcessModal {
+    fn render(
+        &mut self,
+        window: &mut ui::Window,
+        cx: &mut ui::Context<Self>,
+    ) -> impl ui::IntoElement {
+        v_flex()
+            .key_context({
+                let mut key_context = KeyContext::new_with_defaults();
+                key_context.add("Pane");
+                key_context.add("RunModal");
+                key_context
+            })
+            .size_full()
+            .w(rems(34.))
+            .elevation_3(cx)
+            .overflow_hidden()
+            .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
+                cx.emit(DismissEvent);
+            }))
+            .on_action(cx.listener(|this, _: &pane::ActivateNextItem, window, cx| {
+                this.mode = match this.mode {
+                    NewProcessMode::Task => NewProcessMode::Debug,
+                    NewProcessMode::Debug => NewProcessMode::Attach,
+                    NewProcessMode::Attach => NewProcessMode::Launch,
+                    NewProcessMode::Launch => NewProcessMode::Task,
+                };
+
+                this.mode_focus_handle(cx).focus(window);
+            }))
+            .on_action(
+                cx.listener(|this, _: &pane::ActivatePreviousItem, window, cx| {
+                    this.mode = match this.mode {
+                        NewProcessMode::Task => NewProcessMode::Launch,
+                        NewProcessMode::Debug => NewProcessMode::Task,
+                        NewProcessMode::Attach => NewProcessMode::Debug,
+                        NewProcessMode::Launch => NewProcessMode::Attach,
+                    };
+
+                    this.mode_focus_handle(cx).focus(window);
+                }),
+            )
+            .child(
+                h_flex()
+                    .p_2()
+                    .w_full()
+                    .border_b_1()
+                    .border_color(cx.theme().colors().border_variant)
+                    .child(
+                        ToggleButton::new(
+                            "debugger-session-ui-tasks-button",
+                            NewProcessMode::Task.to_string(),
+                        )
+                        .size(ButtonSize::Default)
+                        .toggle_state(matches!(self.mode, NewProcessMode::Task))
+                        .style(ui::ButtonStyle::Subtle)
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            this.mode = NewProcessMode::Task;
+                            this.mode_focus_handle(cx).focus(window);
+                            cx.notify();
+                        }))
+                        .tooltip(Tooltip::text("Run predefined task"))
+                        .first(),
+                    )
+                    .child(
+                        ToggleButton::new(
+                            "debugger-session-ui-launch-button",
+                            NewProcessMode::Debug.to_string(),
+                        )
+                        .size(ButtonSize::Default)
+                        .style(ui::ButtonStyle::Subtle)
+                        .toggle_state(matches!(self.mode, NewProcessMode::Debug))
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            this.mode = NewProcessMode::Debug;
+                            this.mode_focus_handle(cx).focus(window);
+                            cx.notify();
+                        }))
+                        .tooltip(Tooltip::text("Start a predefined debug scenario"))
+                        .middle(),
+                    )
+                    .child(
+                        ToggleButton::new(
+                            "debugger-session-ui-attach-button",
+                            NewProcessMode::Attach.to_string(),
+                        )
+                        .size(ButtonSize::Default)
+                        .toggle_state(matches!(self.mode, NewProcessMode::Attach))
+                        .style(ui::ButtonStyle::Subtle)
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            this.mode = NewProcessMode::Attach;
+
+                            if let Some(debugger) = this.debugger.as_ref() {
+                                Self::update_attach_picker(
+                                    &this.attach_mode,
+                                    &debugger,
+                                    window,
+                                    cx,
+                                );
+                            }
+                            this.mode_focus_handle(cx).focus(window);
+                            cx.notify();
+                        }))
+                        .tooltip(Tooltip::text("Attach the debugger to a running process"))
+                        .middle(),
+                    )
+                    .child(
+                        ToggleButton::new(
+                            "debugger-session-ui-custom-button",
+                            NewProcessMode::Launch.to_string(),
+                        )
+                        .size(ButtonSize::Default)
+                        .toggle_state(matches!(self.mode, NewProcessMode::Launch))
+                        .style(ui::ButtonStyle::Subtle)
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            this.mode = NewProcessMode::Launch;
+                            this.mode_focus_handle(cx).focus(window);
+                            cx.notify();
+                        }))
+                        .tooltip(Tooltip::text("Launch a new process with a debugger"))
+                        .last(),
+                    ),
+            )
+            .child(v_flex().child(self.render_mode(window, cx)))
+            .map(|el| {
+                let container = h_flex()
+                    .w_full()
+                    .p_1p5()
+                    .gap_2()
+                    .justify_between()
+                    .border_t_1()
+                    .border_color(cx.theme().colors().border_variant);
+                match self.mode {
+                    NewProcessMode::Launch => el.child(
+                        container
+                            .child(
+                                h_flex()
+                                    .text_ui_sm(cx)
+                                    .text_color(Color::Muted.color(cx))
+                                    .child(
+                                        InteractiveText::new(
+                                            "open-debug-json",
+                                            StyledText::new(
+                                                "Open .zed/debug.json for advanced configuration.",
+                                            )
+                                            .with_highlights([(
+                                                5..20,
+                                                HighlightStyle {
+                                                    underline: Some(UnderlineStyle {
+                                                        thickness: px(1.0),
+                                                        color: None,
+                                                        wavy: false,
+                                                    }),
+                                                    ..Default::default()
+                                                },
+                                            )]),
+                                        )
+                                        .on_click(
+                                            vec![5..20],
+                                            {
+                                                let this = cx.entity();
+                                                move |_, window, cx| {
+                                                    this.update(cx, |this, cx| {
+                                                        this.open_debug_json(window, cx);
+                                                    })
+                                                }
+                                            },
+                                        ),
+                                    ),
+                            )
+                            .child(
+                                Button::new("debugger-spawn", "Start")
+                                    .on_click(cx.listener(|this, _, window, cx| {
+                                        this.start_new_session(window, cx)
+                                    }))
+                                    .disabled(
+                                        self.debugger.is_none()
+                                            || self
+                                                .configure_mode
+                                                .read(cx)
+                                                .program
+                                                .read(cx)
+                                                .is_empty(cx),
+                                    ),
+                            ),
+                    ),
+                    NewProcessMode::Attach => el.child(
+                        container
+                            .child(div().child(self.adapter_drop_down_menu(window, cx)))
+                            .child(
+                                Button::new("debugger-spawn", "Start")
+                                    .on_click(cx.listener(|this, _, window, cx| {
+                                        this.start_new_session(window, cx)
+                                    }))
+                                    .disabled(
+                                        self.debugger.is_none()
+                                            || self
+                                                .attach_mode
+                                                .read(cx)
+                                                .attach_picker
+                                                .read(cx)
+                                                .picker
+                                                .read(cx)
+                                                .delegate
+                                                .match_count()
+                                                == 0,
+                                    ),
+                            ),
+                    ),
+                    NewProcessMode::Debug => el,
+                    NewProcessMode::Task => el,
+                }
+            })
+    }
+}
+
+impl EventEmitter<DismissEvent> for NewProcessModal {}
+impl Focusable for NewProcessModal {
+    fn focus_handle(&self, cx: &ui::App) -> gpui::FocusHandle {
+        self.mode_focus_handle(cx)
+    }
+}
+
+impl ModalView for NewProcessModal {}
+
+impl RenderOnce for AttachMode {
+    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+        v_flex()
+            .w_full()
+            .track_focus(&self.attach_picker.focus_handle(cx))
+            .child(self.attach_picker.clone())
+    }
+}
+
+#[derive(Clone)]
+pub(super) struct ConfigureMode {
+    program: Entity<Editor>,
+    cwd: Entity<Editor>,
+    stop_on_entry: ToggleState,
+    save_to_debug_json: ToggleState,
+}
+
+impl ConfigureMode {
+    pub(super) fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
+        let program = cx.new(|cx| Editor::single_line(window, cx));
+        program.update(cx, |this, cx| {
+            this.set_placeholder_text("ENV=Zed ~/bin/program --option", cx);
+        });
+
+        let cwd = cx.new(|cx| Editor::single_line(window, cx));
+        cwd.update(cx, |this, cx| {
+            this.set_placeholder_text("Ex: $ZED_WORKTREE_ROOT", cx);
+        });
+
+        cx.new(|_| Self {
+            program,
+            cwd,
+            stop_on_entry: ToggleState::Unselected,
+            save_to_debug_json: ToggleState::Unselected,
+        })
+    }
+
+    fn load(&mut self, cwd: PathBuf, window: &mut Window, cx: &mut App) {
+        self.cwd.update(cx, |editor, cx| {
+            if editor.is_empty(cx) {
+                editor.set_text(cwd.to_string_lossy(), window, cx);
+            }
+        });
+    }
+
+    pub(super) fn debug_request(&self, cx: &App) -> task::LaunchRequest {
+        let cwd_text = self.cwd.read(cx).text(cx);
+        let cwd = if cwd_text.is_empty() {
+            None
+        } else {
+            Some(PathBuf::from(cwd_text))
+        };
+
+        if cfg!(windows) {
+            return task::LaunchRequest {
+                program: self.program.read(cx).text(cx),
+                cwd,
+                args: Default::default(),
+                env: Default::default(),
+            };
+        }
+        let command = self.program.read(cx).text(cx);
+        let mut args = shlex::split(&command).into_iter().flatten().peekable();
+        let mut env = FxHashMap::default();
+        while args.peek().is_some_and(|arg| arg.contains('=')) {
+            let arg = args.next().unwrap();
+            let (lhs, rhs) = arg.split_once('=').unwrap();
+            env.insert(lhs.to_string(), rhs.to_string());
+        }
+
+        let program = if let Some(program) = args.next() {
+            program
+        } else {
+            env = FxHashMap::default();
+            command
+        };
+
+        let args = args.collect::<Vec<_>>();
+
+        task::LaunchRequest {
+            program,
+            cwd,
+            args,
+            env,
+        }
+    }
+
+    fn render(
+        &mut self,
+        adapter_menu: DropdownMenu,
+        window: &mut Window,
+        cx: &mut ui::Context<Self>,
+    ) -> impl IntoElement {
+        v_flex()
+            .p_2()
+            .w_full()
+            .gap_2()
+            .track_focus(&self.program.focus_handle(cx))
+            .child(
+                h_flex()
+                    .gap_2()
+                    .child(
+                        Label::new("Debugger")
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    )
+                    .child(adapter_menu),
+            )
+            .child(
+                v_flex()
+                    .gap_0p5()
+                    .child(
+                        Label::new("Program")
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    )
+                    .child(render_editor(&self.program, window, cx)),
+            )
+            .child(
+                v_flex()
+                    .gap_0p5()
+                    .child(
+                        Label::new("Working Directory")
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    )
+                    .child(render_editor(&self.cwd, window, cx)),
+            )
+            .child(
+                CheckboxWithLabel::new(
+                    "debugger-stop-on-entry",
+                    Label::new("Stop on Entry")
+                        .size(LabelSize::Small)
+                        .color(Color::Muted),
+                    self.stop_on_entry,
+                    {
+                        let this = cx.weak_entity();
+                        move |state, _, cx| {
+                            this.update(cx, |this, _| {
+                                this.stop_on_entry = *state;
+                            })
+                            .ok();
+                        }
+                    },
+                )
+                .checkbox_position(ui::IconPosition::End),
+            )
+            .child(
+                CheckboxWithLabel::new(
+                    "debugger-save-to-debug-json",
+                    Label::new("Save to debug.json")
+                        .size(LabelSize::Small)
+                        .color(Color::Muted),
+                    self.save_to_debug_json,
+                    {
+                        let this = cx.weak_entity();
+                        move |state, _, cx| {
+                            this.update(cx, |this, _| {
+                                this.save_to_debug_json = *state;
+                            })
+                            .ok();
+                        }
+                    },
+                )
+                .checkbox_position(ui::IconPosition::End),
+            )
+    }
+}
+
+#[derive(Clone)]
+pub(super) struct AttachMode {
+    pub(super) definition: ZedDebugConfig,
+    pub(super) attach_picker: Entity<AttachModal>,
+}
+
+impl AttachMode {
+    pub(super) fn new(
+        debugger: Option<DebugAdapterName>,
+        workspace: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<NewProcessModal>,
+    ) -> Entity<Self> {
+        let definition = ZedDebugConfig {
+            adapter: debugger.unwrap_or(DebugAdapterName("".into())).0,
+            label: "Attach New Session Setup".into(),
+            request: dap::DebugRequest::Attach(task::AttachRequest { process_id: None }),
+            stop_on_entry: Some(false),
+        };
+        let attach_picker = cx.new(|cx| {
+            let modal = AttachModal::new(definition.clone(), workspace, false, window, cx);
+            window.focus(&modal.focus_handle(cx));
+
+            modal
+        });
+
+        cx.new(|_| Self {
+            definition,
+            attach_picker,
+        })
+    }
+    pub(super) fn debug_request(&self) -> task::AttachRequest {
+        task::AttachRequest { process_id: None }
+    }
+}
+
+#[derive(Clone)]
+pub(super) struct TaskMode {
+    pub(super) task_modal: Entity<TasksModal>,
+}
+
+pub(super) struct DebugDelegate {
+    task_store: Entity<TaskStore>,
+    candidates: Vec<(Option<TaskSourceKind>, DebugScenario)>,
+    selected_index: usize,
+    matches: Vec<StringMatch>,
+    prompt: String,
+    debug_panel: WeakEntity<DebugPanel>,
+    task_contexts: Option<Arc<TaskContexts>>,
+    divider_index: Option<usize>,
+    last_used_candidate_index: Option<usize>,
+}
+
+impl DebugDelegate {
+    pub(super) fn new(debug_panel: WeakEntity<DebugPanel>, task_store: Entity<TaskStore>) -> Self {
+        Self {
+            task_store,
+            candidates: Vec::default(),
+            selected_index: 0,
+            matches: Vec::new(),
+            prompt: String::new(),
+            debug_panel,
+            task_contexts: None,
+            divider_index: None,
+            last_used_candidate_index: None,
+        }
+    }
+
+    fn get_scenario_kind(
+        languages: &Arc<LanguageRegistry>,
+        dap_registry: &DapRegistry,
+        scenario: DebugScenario,
+    ) -> (Option<TaskSourceKind>, DebugScenario) {
+        let language_names = languages.language_names();
+        let language = dap_registry
+            .adapter_language(&scenario.adapter)
+            .map(|language| TaskSourceKind::Language {
+                name: language.into(),
+            });
+
+        let language = language.or_else(|| {
+            scenario.label.split_whitespace().find_map(|word| {
+                language_names
+                    .iter()
+                    .find(|name| name.eq_ignore_ascii_case(word))
+                    .map(|name| TaskSourceKind::Language {
+                        name: name.to_owned().into(),
+                    })
+            })
+        });
+
+        (language, scenario)
+    }
+
+    pub fn tasks_loaded(
+        &mut self,
+        task_contexts: Arc<TaskContexts>,
+        languages: Arc<LanguageRegistry>,
+        lsp_tasks: Vec<(TaskSourceKind, task::ResolvedTask)>,
+        current_resolved_tasks: Vec<(TaskSourceKind, task::ResolvedTask)>,
+        add_current_language_tasks: bool,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Task<()> {
+        self.task_contexts = Some(task_contexts.clone());
+        let task = self.task_store.update(cx, |task_store, cx| {
+            task_store.task_inventory().map(|inventory| {
+                inventory.update(cx, |inventory, cx| {
+                    inventory.list_debug_scenarios(
+                        &task_contexts,
+                        lsp_tasks,
+                        current_resolved_tasks,
+                        add_current_language_tasks,
+                        cx,
+                    )
+                })
+            })
+        });
+        cx.spawn(async move |this, cx| {
+            let (recent, scenarios) = if let Some(task) = task {
+                task.await
+            } else {
+                (Vec::new(), Vec::new())
+            };
+
+            this.update(cx, |this, cx| {
+                if !recent.is_empty() {
+                    this.delegate.last_used_candidate_index = Some(recent.len() - 1);
+                }
+
+                let dap_registry = cx.global::<DapRegistry>();
+                let hide_vscode = scenarios.iter().any(|(kind, _)| match kind {
+                    TaskSourceKind::Worktree {
+                        id: _,
+                        directory_in_worktree: dir,
+                        id_base: _,
+                    } => dir.ends_with(".zed"),
+                    _ => false,
+                });
+
+                this.delegate.candidates = recent
+                    .into_iter()
+                    .map(|scenario| Self::get_scenario_kind(&languages, &dap_registry, scenario))
+                    .chain(
+                        scenarios
+                            .into_iter()
+                            .filter(|(kind, _)| match kind {
+                                TaskSourceKind::Worktree {
+                                    id: _,
+                                    directory_in_worktree: dir,
+                                    id_base: _,
+                                } => !(hide_vscode && dir.ends_with(".vscode")),
+                                _ => true,
+                            })
+                            .map(|(kind, scenario)| {
+                                let (language, scenario) =
+                                    Self::get_scenario_kind(&languages, &dap_registry, scenario);
+                                (language.or(Some(kind)), scenario)
+                            }),
+                    )
+                    .collect();
+            })
+            .ok();
+        })
+    }
+}
+
+impl PickerDelegate for DebugDelegate {
+    type ListItem = ui::ListItem;
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(
+        &mut self,
+        ix: usize,
+        _window: &mut Window,
+        _cx: &mut Context<picker::Picker<Self>>,
+    ) {
+        self.selected_index = ix;
+    }
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> std::sync::Arc<str> {
+        "Find a debug task, or debug a command.".into()
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        window: &mut Window,
+        cx: &mut Context<picker::Picker<Self>>,
+    ) -> gpui::Task<()> {
+        let candidates = self.candidates.clone();
+
+        cx.spawn_in(window, async move |picker, cx| {
+            let candidates: Vec<_> = candidates
+                .into_iter()
+                .enumerate()
+                .map(|(index, (_, candidate))| {
+                    StringMatchCandidate::new(index, candidate.label.as_ref())
+                })
+                .collect();
+
+            let matches = fuzzy::match_strings(
+                &candidates,
+                &query,
+                true,
+                true,
+                1000,
+                &Default::default(),
+                cx.background_executor().clone(),
+            )
+            .await;
+
+            picker
+                .update(cx, |picker, _| {
+                    let delegate = &mut picker.delegate;
+
+                    delegate.matches = matches;
+                    delegate.prompt = query;
+
+                    delegate.divider_index = delegate.last_used_candidate_index.and_then(|index| {
+                        let index = delegate
+                            .matches
+                            .partition_point(|matching_task| matching_task.candidate_id <= index);
+                        Some(index).and_then(|index| (index != 0).then(|| index - 1))
+                    });
+
+                    if delegate.matches.is_empty() {
+                        delegate.selected_index = 0;
+                    } else {
+                        delegate.selected_index =
+                            delegate.selected_index.min(delegate.matches.len() - 1);
+                    }
+                })
+                .log_err();
+        })
+    }
+
+    fn separators_after_indices(&self) -> Vec<usize> {
+        if let Some(i) = self.divider_index {
+            vec![i]
+        } else {
+            Vec::new()
+        }
+    }
+
+    fn confirm_input(
+        &mut self,
+        _secondary: bool,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) {
+        let text = self.prompt.clone();
+        let (task_context, worktree_id) = self
+            .task_contexts
+            .as_ref()
+            .and_then(|task_contexts| {
+                Some((
+                    task_contexts.active_context().cloned()?,
+                    task_contexts.worktree(),
+                ))
+            })
+            .unwrap_or_default();
+
+        let mut args = shlex::split(&text).into_iter().flatten().peekable();
+        let mut env = HashMap::default();
+        while args.peek().is_some_and(|arg| arg.contains('=')) {
+            let arg = args.next().unwrap();
+            let (lhs, rhs) = arg.split_once('=').unwrap();
+            env.insert(lhs.to_string(), rhs.to_string());
+        }
+
+        let program = if let Some(program) = args.next() {
+            program
+        } else {
+            env = HashMap::default();
+            text
+        };
+
+        let args = args.collect::<Vec<_>>();
+        let task = task::TaskTemplate {
+            label: "one-off".to_owned(),
+            env,
+            command: program,
+            args,
+            ..Default::default()
+        };
+
+        let Some(location) = self
+            .task_contexts
+            .as_ref()
+            .and_then(|cx| cx.location().cloned())
+        else {
+            return;
+        };
+        let file = location.buffer.read(cx).file();
+        let language = location.buffer.read(cx).language();
+        let language_name = language.as_ref().map(|l| l.name());
+        let Some(adapter): Option<DebugAdapterName> =
+            language::language_settings::language_settings(language_name, file, cx)
+                .debuggers
+                .first()
+                .map(SharedString::from)
+                .map(Into::into)
+                .or_else(|| {
+                    language.and_then(|l| {
+                        l.config()
+                            .debuggers
+                            .first()
+                            .map(SharedString::from)
+                            .map(Into::into)
+                    })
+                })
+        else {
+            return;
+        };
+        let locators = cx.global::<DapRegistry>().locators();
+        cx.spawn_in(window, async move |this, cx| {
+            let Some(debug_scenario) = cx
+                .background_spawn(async move {
+                    for locator in locators {
+                        if let Some(scenario) =
+                            locator.1.create_scenario(&task, "one-off", &adapter).await
+                        {
+                            return Some(scenario);
+                        }
+                    }
+                    None
+                })
+                .await
+            else {
+                return;
+            };
+
+            this.update_in(cx, |this, window, cx| {
+                send_telemetry(&debug_scenario, TelemetrySpawnLocation::ScenarioList, cx);
+                this.delegate
+                    .debug_panel
+                    .update(cx, |panel, cx| {
+                        panel.start_session(
+                            debug_scenario,
+                            task_context,
+                            None,
+                            worktree_id,
+                            window,
+                            cx,
+                        );
+                    })
+                    .ok();
+                cx.emit(DismissEvent);
+            })
+            .ok();
+        })
+        .detach();
+    }
+
+    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
+        let debug_scenario = self
+            .matches
+            .get(self.selected_index())
+            .and_then(|match_candidate| self.candidates.get(match_candidate.candidate_id).cloned());
+
+        let Some((_, debug_scenario)) = debug_scenario else {
+            return;
+        };
+
+        let (task_context, worktree_id) = self
+            .task_contexts
+            .as_ref()
+            .and_then(|task_contexts| {
+                Some((
+                    task_contexts.active_context().cloned()?,
+                    task_contexts.worktree(),
+                ))
+            })
+            .unwrap_or_default();
+
+        send_telemetry(&debug_scenario, TelemetrySpawnLocation::ScenarioList, cx);
+        self.debug_panel
+            .update(cx, |panel, cx| {
+                panel.start_session(debug_scenario, task_context, None, worktree_id, window, cx);
+            })
+            .ok();
+
+        cx.emit(DismissEvent);
+    }
+
+    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
+        cx.emit(DismissEvent);
+    }
+
+    fn render_footer(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<ui::AnyElement> {
+        let current_modifiers = window.modifiers();
+        let footer = h_flex()
+            .w_full()
+            .p_1p5()
+            .justify_end()
+            .border_t_1()
+            .border_color(cx.theme().colors().border_variant)
+            // .child(
+            //     // TODO: add button to open selected task in debug.json
+            //     h_flex().into_any_element(),
+            // )
+            .map(|this| {
+                if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty() {
+                    let action = picker::ConfirmInput {
+                        secondary: current_modifiers.secondary(),
+                    }
+                    .boxed_clone();
+                    this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| {
+                        Button::new("launch-custom", "Launch Custom")
+                            .key_binding(keybind)
+                            .on_click(move |_, window, cx| {
+                                window.dispatch_action(action.boxed_clone(), cx)
+                            })
+                    }))
+                } else {
+                    this.children(KeyBinding::for_action(&menu::Confirm, window, cx).map(
+                        |keybind| {
+                            let is_recent_selected =
+                                self.divider_index >= Some(self.selected_index);
+                            let run_entry_label =
+                                if is_recent_selected { "Rerun" } else { "Spawn" };
+
+                            Button::new("spawn", run_entry_label)
+                                .key_binding(keybind)
+                                .on_click(|_, window, cx| {
+                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx);
+                                })
+                        },
+                    ))
+                }
+            });
+        Some(footer.into_any_element())
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        window: &mut Window,
+        cx: &mut Context<picker::Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let hit = &self.matches[ix];
+
+        let highlighted_location = HighlightedMatch {
+            text: hit.string.clone(),
+            highlight_positions: hit.positions.clone(),
+            char_count: hit.string.chars().count(),
+            color: Color::Default,
+        };
+        let task_kind = &self.candidates[hit.candidate_id].0;
+
+        let icon = match task_kind {
+            Some(TaskSourceKind::UserInput) => Some(Icon::new(IconName::Terminal)),
+            Some(TaskSourceKind::AbsPath { .. }) => Some(Icon::new(IconName::Settings)),
+            Some(TaskSourceKind::Worktree { .. }) => Some(Icon::new(IconName::FileTree)),
+            Some(TaskSourceKind::Lsp {
+                language_name: name,
+                ..
+            })
+            | Some(TaskSourceKind::Language { name }) => file_icons::FileIcons::get(cx)
+                .get_icon_for_type(&name.to_lowercase(), cx)
+                .map(Icon::from_path),
+            None => Some(Icon::new(IconName::HistoryRerun)),
+        }
+        .map(|icon| icon.color(Color::Muted).size(IconSize::Small));
+        let indicator = if matches!(task_kind, Some(TaskSourceKind::Lsp { .. })) {
+            Some(Indicator::icon(
+                Icon::new(IconName::BoltFilled)
+                    .color(Color::Muted)
+                    .size(IconSize::Small),
+            ))
+        } else {
+            None
+        };
+        let icon = icon.map(|icon| {
+            IconWithIndicator::new(icon, indicator)
+                .indicator_border_color(Some(cx.theme().colors().border_transparent))
+        });
+
+        Some(
+            ListItem::new(SharedString::from(format!("debug-scenario-selection-{ix}")))
+                .inset(true)
+                .start_slot::<IconWithIndicator>(icon)
+                .spacing(ListItemSpacing::Sparse)
+                .toggle_state(selected)
+                .child(highlighted_location.render(window, cx)),
+        )
+    }
+}
+
+pub(crate) fn resolve_path(path: &mut String) {
+    if path.starts_with('~') {
+        let home = paths::home_dir().to_string_lossy().to_string();
+        let trimmed_path = path.trim().to_owned();
+        *path = trimmed_path.replacen('~', &home, 1);
+    } else if let Some(strip_path) = path.strip_prefix(&format!(".{}", std::path::MAIN_SEPARATOR)) {
+        *path = format!(
+            "$ZED_WORKTREE_ROOT{}{}",
+            std::path::MAIN_SEPARATOR,
+            &strip_path
+        );
+    };
+}

crates/debugger_ui/src/new_session_modal.rs 🔗

@@ -1,924 +0,0 @@
-use std::{
-    borrow::Cow,
-    ops::Not,
-    path::{Path, PathBuf},
-    sync::Arc,
-    usize,
-};
-
-use dap::{
-    DapRegistry, DebugRequest,
-    adapters::{DebugAdapterName, DebugTaskDefinition},
-};
-use editor::{Editor, EditorElement, EditorStyle};
-use fuzzy::{StringMatch, StringMatchCandidate};
-use gpui::{
-    App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render,
-    Subscription, TextStyle, WeakEntity,
-};
-use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch};
-use project::{TaskContexts, TaskSourceKind, task_store::TaskStore};
-use settings::Settings;
-use task::{DebugScenario, LaunchRequest};
-use theme::ThemeSettings;
-use ui::{
-    ActiveTheme, Button, ButtonCommon, ButtonSize, CheckboxWithLabel, Clickable, Color, Context,
-    ContextMenu, Disableable, DropdownMenu, FluentBuilder, Icon, IconName, InteractiveElement,
-    IntoElement, Label, LabelCommon as _, ListItem, ListItemSpacing, ParentElement, RenderOnce,
-    SharedString, Styled, StyledExt, ToggleButton, ToggleState, Toggleable, Window, div, h_flex,
-    relative, rems, v_flex,
-};
-use util::ResultExt;
-use workspace::{ModalView, Workspace, pane};
-
-use crate::{attach_modal::AttachModal, debugger_panel::DebugPanel};
-
-pub(super) struct NewSessionModal {
-    workspace: WeakEntity<Workspace>,
-    debug_panel: WeakEntity<DebugPanel>,
-    mode: NewSessionMode,
-    launch_picker: Entity<Picker<DebugScenarioDelegate>>,
-    attach_mode: Entity<AttachMode>,
-    custom_mode: Entity<CustomMode>,
-    debugger: Option<DebugAdapterName>,
-    task_contexts: Arc<TaskContexts>,
-    _subscriptions: [Subscription; 2],
-}
-
-fn suggested_label(request: &DebugRequest, debugger: &str) -> SharedString {
-    match request {
-        DebugRequest::Launch(config) => {
-            let last_path_component = Path::new(&config.program)
-                .file_name()
-                .map(|name| name.to_string_lossy())
-                .unwrap_or_else(|| Cow::Borrowed(&config.program));
-
-            format!("{} ({debugger})", last_path_component).into()
-        }
-        DebugRequest::Attach(config) => format!(
-            "pid: {} ({debugger})",
-            config.process_id.unwrap_or(u32::MAX)
-        )
-        .into(),
-    }
-}
-
-impl NewSessionModal {
-    pub(super) fn show(
-        workspace: &mut Workspace,
-        window: &mut Window,
-        cx: &mut Context<Workspace>,
-    ) {
-        let Some(debug_panel) = workspace.panel::<DebugPanel>(cx) else {
-            return;
-        };
-        let task_store = workspace.project().read(cx).task_store().clone();
-
-        cx.spawn_in(window, async move |workspace, cx| {
-            let task_contexts = Arc::from(
-                workspace
-                    .update_in(cx, |workspace, window, cx| {
-                        tasks_ui::task_contexts(workspace, window, cx)
-                    })?
-                    .await,
-            );
-
-            workspace.update_in(cx, |workspace, window, cx| {
-                let workspace_handle = workspace.weak_handle();
-                workspace.toggle_modal(window, cx, |window, cx| {
-                    let attach_mode = AttachMode::new(None, workspace_handle.clone(), window, cx);
-
-                    let launch_picker = cx.new(|cx| {
-                        Picker::uniform_list(
-                            DebugScenarioDelegate::new(
-                                debug_panel.downgrade(),
-                                workspace_handle.clone(),
-                                task_store,
-                                task_contexts.clone(),
-                            ),
-                            window,
-                            cx,
-                        )
-                        .modal(false)
-                    });
-
-                    let _subscriptions = [
-                        cx.subscribe(&launch_picker, |_, _, _, cx| {
-                            cx.emit(DismissEvent);
-                        }),
-                        cx.subscribe(
-                            &attach_mode.read(cx).attach_picker.clone(),
-                            |_, _, _, cx| {
-                                cx.emit(DismissEvent);
-                            },
-                        ),
-                    ];
-
-                    let custom_mode = CustomMode::new(None, window, cx);
-
-                    Self {
-                        launch_picker,
-                        attach_mode,
-                        custom_mode,
-                        debugger: None,
-                        mode: NewSessionMode::Launch,
-                        debug_panel: debug_panel.downgrade(),
-                        workspace: workspace_handle,
-                        task_contexts,
-                        _subscriptions,
-                    }
-                });
-            })?;
-
-            anyhow::Ok(())
-        })
-        .detach();
-    }
-
-    fn render_mode(&self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
-        let dap_menu = self.adapter_drop_down_menu(window, cx);
-        match self.mode {
-            NewSessionMode::Attach => self.attach_mode.update(cx, |this, cx| {
-                this.clone().render(window, cx).into_any_element()
-            }),
-            NewSessionMode::Custom => self.custom_mode.update(cx, |this, cx| {
-                this.clone().render(dap_menu, window, cx).into_any_element()
-            }),
-            NewSessionMode::Launch => v_flex()
-                .w(rems(34.))
-                .child(self.launch_picker.clone())
-                .into_any_element(),
-        }
-    }
-
-    fn mode_focus_handle(&self, cx: &App) -> FocusHandle {
-        match self.mode {
-            NewSessionMode::Attach => self.attach_mode.read(cx).attach_picker.focus_handle(cx),
-            NewSessionMode::Custom => self.custom_mode.read(cx).program.focus_handle(cx),
-            NewSessionMode::Launch => self.launch_picker.focus_handle(cx),
-        }
-    }
-
-    fn debug_scenario(&self, debugger: &str, cx: &App) -> Option<DebugScenario> {
-        let request = match self.mode {
-            NewSessionMode::Custom => Some(DebugRequest::Launch(
-                self.custom_mode.read(cx).debug_request(cx),
-            )),
-            NewSessionMode::Attach => Some(DebugRequest::Attach(
-                self.attach_mode.read(cx).debug_request(),
-            )),
-            _ => None,
-        }?;
-        let label = suggested_label(&request, debugger);
-
-        let stop_on_entry = if let NewSessionMode::Custom = &self.mode {
-            Some(self.custom_mode.read(cx).stop_on_entry.selected())
-        } else {
-            None
-        };
-
-        Some(DebugScenario {
-            adapter: debugger.to_owned().into(),
-            label,
-            request: Some(request),
-            initialize_args: None,
-            tcp_connection: None,
-            stop_on_entry,
-            build: None,
-        })
-    }
-
-    fn start_new_session(&self, window: &mut Window, cx: &mut Context<Self>) {
-        let Some(debugger) = self.debugger.as_ref() else {
-            // todo(debugger): show in UI.
-            log::error!("No debugger selected");
-            return;
-        };
-
-        if let NewSessionMode::Launch = &self.mode {
-            self.launch_picker.update(cx, |picker, cx| {
-                picker.delegate.confirm(false, window, cx);
-            });
-            return;
-        }
-
-        let Some(config) = self.debug_scenario(debugger, cx) else {
-            log::error!("debug config not found in mode: {}", self.mode);
-            return;
-        };
-
-        let debug_panel = self.debug_panel.clone();
-        let task_contexts = self.task_contexts.clone();
-        cx.spawn_in(window, async move |this, cx| {
-            let task_context = task_contexts.active_context().cloned().unwrap_or_default();
-            let worktree_id = task_contexts.worktree();
-            debug_panel.update_in(cx, |debug_panel, window, cx| {
-                debug_panel.start_session(config, task_context, None, worktree_id, window, cx)
-            })?;
-            this.update(cx, |_, cx| {
-                cx.emit(DismissEvent);
-            })
-            .ok();
-            anyhow::Result::<_, anyhow::Error>::Ok(())
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn update_attach_picker(
-        attach: &Entity<AttachMode>,
-        adapter: &DebugAdapterName,
-        window: &mut Window,
-        cx: &mut App,
-    ) {
-        attach.update(cx, |this, cx| {
-            if adapter != &this.definition.adapter {
-                this.definition.adapter = adapter.clone();
-
-                this.attach_picker.update(cx, |this, cx| {
-                    this.picker.update(cx, |this, cx| {
-                        this.delegate.definition.adapter = adapter.clone();
-                        this.focus(window, cx);
-                    })
-                });
-            }
-
-            cx.notify();
-        })
-    }
-    fn adapter_drop_down_menu(
-        &self,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> ui::DropdownMenu {
-        let workspace = self.workspace.clone();
-        let weak = cx.weak_entity();
-        let label = self
-            .debugger
-            .as_ref()
-            .map(|d| d.0.clone())
-            .unwrap_or_else(|| SELECT_DEBUGGER_LABEL.clone());
-        let active_buffer_language = self
-            .task_contexts
-            .active_item_context
-            .as_ref()
-            .and_then(|item| {
-                item.1
-                    .as_ref()
-                    .and_then(|location| location.buffer.read(cx).language())
-            })
-            .cloned();
-
-        DropdownMenu::new(
-            "dap-adapter-picker",
-            label,
-            ContextMenu::build(window, cx, move |mut menu, _, cx| {
-                let setter_for_name = |name: DebugAdapterName| {
-                    let weak = weak.clone();
-                    move |window: &mut Window, cx: &mut App| {
-                        weak.update(cx, |this, cx| {
-                            this.debugger = Some(name.clone());
-                            cx.notify();
-                            if let NewSessionMode::Attach = &this.mode {
-                                Self::update_attach_picker(&this.attach_mode, &name, window, cx);
-                            }
-                        })
-                        .ok();
-                    }
-                };
-
-                let mut available_adapters = workspace
-                    .update(cx, |_, cx| DapRegistry::global(cx).enumerate_adapters())
-                    .unwrap_or_default();
-                if let Some(language) = active_buffer_language {
-                    available_adapters.sort_by_key(|adapter| {
-                        language
-                            .config()
-                            .debuggers
-                            .get_index_of(adapter.0.as_ref())
-                            .unwrap_or(usize::MAX)
-                    });
-                }
-
-                for adapter in available_adapters.into_iter() {
-                    menu = menu.entry(adapter.0.clone(), None, setter_for_name(adapter.clone()));
-                }
-                menu
-            }),
-        )
-    }
-}
-
-static SELECT_DEBUGGER_LABEL: SharedString = SharedString::new_static("Select Debugger");
-
-#[derive(Clone)]
-enum NewSessionMode {
-    Custom,
-    Attach,
-    Launch,
-}
-
-impl std::fmt::Display for NewSessionMode {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        let mode = match self {
-            NewSessionMode::Launch => "Launch".to_owned(),
-            NewSessionMode::Attach => "Attach".to_owned(),
-            NewSessionMode::Custom => "Custom".to_owned(),
-        };
-
-        write!(f, "{}", mode)
-    }
-}
-
-impl Focusable for NewSessionMode {
-    fn focus_handle(&self, cx: &App) -> FocusHandle {
-        cx.focus_handle()
-    }
-}
-
-fn render_editor(editor: &Entity<Editor>, window: &mut Window, cx: &App) -> impl IntoElement {
-    let settings = ThemeSettings::get_global(cx);
-    let theme = cx.theme();
-
-    let text_style = TextStyle {
-        color: cx.theme().colors().text,
-        font_family: settings.buffer_font.family.clone(),
-        font_features: settings.buffer_font.features.clone(),
-        font_size: settings.buffer_font_size(cx).into(),
-        font_weight: settings.buffer_font.weight,
-        line_height: relative(settings.buffer_line_height.value()),
-        background_color: Some(theme.colors().editor_background),
-        ..Default::default()
-    };
-
-    let element = EditorElement::new(
-        editor,
-        EditorStyle {
-            background: theme.colors().editor_background,
-            local_player: theme.players().local(),
-            text: text_style,
-            ..Default::default()
-        },
-    );
-
-    div()
-        .rounded_md()
-        .p_1()
-        .border_1()
-        .border_color(theme.colors().border_variant)
-        .when(
-            editor.focus_handle(cx).contains_focused(window, cx),
-            |this| this.border_color(theme.colors().border_focused),
-        )
-        .child(element)
-        .bg(theme.colors().editor_background)
-}
-
-impl Render for NewSessionModal {
-    fn render(
-        &mut self,
-        window: &mut ui::Window,
-        cx: &mut ui::Context<Self>,
-    ) -> impl ui::IntoElement {
-        v_flex()
-            .size_full()
-            .w(rems(34.))
-            .key_context("Pane")
-            .elevation_3(cx)
-            .bg(cx.theme().colors().elevated_surface_background)
-            .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
-                cx.emit(DismissEvent);
-            }))
-            .on_action(
-                cx.listener(|this, _: &pane::ActivatePreviousItem, window, cx| {
-                    this.mode = match this.mode {
-                        NewSessionMode::Attach => NewSessionMode::Launch,
-                        NewSessionMode::Launch => NewSessionMode::Attach,
-                        _ => {
-                            return;
-                        }
-                    };
-
-                    this.mode_focus_handle(cx).focus(window);
-                }),
-            )
-            .on_action(cx.listener(|this, _: &pane::ActivateNextItem, window, cx| {
-                this.mode = match this.mode {
-                    NewSessionMode::Attach => NewSessionMode::Launch,
-                    NewSessionMode::Launch => NewSessionMode::Attach,
-                    _ => {
-                        return;
-                    }
-                };
-
-                this.mode_focus_handle(cx).focus(window);
-            }))
-            .child(
-                h_flex()
-                    .w_full()
-                    .justify_around()
-                    .p_2()
-                    .child(
-                        h_flex()
-                            .justify_start()
-                            .w_full()
-                            .child(
-                                ToggleButton::new("debugger-session-ui-picker-button", "Launch")
-                                    .size(ButtonSize::Default)
-                                    .style(ui::ButtonStyle::Subtle)
-                                    .toggle_state(matches!(self.mode, NewSessionMode::Launch))
-                                    .on_click(cx.listener(|this, _, window, cx| {
-                                        this.mode = NewSessionMode::Launch;
-                                        this.mode_focus_handle(cx).focus(window);
-                                        cx.notify();
-                                    }))
-                                    .first(),
-                            )
-                            .child(
-                                ToggleButton::new("debugger-session-ui-attach-button", "Attach")
-                                    .size(ButtonSize::Default)
-                                    .toggle_state(matches!(self.mode, NewSessionMode::Attach))
-                                    .style(ui::ButtonStyle::Subtle)
-                                    .on_click(cx.listener(|this, _, window, cx| {
-                                        this.mode = NewSessionMode::Attach;
-
-                                        if let Some(debugger) = this.debugger.as_ref() {
-                                            Self::update_attach_picker(
-                                                &this.attach_mode,
-                                                &debugger,
-                                                window,
-                                                cx,
-                                            );
-                                        }
-                                        this.mode_focus_handle(cx).focus(window);
-                                        cx.notify();
-                                    }))
-                                    .last(),
-                            ),
-                    )
-                    .justify_between()
-                    .border_color(cx.theme().colors().border_variant)
-                    .border_b_1(),
-            )
-            .child(v_flex().child(self.render_mode(window, cx)))
-            .child(
-                h_flex()
-                    .justify_between()
-                    .gap_2()
-                    .p_2()
-                    .border_color(cx.theme().colors().border_variant)
-                    .border_t_1()
-                    .w_full()
-                    .child(match self.mode {
-                        NewSessionMode::Attach => {
-                            div().child(self.adapter_drop_down_menu(window, cx))
-                        }
-                        NewSessionMode::Launch => div().child(
-                            Button::new("new-session-modal-custom", "Custom").on_click({
-                                let this = cx.weak_entity();
-                                move |_, window, cx| {
-                                    this.update(cx, |this, cx| {
-                                        this.mode = NewSessionMode::Custom;
-                                        this.mode_focus_handle(cx).focus(window);
-                                    })
-                                    .ok();
-                                }
-                            }),
-                        ),
-                        NewSessionMode::Custom => div().child(
-                            Button::new("new-session-modal-back", "Save to .zed/debug.json...")
-                                .on_click(cx.listener(|this, _, window, cx| {
-                                    let Some(save_scenario_task) = this
-                                        .debugger
-                                        .as_ref()
-                                        .and_then(|debugger| this.debug_scenario(&debugger, cx))
-                                        .zip(this.task_contexts.worktree())
-                                        .and_then(|(scenario, worktree_id)| {
-                                            this.debug_panel
-                                                .update(cx, |panel, cx| {
-                                                    panel.save_scenario(
-                                                        &scenario,
-                                                        worktree_id,
-                                                        window,
-                                                        cx,
-                                                    )
-                                                })
-                                                .ok()
-                                        })
-                                    else {
-                                        return;
-                                    };
-
-                                    cx.spawn(async move |this, cx| {
-                                        if save_scenario_task.await.is_ok() {
-                                            this.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
-                                        }
-                                    })
-                                    .detach();
-                                }))
-                                .disabled(
-                                    self.debugger.is_none()
-                                        || self.custom_mode.read(cx).program.read(cx).is_empty(cx),
-                                ),
-                        ),
-                    })
-                    .child(
-                        Button::new("debugger-spawn", "Start")
-                            .on_click(cx.listener(|this, _, window, cx| match &this.mode {
-                                NewSessionMode::Launch => {
-                                    this.launch_picker.update(cx, |picker, cx| {
-                                        picker.delegate.confirm(true, window, cx)
-                                    })
-                                }
-                                _ => this.start_new_session(window, cx),
-                            }))
-                            .disabled(match self.mode {
-                                NewSessionMode::Launch => {
-                                    !self.launch_picker.read(cx).delegate.matches.is_empty()
-                                }
-                                NewSessionMode::Attach => {
-                                    self.debugger.is_none()
-                                        || self
-                                            .attach_mode
-                                            .read(cx)
-                                            .attach_picker
-                                            .read(cx)
-                                            .picker
-                                            .read(cx)
-                                            .delegate
-                                            .match_count()
-                                            == 0
-                                }
-                                NewSessionMode::Custom => {
-                                    self.debugger.is_none()
-                                        || self.custom_mode.read(cx).program.read(cx).is_empty(cx)
-                                }
-                            }),
-                    ),
-            )
-    }
-}
-
-impl EventEmitter<DismissEvent> for NewSessionModal {}
-impl Focusable for NewSessionModal {
-    fn focus_handle(&self, cx: &ui::App) -> gpui::FocusHandle {
-        self.mode_focus_handle(cx)
-    }
-}
-
-impl ModalView for NewSessionModal {}
-
-impl RenderOnce for AttachMode {
-    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
-        v_flex()
-            .w_full()
-            .track_focus(&self.attach_picker.focus_handle(cx))
-            .child(self.attach_picker.clone())
-    }
-}
-
-#[derive(Clone)]
-pub(super) struct CustomMode {
-    program: Entity<Editor>,
-    cwd: Entity<Editor>,
-    stop_on_entry: ToggleState,
-}
-
-impl CustomMode {
-    pub(super) fn new(
-        past_launch_config: Option<LaunchRequest>,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> Entity<Self> {
-        let (past_program, past_cwd) = past_launch_config
-            .map(|config| (Some(config.program), config.cwd))
-            .unwrap_or_else(|| (None, None));
-
-        let program = cx.new(|cx| Editor::single_line(window, cx));
-        program.update(cx, |this, cx| {
-            this.set_placeholder_text("Program path", cx);
-
-            if let Some(past_program) = past_program {
-                this.set_text(past_program, window, cx);
-            };
-        });
-        let cwd = cx.new(|cx| Editor::single_line(window, cx));
-        cwd.update(cx, |this, cx| {
-            this.set_placeholder_text("Working Directory", cx);
-            if let Some(past_cwd) = past_cwd {
-                this.set_text(past_cwd.to_string_lossy(), window, cx);
-            };
-        });
-        cx.new(|_| Self {
-            program,
-            cwd,
-            stop_on_entry: ToggleState::Unselected,
-        })
-    }
-
-    pub(super) fn debug_request(&self, cx: &App) -> task::LaunchRequest {
-        let path = self.cwd.read(cx).text(cx);
-        task::LaunchRequest {
-            program: self.program.read(cx).text(cx),
-            cwd: path.is_empty().not().then(|| PathBuf::from(path)),
-            args: Default::default(),
-            env: Default::default(),
-        }
-    }
-
-    fn render(
-        &mut self,
-        adapter_menu: DropdownMenu,
-        window: &mut Window,
-        cx: &mut ui::Context<Self>,
-    ) -> impl IntoElement {
-        v_flex()
-            .p_2()
-            .w_full()
-            .gap_3()
-            .track_focus(&self.program.focus_handle(cx))
-            .child(
-                div().child(
-                    Label::new("Program")
-                        .size(ui::LabelSize::Small)
-                        .color(Color::Muted),
-                ),
-            )
-            .child(render_editor(&self.program, window, cx))
-            .child(
-                h_flex()
-                    .child(
-                        Label::new("Debugger")
-                            .size(ui::LabelSize::Small)
-                            .color(Color::Muted),
-                    )
-                    .gap(ui::DynamicSpacing::Base08.rems(cx))
-                    .child(adapter_menu),
-            )
-            .child(
-                CheckboxWithLabel::new(
-                    "debugger-stop-on-entry",
-                    Label::new("Stop on Entry").size(ui::LabelSize::Small),
-                    self.stop_on_entry,
-                    {
-                        let this = cx.weak_entity();
-                        move |state, _, cx| {
-                            this.update(cx, |this, _| {
-                                this.stop_on_entry = *state;
-                            })
-                            .ok();
-                        }
-                    },
-                )
-                .checkbox_position(ui::IconPosition::End),
-            )
-    }
-}
-
-#[derive(Clone)]
-pub(super) struct AttachMode {
-    pub(super) definition: DebugTaskDefinition,
-    pub(super) attach_picker: Entity<AttachModal>,
-}
-
-impl AttachMode {
-    pub(super) fn new(
-        debugger: Option<DebugAdapterName>,
-        workspace: WeakEntity<Workspace>,
-        window: &mut Window,
-        cx: &mut Context<NewSessionModal>,
-    ) -> Entity<Self> {
-        let definition = DebugTaskDefinition {
-            adapter: debugger.unwrap_or(DebugAdapterName("".into())),
-            label: "Attach New Session Setup".into(),
-            request: dap::DebugRequest::Attach(task::AttachRequest { process_id: None }),
-            initialize_args: None,
-            tcp_connection: None,
-            stop_on_entry: Some(false),
-        };
-        let attach_picker = cx.new(|cx| {
-            let modal = AttachModal::new(definition.clone(), workspace, false, window, cx);
-            window.focus(&modal.focus_handle(cx));
-
-            modal
-        });
-
-        cx.new(|_| Self {
-            definition,
-            attach_picker,
-        })
-    }
-    pub(super) fn debug_request(&self) -> task::AttachRequest {
-        task::AttachRequest { process_id: None }
-    }
-}
-
-pub(super) struct DebugScenarioDelegate {
-    task_store: Entity<TaskStore>,
-    candidates: Option<Vec<(TaskSourceKind, DebugScenario)>>,
-    selected_index: usize,
-    matches: Vec<StringMatch>,
-    prompt: String,
-    debug_panel: WeakEntity<DebugPanel>,
-    workspace: WeakEntity<Workspace>,
-    task_contexts: Arc<TaskContexts>,
-}
-
-impl DebugScenarioDelegate {
-    pub(super) fn new(
-        debug_panel: WeakEntity<DebugPanel>,
-        workspace: WeakEntity<Workspace>,
-        task_store: Entity<TaskStore>,
-        task_contexts: Arc<TaskContexts>,
-    ) -> Self {
-        Self {
-            task_store,
-            candidates: None,
-            selected_index: 0,
-            matches: Vec::new(),
-            prompt: String::new(),
-            debug_panel,
-            workspace,
-            task_contexts,
-        }
-    }
-}
-
-impl PickerDelegate for DebugScenarioDelegate {
-    type ListItem = ui::ListItem;
-
-    fn match_count(&self) -> usize {
-        self.matches.len()
-    }
-
-    fn selected_index(&self) -> usize {
-        self.selected_index
-    }
-
-    fn set_selected_index(
-        &mut self,
-        ix: usize,
-        _window: &mut Window,
-        _cx: &mut Context<picker::Picker<Self>>,
-    ) {
-        self.selected_index = ix;
-    }
-
-    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> std::sync::Arc<str> {
-        "".into()
-    }
-
-    fn update_matches(
-        &mut self,
-        query: String,
-        window: &mut Window,
-        cx: &mut Context<picker::Picker<Self>>,
-    ) -> gpui::Task<()> {
-        let candidates = self.candidates.clone();
-        let workspace = self.workspace.clone();
-        let task_store = self.task_store.clone();
-
-        cx.spawn_in(window, async move |picker, cx| {
-            let candidates: Vec<_> = match &candidates {
-                Some(candidates) => candidates
-                    .into_iter()
-                    .enumerate()
-                    .map(|(index, (_, candidate))| {
-                        StringMatchCandidate::new(index, candidate.label.as_ref())
-                    })
-                    .collect(),
-                None => {
-                    let worktree_ids: Vec<_> = workspace
-                        .update(cx, |this, cx| {
-                            this.visible_worktrees(cx)
-                                .map(|tree| tree.read(cx).id())
-                                .collect()
-                        })
-                        .ok()
-                        .unwrap_or_default();
-
-                    let scenarios: Vec<_> = task_store
-                        .update(cx, |task_store, cx| {
-                            task_store.task_inventory().map(|item| {
-                                item.read(cx).list_debug_scenarios(worktree_ids.into_iter())
-                            })
-                        })
-                        .ok()
-                        .flatten()
-                        .unwrap_or_default();
-
-                    picker
-                        .update(cx, |picker, _| {
-                            picker.delegate.candidates = Some(scenarios.clone());
-                        })
-                        .ok();
-
-                    scenarios
-                        .into_iter()
-                        .enumerate()
-                        .map(|(index, (_, candidate))| {
-                            StringMatchCandidate::new(index, candidate.label.as_ref())
-                        })
-                        .collect()
-                }
-            };
-
-            let matches = fuzzy::match_strings(
-                &candidates,
-                &query,
-                true,
-                1000,
-                &Default::default(),
-                cx.background_executor().clone(),
-            )
-            .await;
-
-            picker
-                .update(cx, |picker, _| {
-                    let delegate = &mut picker.delegate;
-
-                    delegate.matches = matches;
-                    delegate.prompt = query;
-
-                    if delegate.matches.is_empty() {
-                        delegate.selected_index = 0;
-                    } else {
-                        delegate.selected_index =
-                            delegate.selected_index.min(delegate.matches.len() - 1);
-                    }
-                })
-                .log_err();
-        })
-    }
-
-    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
-        let debug_scenario = self
-            .matches
-            .get(self.selected_index())
-            .and_then(|match_candidate| {
-                self.candidates
-                    .as_ref()
-                    .map(|candidates| candidates[match_candidate.candidate_id].clone())
-            });
-
-        let Some((task_source_kind, debug_scenario)) = debug_scenario else {
-            return;
-        };
-
-        let (task_context, worktree_id) = if let TaskSourceKind::Worktree {
-            id: worktree_id,
-            directory_in_worktree: _,
-            id_base: _,
-        } = task_source_kind
-        {
-            self.task_contexts
-                .task_context_for_worktree_id(worktree_id)
-                .cloned()
-                .map(|context| (context, Some(worktree_id)))
-        } else {
-            None
-        }
-        .unwrap_or_default();
-
-        self.debug_panel
-            .update(cx, |panel, cx| {
-                panel.start_session(debug_scenario, task_context, None, worktree_id, window, cx);
-            })
-            .ok();
-
-        cx.emit(DismissEvent);
-    }
-
-    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
-        cx.emit(DismissEvent);
-    }
-
-    fn render_match(
-        &self,
-        ix: usize,
-        selected: bool,
-        window: &mut Window,
-        cx: &mut Context<picker::Picker<Self>>,
-    ) -> Option<Self::ListItem> {
-        let hit = &self.matches[ix];
-
-        let highlighted_location = HighlightedMatch {
-            text: hit.string.clone(),
-            highlight_positions: hit.positions.clone(),
-            char_count: hit.string.chars().count(),
-            color: Color::Default,
-        };
-
-        let icon = Icon::new(IconName::FileTree)
-            .color(Color::Muted)
-            .size(ui::IconSize::Small);
-
-        Some(
-            ListItem::new(SharedString::from(format!("debug-scenario-selection-{ix}")))
-                .inset(true)
-                .start_slot::<Icon>(icon)
-                .spacing(ListItemSpacing::Sparse)
-                .toggle_state(selected)
-                .child(highlighted_location.render(window, cx)),
-        )
-    }
-}

crates/debugger_ui/src/onboarding_modal.rs 🔗

@@ -0,0 +1,166 @@
+use gpui::{
+    ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render,
+};
+use ui::{TintColor, Vector, VectorName, prelude::*};
+use workspace::{ModalView, Workspace};
+
+use crate::DebugPanel;
+
+macro_rules! debugger_onboarding_event {
+    ($name:expr) => {
+        telemetry::event!($name, source = "Debugger Onboarding");
+    };
+    ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => {
+        telemetry::event!($name, source = "Debugger Onboarding", $($key $(= $value)?),+);
+    };
+}
+
+pub struct DebuggerOnboardingModal {
+    focus_handle: FocusHandle,
+    workspace: Entity<Workspace>,
+}
+
+impl DebuggerOnboardingModal {
+    pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
+        let workspace_entity = cx.entity();
+        workspace.toggle_modal(window, cx, |_window, cx| Self {
+            workspace: workspace_entity,
+            focus_handle: cx.focus_handle(),
+        });
+    }
+
+    fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
+        self.workspace.update(cx, |workspace, cx| {
+            workspace.focus_panel::<DebugPanel>(window, cx);
+        });
+
+        cx.emit(DismissEvent);
+
+        debugger_onboarding_event!("Open Panel Clicked");
+    }
+
+    fn view_blog(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
+        cx.open_url("http://zed.dev/blog/debugger");
+        cx.notify();
+
+        debugger_onboarding_event!("Blog Link Clicked");
+    }
+
+    fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+        cx.emit(DismissEvent);
+    }
+}
+
+impl EventEmitter<DismissEvent> for DebuggerOnboardingModal {}
+
+impl Focusable for DebuggerOnboardingModal {
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl ModalView for DebuggerOnboardingModal {}
+
+impl Render for DebuggerOnboardingModal {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let window_height = window.viewport_size().height;
+        let max_height = window_height - px(200.);
+
+        let base = v_flex()
+            .id("debugger-onboarding")
+            .key_context("DebuggerOnboardingModal")
+            .relative()
+            .w(px(450.))
+            .h_full()
+            .max_h(max_height)
+            .p_4()
+            .gap_2()
+            .elevation_3(cx)
+            .track_focus(&self.focus_handle(cx))
+            .overflow_hidden()
+            .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
+                debugger_onboarding_event!("Canceled", trigger = "Action");
+                cx.emit(DismissEvent);
+            }))
+            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
+                this.focus_handle.focus(window);
+            }))
+            .child(
+                div()
+                    .absolute()
+                    .top(px(-8.0))
+                    .right_0()
+                    .w(px(400.))
+                    .h(px(92.))
+                    .child(
+                        Vector::new(
+                            VectorName::DebuggerGrid,
+                            rems_from_px(400.),
+                            rems_from_px(92.),
+                        )
+                        .color(ui::Color::Custom(cx.theme().colors().text.alpha(0.32))),
+                    ),
+            )
+            .child(
+                div()
+                    .absolute()
+                    .inset_0()
+                    .size_full()
+                    .bg(gpui::linear_gradient(
+                        175.,
+                        gpui::linear_color_stop(
+                            cx.theme().colors().elevated_surface_background,
+                            0.,
+                        ),
+                        gpui::linear_color_stop(
+                            cx.theme().colors().elevated_surface_background.opacity(0.),
+                            0.8,
+                        ),
+                    )),
+            )
+            .child(
+                v_flex()
+                    .w_full()
+                    .gap_1()
+                    .child(
+                        Label::new("Introducing")
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    )
+                    .child(Headline::new("Zed's Debugger").size(HeadlineSize::Large)),
+            )
+            .child(h_flex().absolute().top_2().right_2().child(
+                IconButton::new("cancel", IconName::X).on_click(cx.listener(
+                    |_, _: &ClickEvent, _window, cx| {
+                        debugger_onboarding_event!("Cancelled", trigger = "X click");
+                        cx.emit(DismissEvent);
+                    },
+                )),
+            ));
+
+        let open_panel_button = Button::new("open-panel", "Get Started with the Debugger")
+            .icon_size(IconSize::Indicator)
+            .style(ButtonStyle::Tinted(TintColor::Accent))
+            .full_width()
+            .on_click(cx.listener(Self::open_panel));
+
+        let blog_post_button = Button::new("view-blog", "Check out the Blog Post")
+            .icon(IconName::ArrowUpRight)
+            .icon_size(IconSize::Indicator)
+            .icon_color(Color::Muted)
+            .full_width()
+            .on_click(cx.listener(Self::view_blog));
+
+        let copy = "It's finally here: Native support for debugging across multiple programming languages.";
+
+        base.child(Label::new(copy).color(Color::Muted)).child(
+            v_flex()
+                .w_full()
+                .mt_2()
+                .gap_2()
+                .child(open_panel_button)
+                .child(blog_post_button),
+        )
+    }
+}

crates/debugger_ui/src/persistence.rs 🔗

@@ -1,3 +1,4 @@
+use anyhow::Context as _;
 use collections::HashMap;
 use dap::{Capabilities, adapters::DebugAdapterName};
 use db::kvp::KEY_VALUE_STORE;
@@ -60,6 +61,28 @@ impl DebuggerPaneItem {
             DebuggerPaneItem::Terminal => SharedString::new_static("Terminal"),
         }
     }
+    pub(crate) fn tab_tooltip(self) -> SharedString {
+        let tooltip = match self {
+            DebuggerPaneItem::Console => {
+                "Displays program output and allows manual input of debugger commands."
+            }
+            DebuggerPaneItem::Variables => {
+                "Shows current values of local and global variables in the current stack frame."
+            }
+            DebuggerPaneItem::BreakpointList => "Lists all active breakpoints set in the code.",
+            DebuggerPaneItem::Frames => {
+                "Displays the call stack, letting you navigate between function calls."
+            }
+            DebuggerPaneItem::Modules => "Shows all modules or libraries loaded by the program.",
+            DebuggerPaneItem::LoadedSources => {
+                "Lists all source files currently loaded and used by the debugger."
+            }
+            DebuggerPaneItem::Terminal => {
+                "Provides an interactive terminal session within the debugging environment."
+            }
+        };
+        SharedString::new_static(tooltip)
+    }
 }
 
 impl From<DebuggerPaneItem> for SharedString {
@@ -96,18 +119,14 @@ pub(crate) async fn serialize_pane_layout(
     adapter_name: DebugAdapterName,
     pane_group: SerializedLayout,
 ) -> anyhow::Result<()> {
-    if let Ok(serialized_pane_group) = serde_json::to_string(&pane_group) {
-        KEY_VALUE_STORE
-            .write_kvp(
-                format!("{DEBUGGER_PANEL_PREFIX}-{adapter_name}"),
-                serialized_pane_group,
-            )
-            .await
-    } else {
-        Err(anyhow::anyhow!(
-            "Failed to serialize pane group with serde_json as a string"
-        ))
-    }
+    let serialized_pane_group = serde_json::to_string(&pane_group)
+        .context("Serializing pane group with serde_json as a string")?;
+    KEY_VALUE_STORE
+        .write_kvp(
+            format!("{DEBUGGER_PANEL_PREFIX}-{adapter_name}"),
+            serialized_pane_group,
+        )
+        .await
 }
 
 pub(crate) fn build_serialized_layout(
@@ -246,56 +265,37 @@ pub(crate) fn deserialize_pane_layout(
                         stack_frame_list.focus_handle(cx),
                         stack_frame_list.clone().into(),
                         DebuggerPaneItem::Frames,
-                        None,
                         cx,
                     )),
                     DebuggerPaneItem::Variables => Box::new(SubView::new(
                         variable_list.focus_handle(cx),
                         variable_list.clone().into(),
                         DebuggerPaneItem::Variables,
-                        None,
-                        cx,
-                    )),
-                    DebuggerPaneItem::BreakpointList => Box::new(SubView::new(
-                        breakpoint_list.focus_handle(cx),
-                        breakpoint_list.clone().into(),
-                        DebuggerPaneItem::BreakpointList,
-                        None,
                         cx,
                     )),
+                    DebuggerPaneItem::BreakpointList => {
+                        Box::new(SubView::breakpoint_list(breakpoint_list.clone(), cx))
+                    }
                     DebuggerPaneItem::Modules => Box::new(SubView::new(
                         module_list.focus_handle(cx),
                         module_list.clone().into(),
                         DebuggerPaneItem::Modules,
-                        None,
                         cx,
                     )),
                     DebuggerPaneItem::LoadedSources => Box::new(SubView::new(
                         loaded_sources.focus_handle(cx),
                         loaded_sources.clone().into(),
                         DebuggerPaneItem::LoadedSources,
-                        None,
-                        cx,
-                    )),
-                    DebuggerPaneItem::Console => Box::new(SubView::new(
-                        pane.focus_handle(cx),
-                        console.clone().into(),
-                        DebuggerPaneItem::Console,
-                        Some(Box::new({
-                            let console = console.clone().downgrade();
-                            move |cx| {
-                                console
-                                    .read_with(cx, |console, cx| console.show_indicator(cx))
-                                    .unwrap_or_default()
-                            }
-                        })),
                         cx,
                     )),
+                    DebuggerPaneItem::Console => {
+                        let view = SubView::console(console.clone(), cx);
+                        Box::new(view)
+                    }
                     DebuggerPaneItem::Terminal => Box::new(SubView::new(
-                        pane.focus_handle(cx),
+                        terminal.focus_handle(cx),
                         terminal.clone().into(),
                         DebuggerPaneItem::Terminal,
-                        None,
                         cx,
                     )),
                 })

crates/debugger_ui/src/session.rs 🔗

@@ -1,7 +1,6 @@
 pub mod running;
 
-use std::sync::OnceLock;
-
+use crate::{StackTraceView, persistence::SerializedLayout, session::running::DebugTerminal};
 use dap::client::SessionId;
 use gpui::{
     App, Axis, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity,
@@ -11,21 +10,20 @@ use project::debugger::session::Session;
 use project::worktree_store::WorktreeStore;
 use rpc::proto;
 use running::RunningState;
-use ui::{Indicator, prelude::*};
+use std::{cell::OnceCell, sync::OnceLock};
+use ui::{Indicator, Tooltip, prelude::*};
 use workspace::{
     CollaboratorId, FollowableItem, ViewId, Workspace,
     item::{self, Item},
 };
 
-use crate::{debugger_panel::DebugPanel, persistence::SerializedLayout};
-
 pub struct DebugSession {
     remote_id: Option<workspace::ViewId>,
     running_state: Entity<RunningState>,
     label: OnceLock<SharedString>,
-    _debug_panel: WeakEntity<DebugPanel>,
+    stack_trace_view: OnceCell<Entity<StackTraceView>>,
     _worktree_store: WeakEntity<WorktreeStore>,
-    _workspace: WeakEntity<Workspace>,
+    workspace: WeakEntity<Workspace>,
     _subscriptions: [Subscription; 1],
 }
 
@@ -39,8 +37,8 @@ impl DebugSession {
     pub(crate) fn running(
         project: Entity<Project>,
         workspace: WeakEntity<Workspace>,
+        parent_terminal: Option<Entity<DebugTerminal>>,
         session: Entity<Session>,
-        _debug_panel: WeakEntity<DebugPanel>,
         serialized_layout: Option<SerializedLayout>,
         dock_axis: Axis,
         window: &mut Window,
@@ -51,6 +49,7 @@ impl DebugSession {
                 session.clone(),
                 project.clone(),
                 workspace.clone(),
+                parent_terminal,
                 serialized_layout,
                 dock_axis,
                 window,
@@ -65,9 +64,9 @@ impl DebugSession {
             remote_id: None,
             running_state,
             label: OnceLock::new(),
-            _debug_panel,
+            stack_trace_view: OnceCell::new(),
             _worktree_store: project.read(cx).worktree_store().downgrade(),
-            _workspace: workspace,
+            workspace,
         })
     }
 
@@ -75,6 +74,32 @@ impl DebugSession {
         self.running_state.read(cx).session_id()
     }
 
+    pub(crate) fn stack_trace_view(
+        &mut self,
+        project: &Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> &Entity<StackTraceView> {
+        let workspace = self.workspace.clone();
+        let running_state = self.running_state.clone();
+
+        self.stack_trace_view.get_or_init(|| {
+            let stackframe_list = running_state.read(cx).stack_frame_list().clone();
+
+            let stack_frame_view = cx.new(|cx| {
+                StackTraceView::new(
+                    workspace.clone(),
+                    project.clone(),
+                    stackframe_list,
+                    window,
+                    cx,
+                )
+            });
+
+            stack_frame_view
+        })
+    }
+
     pub fn session(&self, cx: &App) -> Entity<Session> {
         self.running_state.read(cx).session().clone()
     }
@@ -100,7 +125,7 @@ impl DebugSession {
         &self.running_state
     }
 
-    pub(crate) fn label_element(&self, cx: &App) -> AnyElement {
+    pub(crate) fn label_element(&self, depth: usize, cx: &App) -> AnyElement {
         let label = self.label(cx);
 
         let is_terminated = self
@@ -128,10 +153,17 @@ impl DebugSession {
         };
 
         h_flex()
+            .id("session-label")
+            .tooltip(Tooltip::text(format!("Session {}", self.session_id(cx).0,)))
+            .ml(depth * px(16.0))
             .gap_2()
             .when_some(icon, |this, indicator| this.child(indicator))
             .justify_between()
-            .child(Label::new(label).when(is_terminated, |this| this.strikethrough()))
+            .child(
+                Label::new(label)
+                    .size(LabelSize::Small)
+                    .when(is_terminated, |this| this.strikethrough()),
+            )
             .into_any_element()
     }
 }
@@ -166,7 +198,7 @@ impl FollowableItem for DebugSession {
         _state: &mut Option<proto::view::Variant>,
         _window: &mut Window,
         _cx: &mut App,
-    ) -> Option<gpui::Task<gpui::Result<Entity<Self>>>> {
+    ) -> Option<gpui::Task<anyhow::Result<Entity<Self>>>> {
         None
     }
 
@@ -188,7 +220,7 @@ impl FollowableItem for DebugSession {
         _message: proto::update_view::Variant,
         _window: &mut Window,
         _cx: &mut Context<Self>,
-    ) -> gpui::Task<gpui::Result<()>> {
+    ) -> gpui::Task<anyhow::Result<()>> {
         Task::ready(Ok(()))
     }
 

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

@@ -7,15 +7,19 @@ pub mod variable_list;
 
 use std::{any::Any, ops::ControlFlow, path::PathBuf, sync::Arc, time::Duration};
 
-use crate::persistence::{self, DebuggerPaneItem, SerializedLayout};
+use crate::{
+    ToggleExpandItem,
+    new_process_modal::resolve_path,
+    persistence::{self, DebuggerPaneItem, SerializedLayout},
+};
 
 use super::DebugPanelItemEvent;
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use breakpoint_list::BreakpointList;
 use collections::{HashMap, IndexMap};
 use console::Console;
 use dap::{
-    Capabilities, RunInTerminalRequestArguments, Thread,
+    Capabilities, DapRegistry, RunInTerminalRequestArguments, Thread,
     adapters::{DebugAdapterName, DebugTaskDefinition},
     client::SessionId,
     debugger_settings::DebuggerSettings,
@@ -38,16 +42,15 @@ use serde_json::Value;
 use settings::Settings;
 use stack_frame_list::StackFrameList;
 use task::{
-    BuildTaskDefinition, DebugScenario, LaunchRequest, ShellBuilder, SpawnInTerminal, TaskContext,
-    substitute_variables_in_map, substitute_variables_in_str,
+    BuildTaskDefinition, DebugScenario, ShellBuilder, SpawnInTerminal, TaskContext, ZedDebugConfig,
+    substitute_variables_in_str,
 };
 use terminal_view::TerminalView;
 use ui::{
-    ActiveTheme, AnyElement, App, ButtonCommon as _, Clickable as _, Context, ContextMenu,
-    Disableable, DropdownMenu, FluentBuilder, IconButton, IconName, IconSize, InteractiveElement,
-    IntoElement, Label, LabelCommon as _, ParentElement, Render, SharedString,
-    StatefulInteractiveElement, Styled, Tab, Tooltip, VisibleOnHover, VisualContext, Window, div,
-    h_flex, v_flex,
+    ActiveTheme, AnyElement, App, ButtonCommon as _, Clickable as _, Context, FluentBuilder,
+    IconButton, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon as _,
+    ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Tab, Tooltip,
+    VisibleOnHover, VisualContext, Window, div, h_flex, v_flex,
 };
 use util::ResultExt;
 use variable_list::VariableList;
@@ -72,12 +75,22 @@ pub struct RunningState {
     console: Entity<Console>,
     breakpoint_list: Entity<BreakpointList>,
     panes: PaneGroup,
-    active_pane: Option<Entity<Pane>>,
+    active_pane: Entity<Pane>,
     pane_close_subscriptions: HashMap<EntityId, Subscription>,
     dock_axis: Axis,
     _schedule_serialize: Option<Task<()>>,
 }
 
+impl RunningState {
+    pub(crate) fn thread_id(&self) -> Option<ThreadId> {
+        self.thread_id
+    }
+
+    pub(crate) fn active_pane(&self) -> &Entity<Pane> {
+        &self.active_pane
+    }
+}
+
 impl Render for RunningState {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let zoomed_pane = self
@@ -87,7 +100,7 @@ impl Render for RunningState {
             .find(|pane| pane.read(cx).is_zoomed());
 
         let active = self.panes.panes().into_iter().next();
-        let x = if let Some(ref zoomed_pane) = zoomed_pane {
+        let pane = if let Some(ref zoomed_pane) = zoomed_pane {
             zoomed_pane.update(cx, |pane, cx| pane.render(window, cx).into_any_element())
         } else if let Some(active) = active {
             self.panes
@@ -113,42 +126,90 @@ impl Render for RunningState {
             .size_full()
             .key_context("DebugSessionItem")
             .track_focus(&self.focus_handle(cx))
-            .child(h_flex().flex_1().child(x))
+            .child(h_flex().flex_1().child(pane))
     }
 }
 
 pub(crate) struct SubView {
     inner: AnyView,
-    pane_focus_handle: FocusHandle,
+    item_focus_handle: FocusHandle,
     kind: DebuggerPaneItem,
     show_indicator: Box<dyn Fn(&App) -> bool>,
+    actions: Option<Box<dyn FnMut(&mut Window, &mut App) -> AnyElement>>,
     hovered: bool,
 }
 
 impl SubView {
     pub(crate) fn new(
-        pane_focus_handle: FocusHandle,
+        item_focus_handle: FocusHandle,
         view: AnyView,
         kind: DebuggerPaneItem,
-        show_indicator: Option<Box<dyn Fn(&App) -> bool>>,
         cx: &mut App,
     ) -> Entity<Self> {
         cx.new(|_| Self {
             kind,
             inner: view,
-            pane_focus_handle,
-            show_indicator: show_indicator.unwrap_or(Box::new(|_| false)),
+            item_focus_handle,
+            show_indicator: Box::new(|_| false),
+            actions: None,
             hovered: false,
         })
     }
 
+    pub(crate) fn console(console: Entity<Console>, cx: &mut App) -> Entity<Self> {
+        let weak_console = console.downgrade();
+        let this = Self::new(
+            console.focus_handle(cx),
+            console.into(),
+            DebuggerPaneItem::Console,
+            cx,
+        );
+        this.update(cx, |this, _| {
+            this.with_indicator(Box::new(move |cx| {
+                weak_console
+                    .read_with(cx, |console, cx| console.show_indicator(cx))
+                    .unwrap_or_default()
+            }))
+        });
+        this
+    }
+
+    pub(crate) fn breakpoint_list(list: Entity<BreakpointList>, cx: &mut App) -> Entity<Self> {
+        let weak_list = list.downgrade();
+        let focus_handle = list.focus_handle(cx);
+        let this = Self::new(
+            focus_handle.clone(),
+            list.into(),
+            DebuggerPaneItem::BreakpointList,
+            cx,
+        );
+
+        this.update(cx, |this, _| {
+            this.with_actions(Box::new(move |_, cx| {
+                weak_list
+                    .update(cx, |this, _| this.render_control_strip())
+                    .unwrap_or_else(|_| div().into_any_element())
+            }));
+        });
+        this
+    }
+
     pub(crate) fn view_kind(&self) -> DebuggerPaneItem {
         self.kind
     }
+    pub(crate) fn with_indicator(&mut self, indicator: Box<dyn Fn(&App) -> bool>) {
+        self.show_indicator = indicator;
+    }
+    pub(crate) fn with_actions(
+        &mut self,
+        actions: Box<dyn FnMut(&mut Window, &mut App) -> AnyElement>,
+    ) {
+        self.actions = Some(actions);
+    }
 }
 impl Focusable for SubView {
     fn focus_handle(&self, _: &App) -> FocusHandle {
-        self.pane_focus_handle.clone()
+        self.item_focus_handle.clone()
     }
 }
 impl EventEmitter<()> for SubView {}
@@ -161,6 +222,10 @@ impl Item for SubView {
         self.kind.to_shared_string()
     }
 
+    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
+        Some(self.kind.tab_tooltip())
+    }
+
     fn tab_content(
         &self,
         params: workspace::item::TabContentParams,
@@ -199,7 +264,7 @@ impl Render for SubView {
             .size_full()
             // Add border unconditionally to prevent layout shifts on focus changes.
             .border_1()
-            .when(self.pane_focus_handle.contains_focused(window, cx), |el| {
+            .when(self.item_focus_handle.contains_focused(window, cx), |el| {
                 el.border_color(cx.theme().colors().pane_focused_border)
             })
             .child(self.inner.clone())
@@ -269,6 +334,7 @@ pub(crate) fn new_debugger_pane(
                                 &new_pane,
                                 item_id_to_move,
                                 new_pane.read(cx).active_item_index(),
+                                true,
                                 window,
                                 cx,
                             );
@@ -307,7 +373,7 @@ pub(crate) fn new_debugger_pane(
                 if let Some(tab) = dragged_item.downcast_ref::<DraggedTab>() {
                     let is_current_pane = tab.pane == cx.entity();
                     let Some(can_drag_away) = weak_running
-                        .update(cx, |running_state, _| {
+                        .read_with(cx, |running_state, _| {
                             let current_panes = running_state.panes.panes();
                             !current_panes.contains(&&tab.pane)
                                 || current_panes.len() > 1
@@ -331,6 +397,7 @@ pub(crate) fn new_debugger_pane(
                 false
             }
         })));
+        pane.set_can_toggle_zoom(false, cx);
         pane.display_nav_history_buttons(None);
         pane.set_custom_drop_handle(cx, custom_drop_handle);
         pane.set_should_display_tab_bar(|_, _| true);
@@ -340,10 +407,13 @@ pub(crate) fn new_debugger_pane(
                 let active_pane_item = pane.active_item();
                 let pane_group_id: SharedString =
                     format!("pane-zoom-button-hover-{}", cx.entity_id()).into();
-                let is_hovered = active_pane_item.as_ref().map_or(false, |item| {
-                    item.downcast::<SubView>()
-                        .map_or(false, |this| this.read(cx).hovered)
-                });
+                let as_subview = active_pane_item
+                    .as_ref()
+                    .and_then(|item| item.downcast::<SubView>());
+                let is_hovered = as_subview
+                    .as_ref()
+                    .map_or(false, |item| item.read(cx).hovered);
+
                 h_flex()
                     .group(pane_group_id.clone())
                     .justify_between()
@@ -387,6 +457,9 @@ pub(crate) fn new_debugger_pane(
                                     .p_1()
                                     .rounded_md()
                                     .cursor_pointer()
+                                    .when_some(item.tab_tooltip_text(cx), |this, tooltip| {
+                                        this.tooltip(Tooltip::text(tooltip))
+                                    })
                                     .map(|this| {
                                         let theme = cx.theme();
                                         if selected {
@@ -437,9 +510,17 @@ pub(crate) fn new_debugger_pane(
                     )
                     .child({
                         let zoomed = pane.is_zoomed();
-                        div()
+                        h_flex()
                             .visible_on_hover(pane_group_id)
                             .when(is_hovered, |this| this.visible())
+                            .when_some(as_subview.as_ref(), |this, subview| {
+                                subview.update(cx, |view, cx| {
+                                    let Some(additional_actions) = view.actions.as_mut() else {
+                                        return this;
+                                    };
+                                    this.child(additional_actions(window, cx))
+                                })
+                            })
                             .child(
                                 IconButton::new(
                                     SharedString::from(format!(
@@ -453,17 +534,19 @@ pub(crate) fn new_debugger_pane(
                                     },
                                 )
                                 .icon_size(IconSize::XSmall)
-                                .on_click(cx.listener(move |pane, _, window, cx| {
-                                    pane.toggle_zoom(&workspace::ToggleZoom, window, cx);
+                                .on_click(cx.listener(move |pane, _, _, cx| {
+                                    let is_zoomed = pane.is_zoomed();
+                                    pane.set_zoomed(!is_zoomed, cx);
+                                    cx.notify();
                                 }))
                                 .tooltip({
                                     let focus_handle = focus_handle.clone();
                                     move |window, cx| {
                                         let zoomed_text =
-                                            if zoomed { "Zoom Out" } else { "Zoom In" };
+                                            if zoomed { "Minimize" } else { "Expand" };
                                         Tooltip::for_action_in(
                                             zoomed_text,
-                                            &workspace::ToggleZoom,
+                                            &ToggleExpandItem,
                                             &focus_handle,
                                             window,
                                             cx,
@@ -484,41 +567,104 @@ pub(crate) fn new_debugger_pane(
 pub struct DebugTerminal {
     pub terminal: Option<Entity<TerminalView>>,
     focus_handle: FocusHandle,
+    _subscriptions: [Subscription; 1],
 }
 
 impl DebugTerminal {
-    fn empty(cx: &mut Context<Self>) -> Self {
+    fn empty(window: &mut Window, cx: &mut Context<Self>) -> Self {
+        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);
+            }
+        });
+
         Self {
             terminal: None,
-            focus_handle: cx.focus_handle(),
+            focus_handle,
+            _subscriptions: [focus_subscription],
         }
     }
 }
 
 impl gpui::Render for DebugTerminal {
     fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
-        if let Some(terminal) = self.terminal.clone() {
-            terminal.into_any_element()
-        } else {
-            div().track_focus(&self.focus_handle).into_any_element()
-        }
+        div()
+            .size_full()
+            .track_focus(&self.focus_handle)
+            .children(self.terminal.clone())
     }
 }
 impl Focusable for DebugTerminal {
-    fn focus_handle(&self, cx: &App) -> FocusHandle {
-        if let Some(terminal) = self.terminal.as_ref() {
-            return terminal.focus_handle(cx);
-        } else {
-            self.focus_handle.clone()
-        }
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
     }
 }
 
 impl RunningState {
-    pub fn new(
+    // todo(debugger) move this to util and make it so you pass a closure to it that converts a string
+    pub(crate) fn substitute_variables_in_config(
+        config: &mut serde_json::Value,
+        context: &TaskContext,
+    ) {
+        match config {
+            serde_json::Value::Object(obj) => {
+                obj.values_mut()
+                    .for_each(|value| Self::substitute_variables_in_config(value, context));
+            }
+            serde_json::Value::Array(array) => {
+                array
+                    .iter_mut()
+                    .for_each(|value| Self::substitute_variables_in_config(value, context));
+            }
+            serde_json::Value::String(s) => {
+                // Some built-in zed tasks wrap their arguments in quotes as they might contain spaces.
+                if s.starts_with("\"$ZED_") && s.ends_with('"') {
+                    *s = s[1..s.len() - 1].to_string();
+                }
+                if let Some(substituted) = substitute_variables_in_str(&s, context) {
+                    *s = substituted;
+                }
+            }
+            _ => {}
+        }
+    }
+
+    pub(crate) fn relativize_paths(
+        key: Option<&str>,
+        config: &mut serde_json::Value,
+        context: &TaskContext,
+    ) {
+        match config {
+            serde_json::Value::Object(obj) => {
+                obj.iter_mut()
+                    .for_each(|(key, value)| Self::relativize_paths(Some(key), value, context));
+            }
+            serde_json::Value::Array(array) => {
+                array
+                    .iter_mut()
+                    .for_each(|value| Self::relativize_paths(None, value, context));
+            }
+            serde_json::Value::String(s) if key == Some("program") || key == Some("cwd") => {
+                // Some built-in zed tasks wrap their arguments in quotes as they might contain spaces.
+                if s.starts_with("\"$ZED_") && s.ends_with('"') {
+                    *s = s[1..s.len() - 1].to_string();
+                }
+                resolve_path(s);
+
+                if let Some(substituted) = substitute_variables_in_str(&s, context) {
+                    *s = substituted;
+                }
+            }
+            _ => {}
+        }
+    }
+
+    pub(crate) fn new(
         session: Entity<Session>,
         project: Entity<Project>,
         workspace: WeakEntity<Workspace>,
+        parent_terminal: Option<Entity<DebugTerminal>>,
         serialized_pane_layout: Option<SerializedLayout>,
         dock_axis: Axis,
         window: &mut Window,
@@ -531,7 +677,8 @@ impl RunningState {
             StackFrameList::new(workspace.clone(), session.clone(), weak_state, window, cx)
         });
 
-        let debug_terminal = cx.new(DebugTerminal::empty);
+        let debug_terminal =
+            parent_terminal.unwrap_or_else(|| cx.new(|cx| DebugTerminal::empty(window, cx)));
 
         let variable_list =
             cx.new(|cx| VariableList::new(session.clone(), stack_frame_list.clone(), window, cx));
@@ -550,22 +697,49 @@ impl RunningState {
             )
         });
 
-        let breakpoint_list = BreakpointList::new(session.clone(), workspace.clone(), &project, cx);
+        let breakpoint_list = BreakpointList::new(
+            Some(session.clone()),
+            workspace.clone(),
+            &project,
+            window,
+            cx,
+        );
 
         let _subscriptions = vec![
+            cx.on_app_quit(move |this, cx| {
+                let shutdown = this
+                    .session
+                    .update(cx, |session, cx| session.on_app_quit(cx));
+                let terminal = this.debug_terminal.clone();
+                async move {
+                    shutdown.await;
+                    drop(terminal)
+                }
+            }),
             cx.observe(&module_list, |_, _, cx| cx.notify()),
             cx.subscribe_in(&session, window, |this, _, event, window, cx| {
                 match event {
                     SessionEvent::Stopped(thread_id) => {
-                        this.workspace
+                        let panel = this
+                            .workspace
                             .update(cx, |workspace, cx| {
                                 workspace.open_panel::<crate::DebugPanel>(window, cx);
+                                workspace.panel::<crate::DebugPanel>(cx)
                             })
-                            .log_err();
+                            .log_err()
+                            .flatten();
 
                         if let Some(thread_id) = thread_id {
                             this.select_thread(*thread_id, window, cx);
                         }
+                        if let Some(panel) = panel {
+                            let id = this.session_id;
+                            window.defer(cx, move |window, cx| {
+                                panel.update(cx, |this, cx| {
+                                    this.activate_session_by_id(id, window, cx);
+                                })
+                            })
+                        }
                     }
                     SessionEvent::Threads => {
                         let threads = this.session.update(cx, |this, cx| this.threads(cx));
@@ -624,10 +798,9 @@ impl RunningState {
                 &workspace,
                 &stack_frame_list,
                 &variable_list,
-                &module_list,
-                &loaded_source_list,
                 &console,
                 &breakpoint_list,
+                &debug_terminal,
                 dock_axis,
                 &mut pane_close_subscriptions,
                 window,
@@ -636,6 +809,7 @@ impl RunningState {
 
             workspace::PaneGroup::with_root(root)
         };
+        let active_pane = panes.first_pane();
 
         Self {
             session,
@@ -648,7 +822,7 @@ impl RunningState {
             stack_frame_list,
             session_id,
             panes,
-            active_pane: None,
+            active_pane,
             module_list,
             console,
             breakpoint_list,
@@ -701,6 +875,7 @@ impl RunningState {
         };
         let project = workspace.read(cx).project().clone();
         let dap_store = project.read(cx).dap_store().downgrade();
+        let dap_registry = cx.global::<DapRegistry>().clone();
         let task_store = project.read(cx).task_store().downgrade();
         let weak_project = project.downgrade();
         let weak_workspace = workspace.downgrade();
@@ -710,20 +885,31 @@ impl RunningState {
                 adapter,
                 label,
                 build,
-                request,
-                initialize_args,
+                mut config,
                 tcp_connection,
-                stop_on_entry,
             } = scenario;
+            Self::relativize_paths(None, &mut config, &task_context);
+            Self::substitute_variables_in_config(&mut config, &task_context);
+
+            let request_type = match dap_registry
+                .adapter(&adapter)
+                .with_context(|| format!("{}: is not a valid adapter name", &adapter)) {
+                    Ok(adapter) => adapter.request_kind(&config).await,
+                    Err(e) => Err(e)
+                };
+
+
+            let config_is_valid = request_type.is_ok();
+
             let build_output = if let Some(build) = build {
-                let (task, locator_name) = match build {
+                let (task_template, locator_name) = match build {
                     BuildTaskDefinition::Template {
                         task_template,
                         locator_name,
                     } => (task_template, locator_name),
                     BuildTaskDefinition::ByName(ref label) => {
-                        let Some(task) = task_store.update(cx, |this, cx| {
-                            this.task_inventory().and_then(|inventory| {
+                        let task = task_store.update(cx, |this, cx| {
+                            this.task_inventory().map(|inventory| {
                                 inventory.read(cx).task_template_by_label(
                                     buffer,
                                     worktree_id,
@@ -731,22 +917,23 @@ impl RunningState {
                                     cx,
                                 )
                             })
-                        })?
-                        else {
-                            anyhow::bail!("Couldn't find task template for {:?}", build)
-                        };
+                        })?;
+                        let task = match task {
+                            Some(task) => task.await,
+                            None => None,
+                        }.with_context(|| format!("Couldn't find task template for {build:?}"))?;
                         (task, None)
                     }
                 };
-                let Some(task) = task.resolve_task("debug-build-task", &task_context) else {
+                let Some(task) = task_template.resolve_task("debug-build-task", &task_context) else {
                     anyhow::bail!("Could not resolve task variables within a debug scenario");
                 };
 
                 let locator_name = if let Some(locator_name) = locator_name {
-                    debug_assert!(request.is_none());
+                    debug_assert!(!config_is_valid);
                     Some(locator_name)
-                } else if request.is_none() {
-                    dap_store
+                } else if !config_is_valid {
+                    let task = dap_store
                         .update(cx, |this, cx| {
                             this.debug_scenario_for_build_task(
                                 task.original_task().clone(),
@@ -754,17 +941,21 @@ impl RunningState {
                                 task.display_label().to_owned().into(),
                                 cx,
                             )
-                            .and_then(|scenario| {
-                                match scenario.build {
-                                    Some(BuildTaskDefinition::Template {
-                                        locator_name, ..
-                                    }) => locator_name,
-                                    _ => None,
-                                }
-                            })
+
+                        });
+                    if let Ok(t) = task {
+                        t.await.and_then(|scenario| {
+                            match scenario.build {
+                                Some(BuildTaskDefinition::Template {
+                                    locator_name, ..
+                                }) => locator_name,
+                                _ => None,
+                            }
                         })
-                        .ok()
-                        .flatten()
+                    } else {
+                        None
+                    }
+
                 } else {
                     None
                 };
@@ -796,7 +987,6 @@ impl RunningState {
                         weak_workspace,
                         None,
                         weak_project,
-                        false,
                         window,
                         cx,
                     )
@@ -813,7 +1003,7 @@ impl RunningState {
                 let exit_status = terminal
                     .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
                     .await
-                    .ok_or_else(|| anyhow!("Failed to wait for completed task"))?;
+                    .context("Failed to wait for completed task")?;
 
                 if !exit_status.success() {
                     anyhow::bail!("Build failed");
@@ -822,63 +1012,45 @@ impl RunningState {
             } else {
                 None
             };
-            let request = if let Some(request) = request {
-                request
+
+            if config_is_valid {
             } else if let Some((task, locator_name)) = build_output {
-                let locator_name = locator_name
-                    .ok_or_else(|| anyhow!("Could not find a valid locator for a build task"))?;
-                dap_store
+                let locator_name =
+                    locator_name.with_context(|| {
+                        format!("Could not find a valid locator for a build task and configure is invalid with error: {}", request_type.err()
+                            .map(|err| err.to_string())
+                            .unwrap_or_default())
+                    })?;
+                let request = dap_store
                     .update(cx, |this, cx| {
                         this.run_debug_locator(&locator_name, task, cx)
                     })?
-                    .await?
-            } else {
-                return Err(anyhow!("No request or build provided"));
-            };
-            let request = match request {
-                dap::DebugRequest::Launch(launch_request) => {
-                    let cwd = match launch_request.cwd.as_deref().and_then(|path| path.to_str()) {
-                        Some(cwd) => {
-                            let substituted_cwd = substitute_variables_in_str(&cwd, &task_context)
-                                .ok_or_else(|| anyhow!("Failed to substitute variables in cwd"))?;
-                            Some(PathBuf::from(substituted_cwd))
-                        }
-                        None => None,
-                    };
+                    .await?;
 
-                    let env = substitute_variables_in_map(
-                        &launch_request.env.into_iter().collect(),
-                        &task_context,
-                    )
-                    .ok_or_else(|| anyhow!("Failed to substitute variables in env"))?
-                    .into_iter()
-                    .collect();
-                    let new_launch_request = LaunchRequest {
-                        program: substitute_variables_in_str(
-                            &launch_request.program,
-                            &task_context,
-                        )
-                        .ok_or_else(|| anyhow!("Failed to substitute variables in program"))?,
-                        args: launch_request
-                            .args
-                            .into_iter()
-                            .map(|arg| substitute_variables_in_str(&arg, &task_context))
-                            .collect::<Option<Vec<_>>>()
-                            .ok_or_else(|| anyhow!("Failed to substitute variables in args"))?,
-                        cwd,
-                        env,
-                    };
+                let zed_config = ZedDebugConfig {
+                    label: label.clone(),
+                    adapter: adapter.clone(),
+                    request,
+                    stop_on_entry: None,
+                };
 
-                    dap::DebugRequest::Launch(new_launch_request)
-                }
-                request @ dap::DebugRequest::Attach(_) => request, // todo(debugger): We should check that process_id is valid and if not show the modal
+                let scenario = dap_registry
+                    .adapter(&adapter)
+                    .with_context(|| anyhow!("{}: is not a valid adapter name", &adapter))?.config_from_zed_format(zed_config)
+.await?;
+                config = scenario.config;
+                Self::substitute_variables_in_config(&mut config, &task_context);
+            } else {
+                let Err(e) = request_type else {
+                    unreachable!();
+                };
+                anyhow::bail!("Zed cannot determine how to run this debug scenario. `build` field was not provided and Debug Adapter won't accept provided configuration because: {e}");
             };
+
             Ok(DebugTaskDefinition {
                 label,
                 adapter: DebugAdapterName(adapter),
-                request,
-                initialize_args,
-                stop_on_entry,
+                config,
                 tcp_connection,
             })
         })
@@ -894,7 +1066,7 @@ impl RunningState {
         let running = cx.entity();
         let Ok(project) = self
             .workspace
-            .update(cx, |workspace, _| workspace.project().clone())
+            .read_with(cx, |workspace, _| workspace.project().clone())
         else {
             return Task::ready(Err(anyhow!("no workspace")));
         };
@@ -903,7 +1075,7 @@ impl RunningState {
         let cwd = Some(&request.cwd)
             .filter(|cwd| cwd.len() > 0)
             .map(PathBuf::from)
-            .or_else(|| session.binary().cwd.clone());
+            .or_else(|| session.binary().unwrap().cwd.clone());
 
         let mut args = request.args.clone();
 
@@ -918,7 +1090,8 @@ impl RunningState {
             None
         };
 
-        let mut envs: HashMap<String, String> = Default::default();
+        let mut envs: HashMap<String, String> =
+            self.session.read(cx).task_context().project_env.clone();
         if let Some(Value::Object(env)) = &request.env {
             for (key, value) in env {
                 let value_str = match (key.as_str(), value) {
@@ -966,15 +1139,7 @@ impl RunningState {
             let terminal = terminal_task.await?;
 
             let terminal_view = cx.new_window_entity(|window, cx| {
-                TerminalView::new(
-                    terminal.clone(),
-                    workspace,
-                    None,
-                    weak_project,
-                    false,
-                    window,
-                    cx,
-                )
+                TerminalView::new(terminal.clone(), workspace, None, weak_project, window, cx)
             })?;
 
             running.update_in(cx, |running, window, cx| {
@@ -990,7 +1155,7 @@ impl RunningState {
                     .pty_info
                     .pid()
                     .map(|pid| pid.as_u32())
-                    .ok_or_else(|| anyhow!("Terminal was spawned but PID was not available"))
+                    .context("Terminal was spawned but PID was not available")
             })?
         });
 
@@ -1004,61 +1169,38 @@ impl RunningState {
         cx: &mut Context<Self>,
     ) -> Box<dyn ItemHandle> {
         match item_kind {
-            DebuggerPaneItem::Console => {
-                let weak_console = self.console.clone().downgrade();
-
-                Box::new(SubView::new(
-                    self.console.focus_handle(cx),
-                    self.console.clone().into(),
-                    item_kind,
-                    Some(Box::new(move |cx| {
-                        weak_console
-                            .read_with(cx, |console, cx| console.show_indicator(cx))
-                            .unwrap_or_default()
-                    })),
-                    cx,
-                ))
-            }
+            DebuggerPaneItem::Console => Box::new(SubView::console(self.console.clone(), cx)),
             DebuggerPaneItem::Variables => Box::new(SubView::new(
                 self.variable_list.focus_handle(cx),
                 self.variable_list.clone().into(),
                 item_kind,
-                None,
-                cx,
-            )),
-            DebuggerPaneItem::BreakpointList => Box::new(SubView::new(
-                self.breakpoint_list.focus_handle(cx),
-                self.breakpoint_list.clone().into(),
-                item_kind,
-                None,
                 cx,
             )),
+            DebuggerPaneItem::BreakpointList => {
+                Box::new(SubView::breakpoint_list(self.breakpoint_list.clone(), cx))
+            }
             DebuggerPaneItem::Frames => Box::new(SubView::new(
                 self.stack_frame_list.focus_handle(cx),
                 self.stack_frame_list.clone().into(),
                 item_kind,
-                None,
                 cx,
             )),
             DebuggerPaneItem::Modules => Box::new(SubView::new(
                 self.module_list.focus_handle(cx),
                 self.module_list.clone().into(),
                 item_kind,
-                None,
                 cx,
             )),
             DebuggerPaneItem::LoadedSources => Box::new(SubView::new(
                 self.loaded_sources_list.focus_handle(cx),
                 self.loaded_sources_list.clone().into(),
                 item_kind,
-                None,
                 cx,
             )),
             DebuggerPaneItem::Terminal => Box::new(SubView::new(
                 self.debug_terminal.focus_handle(cx),
                 self.debug_terminal.clone().into(),
                 item_kind,
-                None,
                 cx,
             )),
         }
@@ -1173,19 +1315,7 @@ impl RunningState {
                 cx.notify();
             }
             Event::Focus => {
-                this.active_pane = Some(source_pane.clone());
-            }
-            Event::ZoomIn => {
-                source_pane.update(cx, |pane, cx| {
-                    pane.set_zoomed(true, cx);
-                });
-                cx.notify();
-            }
-            Event::ZoomOut => {
-                source_pane.update(cx, |pane, cx| {
-                    pane.set_zoomed(false, cx);
-                });
-                cx.notify();
+                this.active_pane = source_pane.clone();
             }
             _ => {}
         }
@@ -1197,12 +1327,14 @@ impl RunningState {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        let active_pane = self.active_pane.clone();
         if let Some(pane) = self
-            .active_pane
-            .as_ref()
-            .and_then(|pane| self.panes.find_pane_in_direction(pane, direction, cx))
+            .panes
+            .find_pane_in_direction(&active_pane, direction, cx)
         {
-            window.focus(&pane.focus_handle(cx));
+            pane.update(cx, |pane, cx| {
+                pane.focus_active_item(window, cx);
+            })
         } else {
             self.workspace
                 .update(cx, |workspace, cx| {
@@ -1212,10 +1344,16 @@ impl RunningState {
         }
     }
 
-    pub(crate) fn go_to_selected_stack_frame(&self, window: &Window, cx: &mut Context<Self>) {
+    pub(crate) fn go_to_selected_stack_frame(&self, window: &mut Window, cx: &mut Context<Self>) {
         if self.thread_id.is_some() {
             self.stack_frame_list
-                .update(cx, |list, cx| list.go_to_selected_stack_frame(window, cx));
+                .update(cx, |list, cx| {
+                    let Some(stack_frame_id) = list.opened_stack_frame_id() else {
+                        return Task::ready(Ok(()));
+                    };
+                    list.go_to_stack_frame(stack_frame_id, window, cx)
+                })
+                .detach();
         }
     }
 
@@ -1232,11 +1370,10 @@ impl RunningState {
     }
 
     pub(crate) fn selected_stack_frame_id(&self, cx: &App) -> Option<dap::StackFrameId> {
-        self.stack_frame_list.read(cx).selected_stack_frame_id()
+        self.stack_frame_list.read(cx).opened_stack_frame_id()
     }
 
-    #[cfg(test)]
-    pub fn stack_frame_list(&self) -> &Entity<StackFrameList> {
+    pub(crate) fn stack_frame_list(&self) -> &Entity<StackFrameList> {
         &self.stack_frame_list
     }
 
@@ -1310,7 +1447,12 @@ impl RunningState {
             .map(|id| self.session().read(cx).thread_status(id))
     }
 
-    fn select_thread(&mut self, thread_id: ThreadId, window: &mut Window, cx: &mut Context<Self>) {
+    pub(crate) fn select_thread(
+        &mut self,
+        thread_id: ThreadId,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         if self.thread_id.is_some_and(|id| id == thread_id) {
             return;
         }
@@ -1447,47 +1589,14 @@ impl RunningState {
         });
     }
 
-    pub(crate) fn thread_dropdown(
-        &self,
-        window: &mut Window,
-        cx: &mut Context<'_, RunningState>,
-    ) -> DropdownMenu {
-        let state = cx.entity();
-        let session_terminated = self.session.read(cx).is_terminated();
-        let threads = self.session.update(cx, |this, cx| this.threads(cx));
-        let selected_thread_name = threads
-            .iter()
-            .find(|(thread, _)| self.thread_id.map(|id| id.0) == Some(thread.id))
-            .map(|(thread, _)| thread.name.clone())
-            .unwrap_or("Threads".to_owned());
-        DropdownMenu::new(
-            ("thread-list", self.session_id.0),
-            selected_thread_name,
-            ContextMenu::build_eager(window, cx, move |mut this, _, _| {
-                for (thread, _) in threads {
-                    let state = state.clone();
-                    let thread_id = thread.id;
-                    this = this.entry(thread.name, None, move |window, cx| {
-                        state.update(cx, |state, cx| {
-                            state.select_thread(ThreadId(thread_id), window, cx);
-                        });
-                    });
-                }
-                this
-            }),
-        )
-        .disabled(session_terminated)
-    }
-
     fn default_pane_layout(
         project: Entity<Project>,
         workspace: &WeakEntity<Workspace>,
         stack_frame_list: &Entity<StackFrameList>,
         variable_list: &Entity<VariableList>,
-        module_list: &Entity<ModuleList>,
-        loaded_source_list: &Entity<LoadedSourceList>,
         console: &Entity<Console>,
         breakpoints: &Entity<BreakpointList>,
+        debug_terminal: &Entity<DebugTerminal>,
         dock_axis: Axis,
         subscriptions: &mut HashMap<EntityId, Subscription>,
         window: &mut Window,
@@ -1500,7 +1609,6 @@ impl RunningState {
                     this.focus_handle(cx),
                     stack_frame_list.clone().into(),
                     DebuggerPaneItem::Frames,
-                    None,
                     cx,
                 )),
                 true,
@@ -1510,13 +1618,7 @@ impl RunningState {
                 cx,
             );
             this.add_item(
-                Box::new(SubView::new(
-                    breakpoints.focus_handle(cx),
-                    breakpoints.clone().into(),
-                    DebuggerPaneItem::BreakpointList,
-                    None,
-                    cx,
-                )),
+                Box::new(SubView::breakpoint_list(breakpoints.clone(), cx)),
                 true,
                 false,
                 None,

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

@@ -1,13 +1,15 @@
 use std::{
+    ops::Range,
     path::{Path, PathBuf},
+    sync::Arc,
     time::Duration,
 };
 
-use dap::ExceptionBreakpointsFilter;
+use dap::{Capabilities, ExceptionBreakpointsFilter};
 use editor::Editor;
 use gpui::{
-    AppContext, Entity, FocusHandle, Focusable, ListState, MouseButton, Stateful, Task, WeakEntity,
-    list,
+    Action, AppContext, ClickEvent, Entity, FocusHandle, Focusable, MouseButton, ScrollStrategy,
+    Stateful, Task, UniformListScrollHandle, WeakEntity, actions, uniform_list,
 };
 use language::Point;
 use project::{
@@ -19,25 +21,39 @@ use project::{
     worktree_store::WorktreeStore,
 };
 use ui::{
-    App, Clickable, Color, Context, Div, Icon, IconButton, IconName, Indicator, InteractiveElement,
-    IntoElement, Label, LabelCommon, LabelSize, ListItem, ParentElement, Render, RenderOnce,
-    Scrollbar, ScrollbarState, SharedString, StatefulInteractiveElement, Styled, Window, div,
-    h_flex, px, v_flex,
+    ActiveTheme, AnyElement, App, ButtonCommon, Clickable, Color, Context, Disableable, Div,
+    Divider, FluentBuilder as _, Icon, IconButton, IconName, IconSize, Indicator,
+    InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ListItem, ParentElement,
+    Render, RenderOnce, Scrollbar, ScrollbarState, SharedString, StatefulInteractiveElement,
+    Styled, Toggleable, Tooltip, Window, div, h_flex, px, v_flex,
 };
-use util::{ResultExt, maybe};
+use util::ResultExt;
 use workspace::Workspace;
+use zed_actions::{ToggleEnableBreakpoint, UnsetBreakpoint};
 
+actions!(
+    debugger,
+    [PreviousBreakpointProperty, NextBreakpointProperty]
+);
+#[derive(Clone, Copy, PartialEq)]
+pub(crate) enum SelectedBreakpointKind {
+    Source,
+    Exception,
+}
 pub(crate) struct BreakpointList {
     workspace: WeakEntity<Workspace>,
     breakpoint_store: Entity<BreakpointStore>,
     worktree_store: Entity<WorktreeStore>,
-    list_state: ListState,
     scrollbar_state: ScrollbarState,
     breakpoints: Vec<BreakpointEntry>,
-    session: Entity<Session>,
+    session: Option<Entity<Session>>,
     hide_scrollbar_task: Option<Task<()>>,
     show_scrollbar: bool,
     focus_handle: FocusHandle,
+    scroll_handle: UniformListScrollHandle,
+    selected_ix: Option<usize>,
+    input: Entity<Editor>,
+    strip_mode: Option<ActiveBreakpointStripMode>,
 }
 
 impl Focusable for BreakpointList {
@@ -46,46 +62,417 @@ impl Focusable for BreakpointList {
     }
 }
 
+#[derive(Clone, Copy, PartialEq)]
+enum ActiveBreakpointStripMode {
+    Log,
+    Condition,
+    HitCondition,
+}
+
 impl BreakpointList {
-    pub(super) fn new(
-        session: Entity<Session>,
+    pub(crate) fn new(
+        session: Option<Entity<Session>>,
         workspace: WeakEntity<Workspace>,
         project: &Entity<Project>,
+        window: &mut Window,
         cx: &mut App,
     ) -> Entity<Self> {
         let project = project.read(cx);
         let breakpoint_store = project.breakpoint_store();
         let worktree_store = project.worktree_store();
+        let focus_handle = cx.focus_handle();
+        let scroll_handle = UniformListScrollHandle::new();
+        let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
 
-        cx.new(|cx| {
-            let weak: gpui::WeakEntity<Self> = cx.weak_entity();
-            let list_state = ListState::new(
-                0,
-                gpui::ListAlignment::Top,
-                px(1000.),
-                move |ix, window, cx| {
-                    let Ok(Some(breakpoint)) =
-                        weak.update(cx, |this, _| this.breakpoints.get(ix).cloned())
-                    else {
-                        return div().into_any_element();
-                    };
-
-                    breakpoint.render(window, cx).into_any_element()
-                },
-            );
-            Self {
-                breakpoint_store,
-                worktree_store,
-                scrollbar_state: ScrollbarState::new(list_state.clone()),
-                list_state,
-                breakpoints: Default::default(),
-                hide_scrollbar_task: None,
-                show_scrollbar: false,
-                workspace,
-                session,
-                focus_handle: cx.focus_handle(),
+        cx.new(|cx| Self {
+            breakpoint_store,
+            worktree_store,
+            scrollbar_state,
+            breakpoints: Default::default(),
+            hide_scrollbar_task: None,
+            show_scrollbar: false,
+            workspace,
+            session,
+            focus_handle,
+            scroll_handle,
+            selected_ix: None,
+            input: cx.new(|cx| Editor::single_line(window, cx)),
+            strip_mode: None,
+        })
+    }
+
+    fn edit_line_breakpoint(
+        &self,
+        path: Arc<Path>,
+        row: u32,
+        action: BreakpointEditAction,
+        cx: &mut App,
+    ) {
+        Self::edit_line_breakpoint_inner(&self.breakpoint_store, path, row, action, cx);
+    }
+    fn edit_line_breakpoint_inner(
+        breakpoint_store: &Entity<BreakpointStore>,
+        path: Arc<Path>,
+        row: u32,
+        action: BreakpointEditAction,
+        cx: &mut App,
+    ) {
+        breakpoint_store.update(cx, |breakpoint_store, cx| {
+            if let Some((buffer, breakpoint)) = breakpoint_store.breakpoint_at_row(&path, row, cx) {
+                breakpoint_store.toggle_breakpoint(buffer, breakpoint, action, cx);
+            } else {
+                log::error!("Couldn't find breakpoint at row event though it exists: row {row}")
+            }
+        })
+    }
+
+    fn go_to_line_breakpoint(
+        &mut self,
+        path: Arc<Path>,
+        row: u32,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let task = self
+            .worktree_store
+            .update(cx, |this, cx| this.find_or_create_worktree(path, false, cx));
+        cx.spawn_in(window, async move |this, cx| {
+            let (worktree, relative_path) = task.await?;
+            let worktree_id = worktree.read_with(cx, |this, _| this.id())?;
+            let item = this
+                .update_in(cx, |this, window, cx| {
+                    this.workspace.update(cx, |this, cx| {
+                        this.open_path((worktree_id, relative_path), None, true, window, cx)
+                    })
+                })??
+                .await?;
+            if let Some(editor) = item.downcast::<Editor>() {
+                editor
+                    .update_in(cx, |this, window, cx| {
+                        this.go_to_singleton_buffer_point(Point { row, column: 0 }, window, cx);
+                    })
+                    .ok();
             }
+            anyhow::Ok(())
         })
+        .detach();
+    }
+
+    pub(crate) fn selection_kind(&self) -> Option<(SelectedBreakpointKind, bool)> {
+        self.selected_ix.and_then(|ix| {
+            self.breakpoints.get(ix).map(|bp| match &bp.kind {
+                BreakpointEntryKind::LineBreakpoint(bp) => (
+                    SelectedBreakpointKind::Source,
+                    bp.breakpoint.state
+                        == project::debugger::breakpoint_store::BreakpointState::Enabled,
+                ),
+                BreakpointEntryKind::ExceptionBreakpoint(bp) => {
+                    (SelectedBreakpointKind::Exception, bp.is_enabled)
+                }
+            })
+        })
+    }
+
+    fn set_active_breakpoint_property(
+        &mut self,
+        prop: ActiveBreakpointStripMode,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        self.strip_mode = Some(prop);
+        let placeholder = match prop {
+            ActiveBreakpointStripMode::Log => "Set Log Message",
+            ActiveBreakpointStripMode::Condition => "Set Condition",
+            ActiveBreakpointStripMode::HitCondition => "Set Hit Condition",
+        };
+        let mut is_exception_breakpoint = true;
+        let active_value = self.selected_ix.and_then(|ix| {
+            self.breakpoints.get(ix).and_then(|bp| {
+                if let BreakpointEntryKind::LineBreakpoint(bp) = &bp.kind {
+                    is_exception_breakpoint = false;
+                    match prop {
+                        ActiveBreakpointStripMode::Log => bp.breakpoint.message.clone(),
+                        ActiveBreakpointStripMode::Condition => bp.breakpoint.condition.clone(),
+                        ActiveBreakpointStripMode::HitCondition => {
+                            bp.breakpoint.hit_condition.clone()
+                        }
+                    }
+                } else {
+                    None
+                }
+            })
+        });
+
+        self.input.update(cx, |this, cx| {
+            this.set_placeholder_text(placeholder, cx);
+            this.set_read_only(is_exception_breakpoint);
+            this.set_text(active_value.as_deref().unwrap_or(""), window, cx);
+        });
+    }
+
+    fn select_ix(&mut self, ix: Option<usize>, window: &mut Window, cx: &mut Context<Self>) {
+        self.selected_ix = ix;
+        if let Some(ix) = ix {
+            self.scroll_handle
+                .scroll_to_item(ix, ScrollStrategy::Center);
+        }
+        if let Some(mode) = self.strip_mode {
+            self.set_active_breakpoint_property(mode, window, cx);
+        }
+
+        cx.notify();
+    }
+
+    fn select_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
+        if self.strip_mode.is_some() {
+            if self.input.focus_handle(cx).contains_focused(window, cx) {
+                cx.propagate();
+                return;
+            }
+        }
+        let ix = match self.selected_ix {
+            _ if self.breakpoints.len() == 0 => None,
+            None => Some(0),
+            Some(ix) => {
+                if ix == self.breakpoints.len() - 1 {
+                    Some(0)
+                } else {
+                    Some(ix + 1)
+                }
+            }
+        };
+        self.select_ix(ix, window, cx);
+    }
+
+    fn select_previous(
+        &mut self,
+        _: &menu::SelectPrevious,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.strip_mode.is_some() {
+            if self.input.focus_handle(cx).contains_focused(window, cx) {
+                cx.propagate();
+                return;
+            }
+        }
+        let ix = match self.selected_ix {
+            _ if self.breakpoints.len() == 0 => None,
+            None => Some(self.breakpoints.len() - 1),
+            Some(ix) => {
+                if ix == 0 {
+                    Some(self.breakpoints.len() - 1)
+                } else {
+                    Some(ix - 1)
+                }
+            }
+        };
+        self.select_ix(ix, window, cx);
+    }
+
+    fn select_first(&mut self, _: &menu::SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
+        if self.strip_mode.is_some() {
+            if self.input.focus_handle(cx).contains_focused(window, cx) {
+                cx.propagate();
+                return;
+            }
+        }
+        let ix = if self.breakpoints.len() > 0 {
+            Some(0)
+        } else {
+            None
+        };
+        self.select_ix(ix, window, cx);
+    }
+
+    fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context<Self>) {
+        if self.strip_mode.is_some() {
+            if self.input.focus_handle(cx).contains_focused(window, cx) {
+                cx.propagate();
+                return;
+            }
+        }
+        let ix = if self.breakpoints.len() > 0 {
+            Some(self.breakpoints.len() - 1)
+        } else {
+            None
+        };
+        self.select_ix(ix, window, cx);
+    }
+
+    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);
+        } else if self.strip_mode.is_some() {
+            self.strip_mode.take();
+            cx.notify();
+        } else {
+            cx.propagate();
+        }
+    }
+    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(entry) = self.selected_ix.and_then(|ix| self.breakpoints.get_mut(ix)) else {
+            return;
+        };
+
+        if let Some(mode) = self.strip_mode {
+            let handle = self.input.focus_handle(cx);
+            if handle.is_focused(window) {
+                // Go back to the main strip. Save the result as well.
+                let text = self.input.read(cx).text(cx);
+
+                match mode {
+                    ActiveBreakpointStripMode::Log => match &entry.kind {
+                        BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+                            Self::edit_line_breakpoint_inner(
+                                &self.breakpoint_store,
+                                line_breakpoint.breakpoint.path.clone(),
+                                line_breakpoint.breakpoint.row,
+                                BreakpointEditAction::EditLogMessage(Arc::from(text)),
+                                cx,
+                            );
+                        }
+                        _ => {}
+                    },
+                    ActiveBreakpointStripMode::Condition => match &entry.kind {
+                        BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+                            Self::edit_line_breakpoint_inner(
+                                &self.breakpoint_store,
+                                line_breakpoint.breakpoint.path.clone(),
+                                line_breakpoint.breakpoint.row,
+                                BreakpointEditAction::EditCondition(Arc::from(text)),
+                                cx,
+                            );
+                        }
+                        _ => {}
+                    },
+                    ActiveBreakpointStripMode::HitCondition => match &entry.kind {
+                        BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+                            Self::edit_line_breakpoint_inner(
+                                &self.breakpoint_store,
+                                line_breakpoint.breakpoint.path.clone(),
+                                line_breakpoint.breakpoint.row,
+                                BreakpointEditAction::EditHitCondition(Arc::from(text)),
+                                cx,
+                            );
+                        }
+                        _ => {}
+                    },
+                }
+                self.focus_handle.focus(window);
+            } else {
+                handle.focus(window);
+            }
+
+            return;
+        }
+        match &mut entry.kind {
+            BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+                let path = line_breakpoint.breakpoint.path.clone();
+                let row = line_breakpoint.breakpoint.row;
+                self.go_to_line_breakpoint(path, row, window, cx);
+            }
+            BreakpointEntryKind::ExceptionBreakpoint(_) => {}
+        }
+    }
+
+    fn toggle_enable_breakpoint(
+        &mut self,
+        _: &ToggleEnableBreakpoint,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(entry) = self.selected_ix.and_then(|ix| self.breakpoints.get_mut(ix)) else {
+            return;
+        };
+        if self.strip_mode.is_some() {
+            if self.input.focus_handle(cx).contains_focused(window, cx) {
+                cx.propagate();
+                return;
+            }
+        }
+
+        match &mut entry.kind {
+            BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+                let path = line_breakpoint.breakpoint.path.clone();
+                let row = line_breakpoint.breakpoint.row;
+                self.edit_line_breakpoint(path, row, BreakpointEditAction::InvertState, cx);
+            }
+            BreakpointEntryKind::ExceptionBreakpoint(exception_breakpoint) => {
+                if let Some(session) = &self.session {
+                    let id = exception_breakpoint.id.clone();
+                    session.update(cx, |session, cx| {
+                        session.toggle_exception_breakpoint(&id, cx);
+                    });
+                }
+            }
+        }
+        cx.notify();
+    }
+
+    fn unset_breakpoint(
+        &mut self,
+        _: &UnsetBreakpoint,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(entry) = self.selected_ix.and_then(|ix| self.breakpoints.get_mut(ix)) else {
+            return;
+        };
+
+        match &mut entry.kind {
+            BreakpointEntryKind::LineBreakpoint(line_breakpoint) => {
+                let path = line_breakpoint.breakpoint.path.clone();
+                let row = line_breakpoint.breakpoint.row;
+                self.edit_line_breakpoint(path, row, BreakpointEditAction::Toggle, cx);
+            }
+            BreakpointEntryKind::ExceptionBreakpoint(_) => {}
+        }
+        cx.notify();
+    }
+
+    fn previous_breakpoint_property(
+        &mut self,
+        _: &PreviousBreakpointProperty,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let next_mode = match self.strip_mode {
+            Some(ActiveBreakpointStripMode::Log) => None,
+            Some(ActiveBreakpointStripMode::Condition) => Some(ActiveBreakpointStripMode::Log),
+            Some(ActiveBreakpointStripMode::HitCondition) => {
+                Some(ActiveBreakpointStripMode::Condition)
+            }
+            None => Some(ActiveBreakpointStripMode::HitCondition),
+        };
+        if let Some(mode) = next_mode {
+            self.set_active_breakpoint_property(mode, window, cx);
+        } else {
+            self.strip_mode.take();
+        }
+
+        cx.notify();
+    }
+    fn next_breakpoint_property(
+        &mut self,
+        _: &NextBreakpointProperty,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let next_mode = match self.strip_mode {
+            Some(ActiveBreakpointStripMode::Log) => Some(ActiveBreakpointStripMode::Condition),
+            Some(ActiveBreakpointStripMode::Condition) => {
+                Some(ActiveBreakpointStripMode::HitCondition)
+            }
+            Some(ActiveBreakpointStripMode::HitCondition) => None,
+            None => Some(ActiveBreakpointStripMode::Log),
+        };
+        if let Some(mode) = next_mode {
+            self.set_active_breakpoint_property(mode, window, cx);
+        } else {
+            self.strip_mode.take();
+        }
+        cx.notify();
     }
 
     fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -103,6 +490,40 @@ impl BreakpointList {
         }))
     }
 
+    fn render_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
+        let selected_ix = self.selected_ix;
+        let focus_handle = self.focus_handle.clone();
+        let supported_breakpoint_properties = self
+            .session
+            .as_ref()
+            .map(|session| SupportedBreakpointProperties::from(session.read(cx).capabilities()))
+            .unwrap_or_else(SupportedBreakpointProperties::empty);
+        let strip_mode = self.strip_mode;
+        uniform_list(
+            "breakpoint-list",
+            self.breakpoints.len(),
+            cx.processor(move |this, range: Range<usize>, _, _| {
+                range
+                    .clone()
+                    .zip(&mut this.breakpoints[range])
+                    .map(|(ix, breakpoint)| {
+                        breakpoint
+                            .render(
+                                strip_mode,
+                                supported_breakpoint_properties,
+                                ix,
+                                Some(ix) == selected_ix,
+                                focus_handle.clone(),
+                            )
+                            .into_any_element()
+                    })
+                    .collect()
+            }),
+        )
+        .track_scroll(self.scroll_handle.clone())
+        .flex_grow()
+    }
+
     fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
         if !(self.show_scrollbar || self.scrollbar_state.is_dragging()) {
             return None;
@@ -140,15 +561,96 @@ impl BreakpointList {
                 .children(Scrollbar::vertical(self.scrollbar_state.clone())),
         )
     }
+    pub(crate) fn render_control_strip(&self) -> AnyElement {
+        let selection_kind = self.selection_kind();
+        let focus_handle = self.focus_handle.clone();
+        let remove_breakpoint_tooltip = selection_kind.map(|(kind, _)| match kind {
+            SelectedBreakpointKind::Source => "Remove breakpoint from a breakpoint list",
+            SelectedBreakpointKind::Exception => {
+                "Exception Breakpoints cannot be removed from the breakpoint list"
+            }
+        });
+        let toggle_label = selection_kind.map(|(_, is_enabled)| {
+            if is_enabled {
+                (
+                    "Disable Breakpoint",
+                    "Disable a breakpoint without removing it from the list",
+                )
+            } else {
+                ("Enable Breakpoint", "Re-enable a breakpoint")
+            }
+        });
+
+        h_flex()
+            .gap_2()
+            .child(
+                IconButton::new(
+                    "disable-breakpoint-breakpoint-list",
+                    IconName::DebugDisabledBreakpoint,
+                )
+                .icon_size(IconSize::XSmall)
+                .when_some(toggle_label, |this, (label, meta)| {
+                    this.tooltip({
+                        let focus_handle = focus_handle.clone();
+                        move |window, cx| {
+                            Tooltip::with_meta_in(
+                                label,
+                                Some(&ToggleEnableBreakpoint),
+                                meta,
+                                &focus_handle,
+                                window,
+                                cx,
+                            )
+                        }
+                    })
+                })
+                .disabled(selection_kind.is_none())
+                .on_click({
+                    let focus_handle = focus_handle.clone();
+                    move |_, window, cx| {
+                        focus_handle.focus(window);
+                        window.dispatch_action(ToggleEnableBreakpoint.boxed_clone(), cx)
+                    }
+                }),
+            )
+            .child(
+                IconButton::new("remove-breakpoint-breakpoint-list", IconName::X)
+                    .icon_size(IconSize::XSmall)
+                    .icon_color(ui::Color::Error)
+                    .when_some(remove_breakpoint_tooltip, |this, tooltip| {
+                        this.tooltip({
+                            let focus_handle = focus_handle.clone();
+                            move |window, cx| {
+                                Tooltip::with_meta_in(
+                                    "Remove Breakpoint",
+                                    Some(&UnsetBreakpoint),
+                                    tooltip,
+                                    &focus_handle,
+                                    window,
+                                    cx,
+                                )
+                            }
+                        })
+                    })
+                    .disabled(
+                        selection_kind.map(|kind| kind.0) != Some(SelectedBreakpointKind::Source),
+                    )
+                    .on_click({
+                        let focus_handle = focus_handle.clone();
+                        move |_, window, cx| {
+                            focus_handle.focus(window);
+                            window.dispatch_action(UnsetBreakpoint.boxed_clone(), cx)
+                        }
+                    }),
+            )
+            .mr_2()
+            .into_any_element()
+    }
 }
+
 impl Render for BreakpointList {
-    fn render(
-        &mut self,
-        _window: &mut ui::Window,
-        cx: &mut ui::Context<Self>,
-    ) -> impl ui::IntoElement {
-        let old_len = self.breakpoints.len();
-        let breakpoints = self.breakpoint_store.read(cx).all_breakpoints(cx);
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
+        let breakpoints = self.breakpoint_store.read(cx).all_source_breakpoints(cx);
         self.breakpoints.clear();
         let weak = cx.weak_entity();
         let breakpoints = breakpoints.into_iter().flat_map(|(path, mut breakpoints)| {
@@ -183,7 +685,7 @@ impl Render for BreakpointList {
                     .map(ToOwned::to_owned)
                     .map(SharedString::from)?;
                 let weak = weak.clone();
-                let line = format!("Line {}", breakpoint.row + 1).into();
+                let line = breakpoint.row + 1;
                 Some(BreakpointEntry {
                     kind: BreakpointEntryKind::LineBreakpoint(LineBreakpoint {
                         name,
@@ -195,8 +697,8 @@ impl Render for BreakpointList {
                 })
             })
         });
-        let exception_breakpoints =
-            self.session
+        let exception_breakpoints = self.session.as_ref().into_iter().flat_map(|session| {
+            session
                 .read(cx)
                 .exception_breakpoints()
                 .map(|(data, is_enabled)| BreakpointEntry {
@@ -206,14 +708,13 @@ impl Render for BreakpointList {
                         is_enabled: *is_enabled,
                     }),
                     weak: weak.clone(),
-                });
+                })
+        });
         self.breakpoints
             .extend(breakpoints.chain(exception_breakpoints));
-        if self.breakpoints.len() != old_len {
-            self.list_state.reset(self.breakpoints.len());
-        }
         v_flex()
             .id("breakpoint-list")
+            .key_context("BreakpointList")
             .track_focus(&self.focus_handle)
             .on_hover(cx.listener(|this, hovered, window, cx| {
                 if *hovered {
@@ -224,183 +725,185 @@ impl Render for BreakpointList {
                     this.hide_scrollbar(window, cx);
                 }
             }))
+            .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_previous))
+            .on_action(cx.listener(Self::select_first))
+            .on_action(cx.listener(Self::select_last))
+            .on_action(cx.listener(Self::dismiss))
+            .on_action(cx.listener(Self::confirm))
+            .on_action(cx.listener(Self::toggle_enable_breakpoint))
+            .on_action(cx.listener(Self::unset_breakpoint))
+            .on_action(cx.listener(Self::next_breakpoint_property))
+            .on_action(cx.listener(Self::previous_breakpoint_property))
             .size_full()
             .m_0p5()
-            .child(list(self.list_state.clone()).flex_grow())
-            .children(self.render_vertical_scrollbar(cx))
+            .child(
+                v_flex()
+                    .size_full()
+                    .child(self.render_list(cx))
+                    .children(self.render_vertical_scrollbar(cx)),
+            )
+            .when_some(self.strip_mode, |this, _| {
+                this.child(Divider::horizontal()).child(
+                    h_flex()
+                        // .w_full()
+                        .m_0p5()
+                        .p_0p5()
+                        .border_1()
+                        .rounded_sm()
+                        .when(
+                            self.input.focus_handle(cx).contains_focused(window, cx),
+                            |this| {
+                                let colors = cx.theme().colors();
+                                let border = if self.input.read(cx).read_only(cx) {
+                                    colors.border_disabled
+                                } else {
+                                    colors.border_focused
+                                };
+                                this.border_color(border)
+                            },
+                        )
+                        .child(self.input.clone()),
+                )
+            })
     }
 }
+
 #[derive(Clone, Debug)]
 struct LineBreakpoint {
     name: SharedString,
     dir: Option<SharedString>,
-    line: SharedString,
+    line: u32,
     breakpoint: SourceBreakpoint,
 }
 
 impl LineBreakpoint {
-    fn render(self, weak: WeakEntity<BreakpointList>) -> ListItem {
-        let LineBreakpoint {
-            name,
-            dir,
-            line,
-            breakpoint,
-        } = self;
-        let icon_name = if breakpoint.state.is_enabled() {
+    fn render(
+        &mut self,
+        props: SupportedBreakpointProperties,
+        strip_mode: Option<ActiveBreakpointStripMode>,
+        ix: usize,
+        is_selected: bool,
+        focus_handle: FocusHandle,
+        weak: WeakEntity<BreakpointList>,
+    ) -> ListItem {
+        let icon_name = if self.breakpoint.state.is_enabled() {
             IconName::DebugBreakpoint
         } else {
             IconName::DebugDisabledBreakpoint
         };
-        let path = breakpoint.path;
-        let row = breakpoint.row;
+        let path = self.breakpoint.path.clone();
+        let row = self.breakpoint.row;
+        let is_enabled = self.breakpoint.state.is_enabled();
         let indicator = div()
             .id(SharedString::from(format!(
                 "breakpoint-ui-toggle-{:?}/{}:{}",
-                dir, name, line
+                self.dir, self.name, self.line
             )))
             .cursor_pointer()
+            .tooltip({
+                let focus_handle = focus_handle.clone();
+                move |window, cx| {
+                    Tooltip::for_action_in(
+                        if is_enabled {
+                            "Disable Breakpoint"
+                        } else {
+                            "Enable Breakpoint"
+                        },
+                        &ToggleEnableBreakpoint,
+                        &focus_handle,
+                        window,
+                        cx,
+                    )
+                }
+            })
             .on_click({
                 let weak = weak.clone();
                 let path = path.clone();
                 move |_, _, cx| {
-                    weak.update(cx, |this, cx| {
-                        this.breakpoint_store.update(cx, |this, cx| {
-                            if let Some((buffer, breakpoint)) =
-                                this.breakpoint_at_row(&path, row, cx)
-                            {
-                                this.toggle_breakpoint(
-                                    buffer,
-                                    breakpoint,
-                                    BreakpointEditAction::InvertState,
-                                    cx,
-                                );
-                            } else {
-                                log::error!("Couldn't find breakpoint at row event though it exists: row {row}")
-                            }
-                        })
+                    weak.update(cx, |breakpoint_list, cx| {
+                        breakpoint_list.edit_line_breakpoint(
+                            path.clone(),
+                            row,
+                            BreakpointEditAction::InvertState,
+                            cx,
+                        );
                     })
                     .ok();
                 }
             })
             .child(Indicator::icon(Icon::new(icon_name)).color(Color::Debugger))
             .on_mouse_down(MouseButton::Left, move |_, _, _| {});
+
         ListItem::new(SharedString::from(format!(
             "breakpoint-ui-item-{:?}/{}:{}",
-            dir, name, line
+            self.dir, self.name, self.line
         )))
+        .on_click({
+            let weak = weak.clone();
+            move |_, window, cx| {
+                weak.update(cx, |breakpoint_list, cx| {
+                    breakpoint_list.select_ix(Some(ix), window, cx);
+                })
+                .ok();
+            }
+        })
         .start_slot(indicator)
         .rounded()
-        .end_hover_slot(
-            IconButton::new(
-                SharedString::from(format!(
-                    "breakpoint-ui-on-click-go-to-line-remove-{:?}/{}:{}",
-                    dir, name, line
-                )),
-                IconName::Close,
-            )
-            .on_click({
-                let weak = weak.clone();
-                let path = path.clone();
-                move |_, _, cx| {
-                    weak.update(cx, |this, cx| {
-                        this.breakpoint_store.update(cx, |this, cx| {
-                            if let Some((buffer, breakpoint)) =
-                                this.breakpoint_at_row(&path, row, cx)
-                            {
-                                this.toggle_breakpoint(
-                                    buffer,
-                                    breakpoint,
-                                    BreakpointEditAction::Toggle,
-                                    cx,
-                                );
-                            } else {
-                                log::error!("Couldn't find breakpoint at row event though it exists: row {row}")
-                            }
-                        })
-                    })
-                    .ok();
-                }
-            })
-            .icon_size(ui::IconSize::XSmall),
-        )
+        .on_secondary_mouse_down(|_, _, cx| {
+            cx.stop_propagation();
+        })
         .child(
-            v_flex()
+            h_flex()
+                .w_full()
+                .mr_4()
+                .py_0p5()
+                .gap_1()
+                .min_h(px(26.))
+                .justify_between()
                 .id(SharedString::from(format!(
                     "breakpoint-ui-on-click-go-to-line-{:?}/{}:{}",
-                    dir, name, line
+                    self.dir, self.name, self.line
                 )))
-                .on_click(move |_, window, cx| {
-                    let path = path.clone();
+                .on_click({
                     let weak = weak.clone();
-                    let row = breakpoint.row;
-                    maybe!({
-                        let task = weak
-                            .update(cx, |this, cx| {
-                                this.worktree_store.update(cx, |this, cx| {
-                                    this.find_or_create_worktree(path, false, cx)
-                                })
-                            })
-                            .ok()?;
-                        window
-                            .spawn(cx, async move |cx| {
-                                let (worktree, relative_path) = task.await?;
-                                let worktree_id = worktree.update(cx, |this, _| this.id())?;
-                                let item = weak
-                                    .update_in(cx, |this, window, cx| {
-                                        this.workspace.update(cx, |this, cx| {
-                                            this.open_path(
-                                                (worktree_id, relative_path),
-                                                None,
-                                                true,
-                                                window,
-                                                cx,
-                                            )
-                                        })
-                                    })??
-                                    .await?;
-                                if let Some(editor) = item.downcast::<Editor>() {
-                                    editor
-                                        .update_in(cx, |this, window, cx| {
-                                            this.go_to_singleton_buffer_point(
-                                                Point { row, column: 0 },
-                                                window,
-                                                cx,
-                                            );
-                                        })
-                                        .ok();
-                                }
-                                Result::<_, anyhow::Error>::Ok(())
-                            })
-                            .detach();
-
-                        Some(())
-                    });
+                    move |_, window, cx| {
+                        weak.update(cx, |breakpoint_list, cx| {
+                            breakpoint_list.select_ix(Some(ix), window, cx);
+                            breakpoint_list.go_to_line_breakpoint(path.clone(), row, window, cx);
+                        })
+                        .ok();
+                    }
                 })
                 .cursor_pointer()
-                .py_1()
-                .items_center()
                 .child(
                     h_flex()
                         .gap_1()
                         .child(
-                            Label::new(name)
+                            Label::new(format!("{}:{}", self.name, self.line))
                                 .size(LabelSize::Small)
                                 .line_height_style(ui::LineHeightStyle::UiLabel),
                         )
-                        .children(dir.map(|dir| {
+                        .children(self.dir.clone().map(|dir| {
                             Label::new(dir)
                                 .color(Color::Muted)
                                 .size(LabelSize::Small)
                                 .line_height_style(ui::LineHeightStyle::UiLabel)
                         })),
                 )
-                .child(
-                    Label::new(line)
-                        .size(LabelSize::XSmall)
-                        .color(Color::Muted)
-                        .line_height_style(ui::LineHeightStyle::UiLabel),
-                ),
+                .child(BreakpointOptionsStrip {
+                    props,
+                    breakpoint: BreakpointEntry {
+                        kind: BreakpointEntryKind::LineBreakpoint(self.clone()),
+                        weak: weak,
+                    },
+                    is_selected,
+                    focus_handle,
+                    strip_mode,
+                    index: ix,
+                }),
         )
+        .toggle_state(is_selected)
     }
 }
 #[derive(Clone, Debug)]

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

@@ -2,24 +2,28 @@ use super::{
     stack_frame_list::{StackFrameList, StackFrameListEvent},
     variable_list::VariableList,
 };
+use alacritty_terminal::vte::ansi;
 use anyhow::Result;
 use collections::HashMap;
 use dap::OutputEvent;
-use editor::{CompletionProvider, Editor, EditorElement, EditorStyle, ExcerptId};
+use editor::{Bias, CompletionProvider, Editor, EditorElement, EditorStyle, ExcerptId};
 use fuzzy::StringMatchCandidate;
 use gpui::{
-    Context, Entity, FocusHandle, Focusable, Render, Subscription, Task, TextStyle, WeakEntity,
+    Action as _, AppContext, Context, Corner, Entity, FocusHandle, Focusable, HighlightStyle, Hsla,
+    Render, Subscription, Task, TextStyle, WeakEntity, actions,
 };
 use language::{Buffer, CodeLabel, ToOffset};
 use menu::Confirm;
 use project::{
-    Completion,
-    debugger::session::{CompletionsQuery, OutputToken, Session},
+    Completion, CompletionResponse,
+    debugger::session::{CompletionsQuery, OutputToken, Session, SessionEvent},
 };
 use settings::Settings;
-use std::{cell::RefCell, rc::Rc, usize};
-use theme::ThemeSettings;
-use ui::{Divider, prelude::*};
+use std::{cell::RefCell, ops::Range, rc::Rc, usize};
+use theme::{Theme, ThemeSettings};
+use ui::{ContextMenu, Divider, PopoverMenu, SplitButton, Tooltip, prelude::*};
+
+actions!(console, [WatchExpression]);
 
 pub struct Console {
     console: Entity<Editor>,
@@ -45,6 +49,7 @@ impl Console {
             let mut editor = Editor::multi_line(window, cx);
             editor.move_to_end(&editor::actions::MoveToEnd, window, cx);
             editor.set_read_only(true);
+            editor.disable_scrollbars_and_minimap(window, cx);
             editor.set_show_gutter(false, cx);
             editor.set_show_runnables(false, cx);
             editor.set_show_breakpoints(false, cx);
@@ -71,13 +76,24 @@ impl Console {
             editor.set_show_gutter(false, cx);
             editor.set_show_wrap_guides(false, cx);
             editor.set_show_indent_guides(false, cx);
-            editor.set_completion_provider(Some(Box::new(ConsoleQueryBarCompletionProvider(this))));
+            editor.set_completion_provider(Some(Rc::new(ConsoleQueryBarCompletionProvider(this))));
 
             editor
         });
 
-        let _subscriptions =
-            vec![cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events)];
+        let _subscriptions = vec![
+            cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events),
+            cx.subscribe_in(&session, window, |this, _, event, window, cx| {
+                if let SessionEvent::ConsoleOutput = event {
+                    this.update_output(window, cx)
+                }
+            }),
+            cx.on_focus(&focus_handle, window, |console, window, cx| {
+                if console.is_running(cx) {
+                    console.query_bar.focus_handle(cx).focus(window);
+                }
+            }),
+        ];
 
         Self {
             session,
@@ -97,8 +113,8 @@ impl Console {
         &self.console
     }
 
-    fn is_local(&self, cx: &Context<Self>) -> bool {
-        self.session.read(cx).is_local()
+    fn is_running(&self, cx: &Context<Self>) -> bool {
+        self.session.read(cx).is_running()
     }
 
     fn handle_stack_frame_list_events(
@@ -109,6 +125,7 @@ impl Console {
     ) {
         match event {
             StackFrameListEvent::SelectedStackFrameChanged(_) => cx.notify(),
+            StackFrameListEvent::BuiltEntries => {}
         }
     }
 
@@ -123,27 +140,237 @@ impl Console {
         cx: &mut App,
     ) {
         self.console.update(cx, |console, cx| {
-            let mut to_insert = String::default();
+            console.set_read_only(false);
+
             for event in events {
-                use std::fmt::Write;
+                let to_insert = format!("{}\n", event.output.trim_end());
+
+                let mut ansi_handler = ConsoleHandler::default();
+                let mut ansi_processor = ansi::Processor::<ansi::StdSyncHandler>::default();
+
+                let len = console.buffer().read(cx).len(cx);
+                ansi_processor.advance(&mut ansi_handler, to_insert.as_bytes());
+                let output = std::mem::take(&mut ansi_handler.output);
+                let mut spans = std::mem::take(&mut ansi_handler.spans);
+                let mut background_spans = std::mem::take(&mut ansi_handler.background_spans);
+                if ansi_handler.current_range_start < output.len() {
+                    spans.push((
+                        ansi_handler.current_range_start..output.len(),
+                        ansi_handler.current_color,
+                    ));
+                }
+                if ansi_handler.current_background_range_start < output.len() {
+                    background_spans.push((
+                        ansi_handler.current_background_range_start..output.len(),
+                        ansi_handler.current_background_color,
+                    ));
+                }
+                console.move_to_end(&editor::actions::MoveToEnd, window, cx);
+                console.insert(&output, window, cx);
+                let buffer = console.buffer().read(cx).snapshot(cx);
+
+                struct ConsoleAnsiHighlight;
+
+                for (range, color) in spans {
+                    let Some(color) = color else { continue };
+                    let start_offset = len + range.start;
+                    let range = start_offset..len + range.end;
+                    let range = buffer.anchor_after(range.start)..buffer.anchor_before(range.end);
+                    let style = HighlightStyle {
+                        color: Some(terminal_view::terminal_element::convert_color(
+                            &color,
+                            cx.theme(),
+                        )),
+                        ..Default::default()
+                    };
+                    console.highlight_text_key::<ConsoleAnsiHighlight>(
+                        start_offset,
+                        vec![range],
+                        style,
+                        cx,
+                    );
+                }
 
-                _ = write!(to_insert, "{}\n", event.output.trim_end());
+                for (range, color) in background_spans {
+                    let Some(color) = color else { continue };
+                    let start_offset = len + range.start;
+                    let range = start_offset..len + range.end;
+                    let range = buffer.anchor_after(range.start)..buffer.anchor_before(range.end);
+
+                    let color_fetcher: fn(&Theme) -> Hsla = match color {
+                        // Named and theme defined colors
+                        ansi::Color::Named(n) => match n {
+                            ansi::NamedColor::Black => |theme| theme.colors().terminal_ansi_black,
+                            ansi::NamedColor::Red => |theme| theme.colors().terminal_ansi_red,
+                            ansi::NamedColor::Green => |theme| theme.colors().terminal_ansi_green,
+                            ansi::NamedColor::Yellow => |theme| theme.colors().terminal_ansi_yellow,
+                            ansi::NamedColor::Blue => |theme| theme.colors().terminal_ansi_blue,
+                            ansi::NamedColor::Magenta => {
+                                |theme| theme.colors().terminal_ansi_magenta
+                            }
+                            ansi::NamedColor::Cyan => |theme| theme.colors().terminal_ansi_cyan,
+                            ansi::NamedColor::White => |theme| theme.colors().terminal_ansi_white,
+                            ansi::NamedColor::BrightBlack => {
+                                |theme| theme.colors().terminal_ansi_bright_black
+                            }
+                            ansi::NamedColor::BrightRed => {
+                                |theme| theme.colors().terminal_ansi_bright_red
+                            }
+                            ansi::NamedColor::BrightGreen => {
+                                |theme| theme.colors().terminal_ansi_bright_green
+                            }
+                            ansi::NamedColor::BrightYellow => {
+                                |theme| theme.colors().terminal_ansi_bright_yellow
+                            }
+                            ansi::NamedColor::BrightBlue => {
+                                |theme| theme.colors().terminal_ansi_bright_blue
+                            }
+                            ansi::NamedColor::BrightMagenta => {
+                                |theme| theme.colors().terminal_ansi_bright_magenta
+                            }
+                            ansi::NamedColor::BrightCyan => {
+                                |theme| theme.colors().terminal_ansi_bright_cyan
+                            }
+                            ansi::NamedColor::BrightWhite => {
+                                |theme| theme.colors().terminal_ansi_bright_white
+                            }
+                            ansi::NamedColor::Foreground => {
+                                |theme| theme.colors().terminal_foreground
+                            }
+                            ansi::NamedColor::Background => {
+                                |theme| theme.colors().terminal_background
+                            }
+                            ansi::NamedColor::Cursor => |theme| theme.players().local().cursor,
+                            ansi::NamedColor::DimBlack => {
+                                |theme| theme.colors().terminal_ansi_dim_black
+                            }
+                            ansi::NamedColor::DimRed => {
+                                |theme| theme.colors().terminal_ansi_dim_red
+                            }
+                            ansi::NamedColor::DimGreen => {
+                                |theme| theme.colors().terminal_ansi_dim_green
+                            }
+                            ansi::NamedColor::DimYellow => {
+                                |theme| theme.colors().terminal_ansi_dim_yellow
+                            }
+                            ansi::NamedColor::DimBlue => {
+                                |theme| theme.colors().terminal_ansi_dim_blue
+                            }
+                            ansi::NamedColor::DimMagenta => {
+                                |theme| theme.colors().terminal_ansi_dim_magenta
+                            }
+                            ansi::NamedColor::DimCyan => {
+                                |theme| theme.colors().terminal_ansi_dim_cyan
+                            }
+                            ansi::NamedColor::DimWhite => {
+                                |theme| theme.colors().terminal_ansi_dim_white
+                            }
+                            ansi::NamedColor::BrightForeground => {
+                                |theme| theme.colors().terminal_bright_foreground
+                            }
+                            ansi::NamedColor::DimForeground => {
+                                |theme| theme.colors().terminal_dim_foreground
+                            }
+                        },
+                        // 'True' colors
+                        ansi::Color::Spec(_) => |theme| theme.colors().editor_background,
+                        // 8 bit, indexed colors
+                        ansi::Color::Indexed(i) => {
+                            match i {
+                                // 0-15 are the same as the named colors above
+                                0 => |theme| theme.colors().terminal_ansi_black,
+                                1 => |theme| theme.colors().terminal_ansi_red,
+                                2 => |theme| theme.colors().terminal_ansi_green,
+                                3 => |theme| theme.colors().terminal_ansi_yellow,
+                                4 => |theme| theme.colors().terminal_ansi_blue,
+                                5 => |theme| theme.colors().terminal_ansi_magenta,
+                                6 => |theme| theme.colors().terminal_ansi_cyan,
+                                7 => |theme| theme.colors().terminal_ansi_white,
+                                8 => |theme| theme.colors().terminal_ansi_bright_black,
+                                9 => |theme| theme.colors().terminal_ansi_bright_red,
+                                10 => |theme| theme.colors().terminal_ansi_bright_green,
+                                11 => |theme| theme.colors().terminal_ansi_bright_yellow,
+                                12 => |theme| theme.colors().terminal_ansi_bright_blue,
+                                13 => |theme| theme.colors().terminal_ansi_bright_magenta,
+                                14 => |theme| theme.colors().terminal_ansi_bright_cyan,
+                                15 => |theme| theme.colors().terminal_ansi_bright_white,
+                                // 16-231 are a 6x6x6 RGB color cube, mapped to 0-255 using steps defined by XTerm.
+                                // See: https://github.com/xterm-x11/xterm-snapshots/blob/master/256colres.pl
+                                // 16..=231 => {
+                                //     let (r, g, b) = rgb_for_index(index as u8);
+                                //     rgba_color(
+                                //         if r == 0 { 0 } else { r * 40 + 55 },
+                                //         if g == 0 { 0 } else { g * 40 + 55 },
+                                //         if b == 0 { 0 } else { b * 40 + 55 },
+                                //     )
+                                // }
+                                // 232-255 are a 24-step grayscale ramp from (8, 8, 8) to (238, 238, 238).
+                                // 232..=255 => {
+                                //     let i = index as u8 - 232; // Align index to 0..24
+                                //     let value = i * 10 + 8;
+                                //     rgba_color(value, value, value)
+                                // }
+                                // For compatibility with the alacritty::Colors interface
+                                // See: https://github.com/alacritty/alacritty/blob/master/alacritty_terminal/src/term/color.rs
+                                _ => |_| gpui::black(),
+                            }
+                        }
+                    };
+
+                    console.highlight_background_key::<ConsoleAnsiHighlight>(
+                        start_offset,
+                        &[range],
+                        color_fetcher,
+                        cx,
+                    );
+                }
             }
 
-            console.set_read_only(false);
-            console.move_to_end(&editor::actions::MoveToEnd, window, cx);
-            console.insert(&to_insert, window, cx);
             console.set_read_only(true);
-
             cx.notify();
         });
     }
 
-    pub fn evaluate(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
+    pub fn watch_expression(
+        &mut self,
+        _: &WatchExpression,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         let expression = self.query_bar.update(cx, |editor, cx| {
             let expression = editor.text(cx);
+            cx.defer_in(window, |editor, window, cx| {
+                editor.clear(window, cx);
+            });
+
+            expression
+        });
+
+        self.session.update(cx, |session, cx| {
+            session
+                .evaluate(
+                    expression.clone(),
+                    Some(dap::EvaluateArgumentsContext::Repl),
+                    self.stack_frame_list.read(cx).opened_stack_frame_id(),
+                    None,
+                    cx,
+                )
+                .detach();
 
-            editor.clear(window, cx);
+            if let Some(stack_frame_id) = self.stack_frame_list.read(cx).opened_stack_frame_id() {
+                session
+                    .add_watcher(expression.into(), stack_frame_id, cx)
+                    .detach();
+            }
+        });
+    }
+
+    pub fn evaluate(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        let expression = self.query_bar.update(cx, |editor, cx| {
+            let expression = editor.text(cx);
+            cx.defer_in(window, |editor, window, cx| {
+                editor.clear(window, cx);
+            });
 
             expression
         });
@@ -153,7 +380,7 @@ impl Console {
                 .evaluate(
                     expression,
                     Some(dap::EvaluateArgumentsContext::Repl),
-                    self.stack_frame_list.read(cx).selected_stack_frame_id(),
+                    self.stack_frame_list.read(cx).opened_stack_frame_id(),
                     None,
                     cx,
                 )
@@ -161,17 +388,56 @@ impl Console {
         });
     }
 
+    fn render_submit_menu(
+        &self,
+        id: impl Into<ElementId>,
+        keybinding_target: Option<FocusHandle>,
+        cx: &App,
+    ) -> impl IntoElement {
+        PopoverMenu::new(id.into())
+            .trigger(
+                ui::ButtonLike::new_rounded_right("console-confirm-split-button-right")
+                    .layer(ui::ElevationIndex::ModalSurface)
+                    .size(ui::ButtonSize::None)
+                    .child(
+                        div()
+                            .px_1()
+                            .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
+                    ),
+            )
+            .when(
+                self.stack_frame_list
+                    .read(cx)
+                    .opened_stack_frame_id()
+                    .is_some(),
+                |this| {
+                    this.menu(move |window, cx| {
+                        Some(ContextMenu::build(window, cx, |context_menu, _, _| {
+                            context_menu
+                                .when_some(keybinding_target.clone(), |el, keybinding_target| {
+                                    el.context(keybinding_target.clone())
+                                })
+                                .action("Watch expression", WatchExpression.boxed_clone())
+                        }))
+                    })
+                },
+            )
+            .anchor(Corner::TopRight)
+    }
+
     fn render_console(&self, cx: &Context<Self>) -> impl IntoElement {
-        EditorElement::new(&self.console, self.editor_style(cx))
+        EditorElement::new(&self.console, Self::editor_style(&self.console, cx))
     }
 
-    fn editor_style(&self, cx: &Context<Self>) -> EditorStyle {
+    fn editor_style(editor: &Entity<Editor>, cx: &Context<Self>) -> EditorStyle {
+        let is_read_only = editor.read(cx).read_only(cx);
         let settings = ThemeSettings::get_global(cx);
+        let theme = cx.theme();
         let text_style = TextStyle {
-            color: if self.console.read(cx).read_only(cx) {
-                cx.theme().colors().text_disabled
+            color: if is_read_only {
+                theme.colors().text_muted
             } else {
-                cx.theme().colors().text
+                theme.colors().text
             },
             font_family: settings.buffer_font.family.clone(),
             font_features: settings.buffer_font.features.clone(),
@@ -181,22 +447,21 @@ impl Console {
             ..Default::default()
         };
         EditorStyle {
-            background: cx.theme().colors().editor_background,
-            local_player: cx.theme().players().local(),
+            background: theme.colors().editor_background,
+            local_player: theme.players().local(),
             text: text_style,
             ..Default::default()
         }
     }
 
     fn render_query_bar(&self, cx: &Context<Self>) -> impl IntoElement {
-        EditorElement::new(&self.query_bar, self.editor_style(cx))
+        EditorElement::new(&self.query_bar, Self::editor_style(&self.query_bar, cx))
     }
-}
 
-impl Render for Console {
-    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+    fn update_output(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         let session = self.session.clone();
         let token = self.last_token;
+
         self.update_output_task = cx.spawn_in(window, async move |this, cx| {
             _ = session.update_in(cx, move |session, window, cx| {
                 let (output, last_processed_token) = session.output(token);
@@ -211,16 +476,57 @@ impl Render for Console {
                 });
             });
         });
+    }
+}
+
+impl Render for Console {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let query_focus_handle = self.query_bar.focus_handle(cx);
 
         v_flex()
             .track_focus(&self.focus_handle)
             .key_context("DebugConsole")
             .on_action(cx.listener(Self::evaluate))
+            .on_action(cx.listener(Self::watch_expression))
             .size_full()
             .child(self.render_console(cx))
-            .when(self.is_local(cx), |this| {
-                this.child(Divider::horizontal())
-                    .child(self.render_query_bar(cx))
+            .when(self.is_running(cx), |this| {
+                this.child(Divider::horizontal()).child(
+                    h_flex()
+                        .gap_1()
+                        .bg(cx.theme().colors().editor_background)
+                        .child(self.render_query_bar(cx))
+                        .child(SplitButton::new(
+                            ui::ButtonLike::new_rounded_all(ElementId::Name(
+                                "split-button-left-confirm-button".into(),
+                            ))
+                            .on_click(move |_, window, cx| {
+                                window.dispatch_action(Box::new(Confirm), cx)
+                            })
+                            .tooltip({
+                                let query_focus_handle = query_focus_handle.clone();
+
+                                move |window, cx| {
+                                    Tooltip::for_action_in(
+                                        "Evaluate",
+                                        &Confirm,
+                                        &query_focus_handle,
+                                        window,
+                                        cx,
+                                    )
+                                }
+                            })
+                            .layer(ui::ElevationIndex::ModalSurface)
+                            .size(ui::ButtonSize::Compact)
+                            .child(Label::new("Evaluate")),
+                            self.render_submit_menu(
+                                ElementId::Name("split-button-right-confirm-button".into()),
+                                Some(query_focus_handle.clone()),
+                                cx,
+                            )
+                            .into_any_element(),
+                        )),
+                )
             })
             .border_2()
     }
@@ -243,9 +549,9 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider {
         _trigger: editor::CompletionContext,
         _window: &mut Window,
         cx: &mut Context<Editor>,
-    ) -> Task<Result<Option<Vec<Completion>>>> {
+    ) -> Task<Result<Vec<CompletionResponse>>> {
         let Some(console) = self.0.upgrade() else {
-            return Task::ready(Ok(None));
+            return Task::ready(Ok(Vec::new()));
         };
 
         let support_completions = console
@@ -263,16 +569,6 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider {
         }
     }
 
-    fn resolve_completions(
-        &self,
-        _buffer: Entity<Buffer>,
-        _completion_indices: Vec<usize>,
-        _completions: Rc<RefCell<Box<[Completion]>>>,
-        _cx: &mut Context<Editor>,
-    ) -> gpui::Task<gpui::Result<bool>> {
-        Task::ready(Ok(false))
-    }
-
     fn apply_additional_edits_for_completion(
         &self,
         _buffer: Entity<Buffer>,
@@ -280,19 +576,37 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider {
         _completion_index: usize,
         _push_to_history: bool,
         _cx: &mut Context<Editor>,
-    ) -> gpui::Task<gpui::Result<Option<language::Transaction>>> {
+    ) -> gpui::Task<anyhow::Result<Option<language::Transaction>>> {
         Task::ready(Ok(None))
     }
 
     fn is_completion_trigger(
         &self,
-        _buffer: &Entity<Buffer>,
-        _position: language::Anchor,
-        _text: &str,
+        buffer: &Entity<Buffer>,
+        position: language::Anchor,
+        text: &str,
         _trigger_in_words: bool,
-        _cx: &mut Context<Editor>,
+        menu_is_open: bool,
+        cx: &mut Context<Editor>,
     ) -> bool {
-        true
+        let snapshot = buffer.read(cx).snapshot();
+        if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input {
+            return false;
+        }
+
+        self.0
+            .read_with(cx, |console, cx| {
+                console
+                    .session
+                    .read(cx)
+                    .capabilities()
+                    .completion_trigger_characters
+                    .as_ref()
+                    .map(|triggers| triggers.contains(&text.to_string()))
+            })
+            .ok()
+            .flatten()
+            .unwrap_or(true)
     }
 }
 
@@ -303,7 +617,7 @@ impl ConsoleQueryBarCompletionProvider {
         buffer: &Entity<Buffer>,
         buffer_position: language::Anchor,
         cx: &mut Context<Editor>,
-    ) -> Task<Result<Option<Vec<Completion>>>> {
+    ) -> Task<Result<Vec<CompletionResponse>>> {
         let (variables, string_matches) = console.update(cx, |console, cx| {
             let mut variables = HashMap::default();
             let mut string_matches = Vec::default();
@@ -332,42 +646,62 @@ impl ConsoleQueryBarCompletionProvider {
             (variables, string_matches)
         });
 
-        let query = buffer.read(cx).text();
-
+        let snapshot = buffer.read(cx).text_snapshot();
+        let query = snapshot.text();
+        let replace_range = {
+            let buffer_offset = buffer_position.to_offset(&snapshot);
+            let reversed_chars = snapshot.reversed_chars_for_range(0..buffer_offset);
+            let mut word_len = 0;
+            for ch in reversed_chars {
+                if ch.is_alphanumeric() || ch == '_' {
+                    word_len += 1;
+                } else {
+                    break;
+                }
+            }
+            let word_start_offset = buffer_offset - word_len;
+            let start_anchor = snapshot.anchor_at(word_start_offset, Bias::Left);
+            start_anchor..buffer_position
+        };
         cx.spawn(async move |_, cx| {
+            const LIMIT: usize = 10;
             let matches = fuzzy::match_strings(
                 &string_matches,
                 &query,
                 true,
-                10,
+                true,
+                LIMIT,
                 &Default::default(),
                 cx.background_executor().clone(),
             )
             .await;
 
-            Ok(Some(
-                matches
-                    .iter()
-                    .filter_map(|string_match| {
-                        let variable_value = variables.get(&string_match.string)?;
-
-                        Some(project::Completion {
-                            replace_range: buffer_position..buffer_position,
-                            new_text: string_match.string.clone(),
-                            label: CodeLabel {
-                                filter_range: 0..string_match.string.len(),
-                                text: format!("{} {}", string_match.string.clone(), variable_value),
-                                runs: Vec::new(),
-                            },
-                            icon_path: None,
-                            documentation: None,
-                            confirm: None,
-                            source: project::CompletionSource::Custom,
-                            insert_text_mode: None,
-                        })
+            let completions = matches
+                .iter()
+                .filter_map(|string_match| {
+                    let variable_value = variables.get(&string_match.string)?;
+
+                    Some(project::Completion {
+                        replace_range: replace_range.clone(),
+                        new_text: string_match.string.clone(),
+                        label: CodeLabel {
+                            filter_range: 0..string_match.string.len(),
+                            text: format!("{} {}", string_match.string, variable_value),
+                            runs: Vec::new(),
+                        },
+                        icon_path: None,
+                        documentation: None,
+                        confirm: None,
+                        source: project::CompletionSource::Custom,
+                        insert_text_mode: None,
                     })
-                    .collect(),
-            ))
+                })
+                .collect::<Vec<_>>();
+
+            Ok(vec![project::CompletionResponse {
+                is_incomplete: completions.len() >= LIMIT,
+                completions,
+            }])
         })
     }
 
@@ -377,10 +711,10 @@ impl ConsoleQueryBarCompletionProvider {
         buffer: &Entity<Buffer>,
         buffer_position: language::Anchor,
         cx: &mut Context<Editor>,
-    ) -> Task<Result<Option<Vec<Completion>>>> {
+    ) -> Task<Result<Vec<CompletionResponse>>> {
         let completion_task = console.update(cx, |console, cx| {
             console.session.update(cx, |state, cx| {
-                let frame_id = console.stack_frame_list.read(cx).selected_stack_frame_id();
+                let frame_id = console.stack_frame_list.read(cx).opened_stack_frame_id();
 
                 state.completions(
                     CompletionsQuery::new(buffer.read(cx), buffer_position, frame_id),
@@ -392,60 +726,122 @@ impl ConsoleQueryBarCompletionProvider {
         cx.background_executor().spawn(async move {
             let completions = completion_task.await?;
 
-            Ok(Some(
-                completions
-                    .into_iter()
-                    .map(|completion| {
-                        let new_text = completion
-                            .text
-                            .as_ref()
-                            .unwrap_or(&completion.label)
-                            .to_owned();
-                        let mut word_bytes_length = 0;
-                        for chunk in snapshot
-                            .reversed_chunks_in_range(language::Anchor::MIN..buffer_position)
-                        {
-                            let mut processed_bytes = 0;
-                            if let Some(_) = chunk.chars().rfind(|c| {
-                                let is_whitespace = c.is_whitespace();
-                                if !is_whitespace {
-                                    processed_bytes += c.len_utf8();
-                                }
-
-                                is_whitespace
-                            }) {
-                                word_bytes_length += processed_bytes;
-                                break;
-                            } else {
-                                word_bytes_length += chunk.len();
-                            }
+            let completions = completions
+                .into_iter()
+                .map(|completion| {
+                    let new_text = completion
+                        .text
+                        .as_ref()
+                        .unwrap_or(&completion.label)
+                        .to_owned();
+                    let buffer_text = snapshot.text();
+                    let buffer_bytes = buffer_text.as_bytes();
+                    let new_bytes = new_text.as_bytes();
+
+                    let mut prefix_len = 0;
+                    for i in (0..new_bytes.len()).rev() {
+                        if buffer_bytes.ends_with(&new_bytes[0..i]) {
+                            prefix_len = i;
+                            break;
                         }
+                    }
 
-                        let buffer_offset = buffer_position.to_offset(&snapshot);
-                        let start = buffer_offset - word_bytes_length;
-                        let start = snapshot.anchor_before(start);
-                        let replace_range = start..buffer_position;
-
-                        project::Completion {
-                            replace_range,
-                            new_text,
-                            label: CodeLabel {
-                                filter_range: 0..completion.label.len(),
-                                text: completion.label,
-                                runs: Vec::new(),
-                            },
-                            icon_path: None,
-                            documentation: None,
-                            confirm: None,
-                            source: project::CompletionSource::BufferWord {
-                                word_range: buffer_position..language::Anchor::MAX,
-                                resolved: false,
-                            },
-                            insert_text_mode: None,
-                        }
-                    })
-                    .collect(),
-            ))
+                    let buffer_offset = buffer_position.to_offset(&snapshot);
+                    let start = buffer_offset - prefix_len;
+                    let start = snapshot.clip_offset(start, Bias::Left);
+                    let start = snapshot.anchor_before(start);
+                    let replace_range = start..buffer_position;
+
+                    project::Completion {
+                        replace_range,
+                        new_text,
+                        label: CodeLabel {
+                            filter_range: 0..completion.label.len(),
+                            text: completion.label,
+                            runs: Vec::new(),
+                        },
+                        icon_path: None,
+                        documentation: None,
+                        confirm: None,
+                        source: project::CompletionSource::BufferWord {
+                            word_range: buffer_position..language::Anchor::MAX,
+                            resolved: false,
+                        },
+                        insert_text_mode: None,
+                    }
+                })
+                .collect();
+
+            Ok(vec![project::CompletionResponse {
+                completions,
+                is_incomplete: false,
+            }])
         })
     }
 }
+
+#[derive(Default)]
+struct ConsoleHandler {
+    output: String,
+    spans: Vec<(Range<usize>, Option<ansi::Color>)>,
+    background_spans: Vec<(Range<usize>, Option<ansi::Color>)>,
+    current_range_start: usize,
+    current_background_range_start: usize,
+    current_color: Option<ansi::Color>,
+    current_background_color: Option<ansi::Color>,
+    pos: usize,
+}
+
+impl ConsoleHandler {
+    fn break_span(&mut self, color: Option<ansi::Color>) {
+        self.spans.push((
+            self.current_range_start..self.output.len(),
+            self.current_color,
+        ));
+        self.current_color = color;
+        self.current_range_start = self.pos;
+    }
+
+    fn break_background_span(&mut self, color: Option<ansi::Color>) {
+        self.background_spans.push((
+            self.current_background_range_start..self.output.len(),
+            self.current_background_color,
+        ));
+        self.current_background_color = color;
+        self.current_background_range_start = self.pos;
+    }
+}
+
+impl ansi::Handler for ConsoleHandler {
+    fn input(&mut self, c: char) {
+        self.output.push(c);
+        self.pos += c.len_utf8();
+    }
+
+    fn linefeed(&mut self) {
+        self.output.push('\n');
+        self.pos += 1;
+    }
+
+    fn put_tab(&mut self, count: u16) {
+        self.output
+            .extend(std::iter::repeat('\t').take(count as usize));
+        self.pos += count as usize;
+    }
+
+    fn terminal_attribute(&mut self, attr: ansi::Attr) {
+        match attr {
+            ansi::Attr::Foreground(color) => {
+                self.break_span(Some(color));
+            }
+            ansi::Attr::Background(color) => {
+                self.break_background_span(Some(color));
+            }
+            ansi::Attr::Reset => {
+                self.break_span(None);
+                self.break_background_span(None);
+            }
+            _ => {}
+        }
+    }
+}

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

@@ -1,24 +1,26 @@
 use anyhow::anyhow;
+use dap::Module;
 use gpui::{
-    AnyElement, Empty, Entity, FocusHandle, Focusable, ListState, MouseButton, Stateful,
-    Subscription, WeakEntity, list,
+    AnyElement, Entity, FocusHandle, Focusable, MouseButton, ScrollStrategy, Stateful,
+    Subscription, Task, UniformListScrollHandle, WeakEntity, uniform_list,
 };
 use project::{
     ProjectItem as _, ProjectPath,
     debugger::session::{Session, SessionEvent},
 };
-use std::{path::Path, sync::Arc};
+use std::{ops::Range, path::Path, sync::Arc};
 use ui::{Scrollbar, ScrollbarState, prelude::*};
-use util::maybe;
 use workspace::Workspace;
 
 pub struct ModuleList {
-    list: ListState,
-    invalidate: bool,
+    scroll_handle: UniformListScrollHandle,
+    selected_ix: Option<usize>,
     session: Entity<Session>,
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
     scrollbar_state: ScrollbarState,
+    entries: Vec<Module>,
+    _rebuild_task: Option<Task<()>>,
     _subscription: Subscription,
 }
 
@@ -28,40 +30,45 @@ impl ModuleList {
         workspace: WeakEntity<Workspace>,
         cx: &mut Context<Self>,
     ) -> Self {
-        let weak_entity = cx.weak_entity();
         let focus_handle = cx.focus_handle();
 
-        let list = ListState::new(
-            0,
-            gpui::ListAlignment::Top,
-            px(1000.),
-            move |ix, _window, cx| {
-                weak_entity
-                    .upgrade()
-                    .map(|module_list| module_list.update(cx, |this, cx| this.render_entry(ix, cx)))
-                    .unwrap_or(div().into_any())
-            },
-        );
-
         let _subscription = cx.subscribe(&session, |this, _, event, cx| match event {
             SessionEvent::Stopped(_) | SessionEvent::Modules => {
-                this.invalidate = true;
-                cx.notify();
+                if this._rebuild_task.is_some() {
+                    this.schedule_rebuild(cx);
+                }
             }
             _ => {}
         });
 
+        let scroll_handle = UniformListScrollHandle::new();
+
         Self {
-            scrollbar_state: ScrollbarState::new(list.clone()),
-            list,
+            scrollbar_state: ScrollbarState::new(scroll_handle.clone()),
+            scroll_handle,
             session,
             workspace,
             focus_handle,
+            entries: Vec::new(),
+            selected_ix: None,
             _subscription,
-            invalidate: true,
+            _rebuild_task: None,
         }
     }
 
+    fn schedule_rebuild(&mut self, cx: &mut Context<Self>) {
+        self._rebuild_task = Some(cx.spawn(async move |this, cx| {
+            this.update(cx, |this, cx| {
+                let modules = this
+                    .session
+                    .update(cx, |session, cx| session.modules(cx).to_owned());
+                this.entries = modules;
+                cx.notify();
+            })
+            .ok();
+        }));
+    }
+
     fn open_module(&mut self, path: Arc<Path>, window: &mut Window, cx: &mut Context<Self>) {
         cx.spawn_in(window, async move |this, cx| {
             let (worktree, relative_path) = this
@@ -111,36 +118,40 @@ impl ModuleList {
 
             anyhow::Ok(())
         })
-        .detach_and_log_err(cx);
+        .detach();
     }
 
     fn render_entry(&mut self, ix: usize, cx: &mut Context<Self>) -> AnyElement {
-        let Some(module) = maybe!({
-            self.session
-                .update(cx, |state, cx| state.modules(cx).get(ix).cloned())
-        }) else {
-            return Empty.into_any();
-        };
+        let module = self.entries[ix].clone();
 
         v_flex()
             .rounded_md()
             .w_full()
             .group("")
             .id(("module-list", ix))
+            .on_any_mouse_down(|_, _, cx| {
+                cx.stop_propagation();
+            })
             .when(module.path.is_some(), |this| {
                 this.on_click({
-                    let path = module.path.as_deref().map(|path| Arc::<Path>::from(Path::new(path)));
+                    let path = module
+                        .path
+                        .as_deref()
+                        .map(|path| Arc::<Path>::from(Path::new(path)));
                     cx.listener(move |this, _, window, cx| {
+                        this.selected_ix = Some(ix);
                         if let Some(path) = path.as_ref() {
                             this.open_module(path.clone(), window, cx);
-                        } else {
-                            log::error!("Wasn't able to find module path, but was still able to click on module list entry");
                         }
+                        cx.notify();
                     })
                 })
             })
             .p_1()
             .hover(|s| s.bg(cx.theme().colors().element_hover))
+            .when(Some(ix) == self.selected_ix, |s| {
+                s.bg(cx.theme().colors().element_hover)
+            })
             .child(h_flex().gap_0p5().text_ui_sm(cx).child(module.name.clone()))
             .child(
                 h_flex()
@@ -188,6 +199,97 @@ impl ModuleList {
             .cursor_default()
             .children(Scrollbar::vertical(self.scrollbar_state.clone()))
     }
+
+    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(ix) = self.selected_ix else { return };
+        let Some(entry) = self.entries.get(ix) else {
+            return;
+        };
+        let Some(path) = entry.path.as_deref() else {
+            return;
+        };
+        let path = Arc::from(Path::new(path));
+        self.open_module(path, window, cx);
+    }
+
+    fn select_ix(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
+        self.selected_ix = ix;
+        if let Some(ix) = ix {
+            self.scroll_handle
+                .scroll_to_item(ix, ScrollStrategy::Center);
+        }
+        cx.notify();
+    }
+
+    fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
+        let ix = match self.selected_ix {
+            _ if self.entries.len() == 0 => None,
+            None => Some(0),
+            Some(ix) => {
+                if ix == self.entries.len() - 1 {
+                    Some(0)
+                } else {
+                    Some(ix + 1)
+                }
+            }
+        };
+        self.select_ix(ix, cx);
+    }
+
+    fn select_previous(
+        &mut self,
+        _: &menu::SelectPrevious,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let ix = match self.selected_ix {
+            _ if self.entries.len() == 0 => None,
+            None => Some(self.entries.len() - 1),
+            Some(ix) => {
+                if ix == 0 {
+                    Some(self.entries.len() - 1)
+                } else {
+                    Some(ix - 1)
+                }
+            }
+        };
+        self.select_ix(ix, cx);
+    }
+
+    fn select_first(
+        &mut self,
+        _: &menu::SelectFirst,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let ix = if self.entries.len() > 0 {
+            Some(0)
+        } else {
+            None
+        };
+        self.select_ix(ix, cx);
+    }
+
+    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+        let ix = if self.entries.len() > 0 {
+            Some(self.entries.len() - 1)
+        } else {
+            None
+        };
+        self.select_ix(ix, cx);
+    }
+
+    fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        uniform_list(
+            "module-list",
+            self.entries.len(),
+            cx.processor(|this, range: Range<usize>, _window, cx| {
+                range.map(|ix| this.render_entry(ix, cx)).collect()
+            }),
+        )
+        .track_scroll(self.scroll_handle.clone())
+        .size_full()
+    }
 }
 
 impl Focusable for ModuleList {
@@ -197,21 +299,20 @@ impl Focusable for ModuleList {
 }
 
 impl Render for ModuleList {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        if self.invalidate {
-            let len = self
-                .session
-                .update(cx, |session, cx| session.modules(cx).len());
-            self.list.reset(len);
-            self.invalidate = false;
-            cx.notify();
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        if self._rebuild_task.is_none() {
+            self.schedule_rebuild(cx);
         }
-
         div()
             .track_focus(&self.focus_handle)
+            .on_action(cx.listener(Self::select_last))
+            .on_action(cx.listener(Self::select_first))
+            .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_previous))
+            .on_action(cx.listener(Self::confirm))
             .size_full()
             .p_1()
-            .child(list(self.list.clone()).size_full())
+            .child(self.render_list(window, cx))
             .child(self.render_vertical_scrollbar(cx))
     }
 }

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

@@ -2,45 +2,50 @@ use std::path::Path;
 use std::sync::Arc;
 use std::time::Duration;
 
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use dap::StackFrameId;
 use gpui::{
-    AnyElement, Entity, EventEmitter, FocusHandle, Focusable, ListState, MouseButton, Stateful,
-    Subscription, Task, WeakEntity, list,
+    AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState, MouseButton,
+    Stateful, Subscription, Task, WeakEntity, list,
 };
+use util::debug_panic;
 
+use crate::StackTraceView;
 use language::PointUtf16;
 use project::debugger::breakpoint_store::ActiveStackFrame;
 use project::debugger::session::{Session, SessionEvent, StackFrame};
 use project::{ProjectItem, ProjectPath};
 use ui::{Scrollbar, ScrollbarState, Tooltip, prelude::*};
-use util::ResultExt;
-use workspace::Workspace;
+use workspace::{ItemHandle, Workspace};
 
 use super::RunningState;
 
 #[derive(Debug)]
 pub enum StackFrameListEvent {
     SelectedStackFrameChanged(StackFrameId),
+    BuiltEntries,
 }
 
 pub struct StackFrameList {
-    list: ListState,
     focus_handle: FocusHandle,
     _subscription: Subscription,
     session: Entity<Session>,
     state: WeakEntity<RunningState>,
     entries: Vec<StackFrameEntry>,
     workspace: WeakEntity<Workspace>,
-    selected_stack_frame_id: Option<StackFrameId>,
+    selected_ix: Option<usize>,
+    opened_stack_frame_id: Option<StackFrameId>,
     scrollbar_state: ScrollbarState,
+    list_state: ListState,
+    error: Option<SharedString>,
     _refresh_task: Task<()>,
 }
 
-#[allow(clippy::large_enum_variant)]
 #[derive(Debug, PartialEq, Eq)]
 pub enum StackFrameEntry {
     Normal(dap::StackFrame),
+    /// Used to indicate that the frame is artificial and is a visual label or separator
+    Label(dap::StackFrame),
     Collapsed(Vec<dap::StackFrame>),
 }
 
@@ -52,23 +57,8 @@ impl StackFrameList {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
-        let weak_entity = cx.weak_entity();
         let focus_handle = cx.focus_handle();
 
-        let list = ListState::new(
-            0,
-            gpui::ListAlignment::Top,
-            px(1000.),
-            move |ix, _window, cx| {
-                weak_entity
-                    .upgrade()
-                    .map(|stack_frame_list| {
-                        stack_frame_list.update(cx, |this, cx| this.render_entry(ix, cx))
-                    })
-                    .unwrap_or(div().into_any())
-            },
-        );
-
         let _subscription =
             cx.subscribe_in(&session, window, |this, _, event, window, cx| match event {
                 SessionEvent::Threads => {
@@ -80,16 +70,27 @@ impl StackFrameList {
                 _ => {}
             });
 
+        let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.), {
+            let this = cx.weak_entity();
+            move |ix, _window, cx| {
+                this.update(cx, |this, cx| this.render_entry(ix, cx))
+                    .unwrap_or(div().into_any())
+            }
+        });
+        let scrollbar_state = ScrollbarState::new(list_state.clone());
+
         let mut this = Self {
-            scrollbar_state: ScrollbarState::new(list.clone()),
-            list,
             session,
             workspace,
             focus_handle,
             state,
             _subscription,
             entries: Default::default(),
-            selected_stack_frame_id: None,
+            error: None,
+            selected_ix: None,
+            opened_stack_frame_id: None,
+            list_state,
+            scrollbar_state,
             _refresh_task: Task::ready(()),
         };
         this.schedule_refresh(true, window, cx);
@@ -101,39 +102,42 @@ impl StackFrameList {
         &self.entries
     }
 
-    #[cfg(test)]
-    pub(crate) fn flatten_entries(&self) -> Vec<dap::StackFrame> {
+    pub(crate) fn flatten_entries(
+        &self,
+        show_collapsed: bool,
+        show_labels: bool,
+    ) -> Vec<dap::StackFrame> {
         self.entries
             .iter()
             .flat_map(|frame| match frame {
                 StackFrameEntry::Normal(frame) => vec![frame.clone()],
-                StackFrameEntry::Collapsed(frames) => frames.clone(),
+                StackFrameEntry::Label(frame) if show_labels => vec![frame.clone()],
+                StackFrameEntry::Collapsed(frames) if show_collapsed => frames.clone(),
+                _ => vec![],
             })
             .collect::<Vec<_>>()
     }
 
-    fn stack_frames(&self, cx: &mut App) -> Vec<StackFrame> {
-        self.state
-            .read_with(cx, |state, _| state.thread_id)
-            .log_err()
-            .flatten()
-            .map(|thread_id| {
-                self.session
-                    .update(cx, |this, cx| this.stack_frames(thread_id, cx))
-            })
-            .unwrap_or_default()
+    fn stack_frames(&self, cx: &mut App) -> Result<Vec<StackFrame>> {
+        if let Ok(Some(thread_id)) = self.state.read_with(cx, |state, _| state.thread_id) {
+            self.session
+                .update(cx, |this, cx| this.stack_frames(thread_id, cx))
+        } else {
+            Ok(Vec::default())
+        }
     }
 
     #[cfg(test)]
     pub(crate) fn dap_stack_frames(&self, cx: &mut App) -> Vec<dap::StackFrame> {
         self.stack_frames(cx)
+            .unwrap_or_default()
             .into_iter()
             .map(|stack_frame| stack_frame.dap.clone())
             .collect()
     }
 
-    pub fn selected_stack_frame_id(&self) -> Option<StackFrameId> {
-        self.selected_stack_frame_id
+    pub fn opened_stack_frame_id(&self) -> Option<StackFrameId> {
+        self.opened_stack_frame_id
     }
 
     pub(super) fn schedule_refresh(
@@ -148,7 +152,7 @@ impl StackFrameList {
             let debounce = this
                 .update(cx, |this, cx| {
                     let new_stack_frames = this.stack_frames(cx);
-                    new_stack_frames.is_empty() && !this.entries.is_empty()
+                    new_stack_frames.unwrap_or_default().is_empty() && !this.entries.is_empty()
                 })
                 .ok()
                 .unwrap_or_default();
@@ -166,27 +170,59 @@ impl StackFrameList {
 
     pub fn build_entries(
         &mut self,
-        select_first_stack_frame: bool,
+        open_first_stack_frame: bool,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        let old_selected_frame_id = self
+            .selected_ix
+            .and_then(|ix| self.entries.get(ix))
+            .and_then(|entry| match entry {
+                StackFrameEntry::Normal(stack_frame) => Some(stack_frame.id),
+                StackFrameEntry::Collapsed(_) | StackFrameEntry::Label(_) => None,
+            });
         let mut entries = Vec::new();
         let mut collapsed_entries = Vec::new();
-        let mut current_stack_frame = None;
-
-        let stack_frames = self.stack_frames(cx);
+        let mut first_stack_frame = None;
+        let mut first_stack_frame_with_path = None;
+
+        let stack_frames = match self.stack_frames(cx) {
+            Ok(stack_frames) => stack_frames,
+            Err(e) => {
+                self.error = Some(format!("{}", e).into());
+                self.entries.clear();
+                self.selected_ix = None;
+                self.list_state.reset(0);
+                cx.emit(StackFrameListEvent::BuiltEntries);
+                cx.notify();
+                return;
+            }
+        };
         for stack_frame in &stack_frames {
             match stack_frame.dap.presentation_hint {
-                Some(dap::StackFramePresentationHint::Deemphasize) => {
+                Some(dap::StackFramePresentationHint::Deemphasize)
+                | Some(dap::StackFramePresentationHint::Subtle) => {
                     collapsed_entries.push(stack_frame.dap.clone());
                 }
+                Some(dap::StackFramePresentationHint::Label) => {
+                    entries.push(StackFrameEntry::Label(stack_frame.dap.clone()));
+                }
                 _ => {
                     let collapsed_entries = std::mem::take(&mut collapsed_entries);
                     if !collapsed_entries.is_empty() {
                         entries.push(StackFrameEntry::Collapsed(collapsed_entries.clone()));
                     }
 
-                    current_stack_frame.get_or_insert(&stack_frame.dap);
+                    first_stack_frame.get_or_insert(entries.len());
+
+                    if stack_frame
+                        .dap
+                        .source
+                        .as_ref()
+                        .is_some_and(|source| source.path.is_some())
+                    {
+                        first_stack_frame_with_path.get_or_insert(entries.len());
+                    }
                     entries.push(StackFrameEntry::Normal(stack_frame.dap.clone()));
                 }
             }
@@ -196,70 +232,64 @@ impl StackFrameList {
         if !collapsed_entries.is_empty() {
             entries.push(StackFrameEntry::Collapsed(collapsed_entries.clone()));
         }
+        self.entries = entries;
 
-        std::mem::swap(&mut self.entries, &mut entries);
-        self.list.reset(self.entries.len());
-
-        if let Some(current_stack_frame) = current_stack_frame.filter(|_| select_first_stack_frame)
+        if let Some(ix) = first_stack_frame_with_path
+            .or(first_stack_frame)
+            .filter(|_| open_first_stack_frame)
         {
-            self.select_stack_frame(current_stack_frame, true, window, cx)
-                .detach_and_log_err(cx);
+            self.select_ix(Some(ix), cx);
+            self.activate_selected_entry(window, cx);
+        } else if let Some(old_selected_frame_id) = old_selected_frame_id {
+            let ix = self.entries.iter().position(|entry| match entry {
+                StackFrameEntry::Normal(frame) => frame.id == old_selected_frame_id,
+                StackFrameEntry::Collapsed(_) | StackFrameEntry::Label(_) => false,
+            });
+            self.selected_ix = ix;
         }
 
+        self.list_state.reset(self.entries.len());
+        cx.emit(StackFrameListEvent::BuiltEntries);
         cx.notify();
     }
 
-    pub fn go_to_selected_stack_frame(&mut self, window: &Window, cx: &mut Context<Self>) {
-        if let Some(selected_stack_frame_id) = self.selected_stack_frame_id {
-            let frame = self
-                .entries
-                .iter()
-                .find_map(|entry| match entry {
-                    StackFrameEntry::Normal(dap) => {
-                        if dap.id == selected_stack_frame_id {
-                            Some(dap)
-                        } else {
-                            None
-                        }
-                    }
-                    StackFrameEntry::Collapsed(daps) => {
-                        daps.iter().find(|dap| dap.id == selected_stack_frame_id)
-                    }
-                })
-                .cloned();
-
-            if let Some(frame) = frame.as_ref() {
-                self.select_stack_frame(frame, true, window, cx)
-                    .detach_and_log_err(cx);
-            }
-        }
-    }
-
-    pub fn select_stack_frame(
+    pub fn go_to_stack_frame(
         &mut self,
-        stack_frame: &dap::StackFrame,
-        go_to_stack_frame: bool,
-        window: &Window,
+        stack_frame_id: StackFrameId,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        self.selected_stack_frame_id = Some(stack_frame.id);
-
-        cx.emit(StackFrameListEvent::SelectedStackFrameChanged(
-            stack_frame.id,
-        ));
-        cx.notify();
-
-        if !go_to_stack_frame {
-            return Task::ready(Ok(()));
+        let Some(stack_frame) = self
+            .entries
+            .iter()
+            .flat_map(|entry| match entry {
+                StackFrameEntry::Label(stack_frame) => std::slice::from_ref(stack_frame),
+                StackFrameEntry::Normal(stack_frame) => std::slice::from_ref(stack_frame),
+                StackFrameEntry::Collapsed(stack_frames) => stack_frames.as_slice(),
+            })
+            .find(|stack_frame| stack_frame.id == stack_frame_id)
+            .cloned()
+        else {
+            return Task::ready(Err(anyhow!("No stack frame for ID")));
         };
+        self.go_to_stack_frame_inner(stack_frame, window, cx)
+    }
 
-        let row = (stack_frame.line.saturating_sub(1)) as u32;
-
-        let Some(abs_path) = self.abs_path_from_stack_frame(&stack_frame) else {
+    fn go_to_stack_frame_inner(
+        &mut self,
+        stack_frame: dap::StackFrame,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        let stack_frame_id = stack_frame.id;
+        self.opened_stack_frame_id = Some(stack_frame_id);
+        let Some(abs_path) = Self::abs_path_from_stack_frame(&stack_frame) else {
             return Task::ready(Err(anyhow!("Project path not found")));
         };
-
-        let stack_frame_id = stack_frame.id;
+        let row = stack_frame.line.saturating_sub(1) as u32;
+        cx.emit(StackFrameListEvent::SelectedStackFrameChanged(
+            stack_frame_id,
+        ));
         cx.spawn_in(window, async move |this, cx| {
             let (worktree, relative_path) = this
                 .update(cx, |this, cx| {
@@ -286,20 +316,31 @@ impl StackFrameList {
                     })
                 })??
                 .await?;
-            let position = buffer.update(cx, |this, _| {
+            let position = buffer.read_with(cx, |this, _| {
                 this.snapshot().anchor_after(PointUtf16::new(row, 0))
             })?;
             this.update_in(cx, |this, window, cx| {
                 this.workspace.update(cx, |workspace, cx| {
-                    let project_path = buffer.read(cx).project_path(cx).ok_or_else(|| {
-                        anyhow!("Could not select a stack frame for unnamed buffer")
-                    })?;
+                    let project_path = buffer
+                        .read(cx)
+                        .project_path(cx)
+                        .context("Could not select a stack frame for unnamed buffer")?;
+
+                    let open_preview = !workspace
+                        .item_of_type::<StackTraceView>(cx)
+                        .map(|viewer| {
+                            workspace
+                                .active_item(cx)
+                                .is_some_and(|item| item.item_id() == viewer.item_id())
+                        })
+                        .unwrap_or_default();
+
                     anyhow::Ok(workspace.open_path_preview(
                         project_path,
                         None,
-                        false,
                         true,
                         true,
+                        open_preview,
                         window,
                         cx,
                     ))
@@ -308,9 +349,9 @@ impl StackFrameList {
             .await?;
 
             this.update(cx, |this, cx| {
-                let Some(thread_id) = this.state.read_with(cx, |state, _| state.thread_id)? else {
-                    return Err(anyhow!("No selected thread ID found"));
-                };
+                let thread_id = this.state.read_with(cx, |state, _| {
+                    state.thread_id.context("No selected thread ID found")
+                })??;
 
                 this.workspace.update(cx, |workspace, cx| {
                     let breakpoint_store = workspace.project().read(cx).breakpoint_store();
@@ -332,11 +373,12 @@ impl StackFrameList {
         })
     }
 
-    fn abs_path_from_stack_frame(&self, stack_frame: &dap::StackFrame) -> Option<Arc<Path>> {
+    pub(crate) fn abs_path_from_stack_frame(stack_frame: &dap::StackFrame) -> Option<Arc<Path>> {
         stack_frame.source.as_ref().and_then(|s| {
             s.path
                 .as_deref()
                 .map(|path| Arc::<Path>::from(Path::new(path)))
+                .filter(|path| path.is_absolute())
         })
     }
 
@@ -346,13 +388,41 @@ impl StackFrameList {
         });
     }
 
+    fn render_label_entry(
+        &self,
+        stack_frame: &dap::StackFrame,
+        _cx: &mut Context<Self>,
+    ) -> AnyElement {
+        h_flex()
+            .rounded_md()
+            .justify_between()
+            .w_full()
+            .group("")
+            .id(("label-stack-frame", stack_frame.id))
+            .p_1()
+            .on_any_mouse_down(|_, _, cx| {
+                cx.stop_propagation();
+            })
+            .child(
+                v_flex().justify_center().gap_0p5().child(
+                    Label::new(stack_frame.name.clone())
+                        .size(LabelSize::Small)
+                        .weight(FontWeight::BOLD)
+                        .truncate()
+                        .color(Color::Info),
+                ),
+            )
+            .into_any()
+    }
+
     fn render_normal_entry(
         &self,
+        ix: usize,
         stack_frame: &dap::StackFrame,
         cx: &mut Context<Self>,
     ) -> AnyElement {
         let source = stack_frame.source.clone();
-        let is_selected_frame = Some(stack_frame.id) == self.selected_stack_frame_id;
+        let is_selected_frame = Some(ix) == self.selected_ix;
 
         let path = source.clone().and_then(|s| s.path.or(s.name));
         let formatted_path = path.map(|path| format!("{}:{}", path, stack_frame.line,));
@@ -388,12 +458,12 @@ impl StackFrameList {
             .when(is_selected_frame, |this| {
                 this.bg(cx.theme().colors().element_hover)
             })
-            .on_click(cx.listener({
-                let stack_frame = stack_frame.clone();
-                move |this, _, window, cx| {
-                    this.select_stack_frame(&stack_frame, true, window, cx)
-                        .detach_and_log_err(cx);
-                }
+            .on_any_mouse_down(|_, _, cx| {
+                cx.stop_propagation();
+            })
+            .on_click(cx.listener(move |this, _, window, cx| {
+                this.selected_ix = Some(ix);
+                this.activate_selected_entry(window, cx);
             }))
             .hover(|style| style.bg(cx.theme().colors().element_hover).cursor_pointer())
             .child(
@@ -448,19 +518,17 @@ impl StackFrameList {
             .into_any()
     }
 
-    pub fn expand_collapsed_entry(
-        &mut self,
-        ix: usize,
-        stack_frames: &Vec<dap::StackFrame>,
-        cx: &mut Context<Self>,
-    ) {
-        self.entries.splice(
-            ix..ix + 1,
-            stack_frames
-                .iter()
-                .map(|frame| StackFrameEntry::Normal(frame.clone())),
-        );
-        self.list.reset(self.entries.len());
+    pub(crate) fn expand_collapsed_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
+        let Some(StackFrameEntry::Collapsed(stack_frames)) = self.entries.get_mut(ix) else {
+            return;
+        };
+        let entries = std::mem::take(stack_frames)
+            .into_iter()
+            .map(StackFrameEntry::Normal);
+        self.entries.splice(ix..ix + 1, entries);
+        self.selected_ix = Some(ix);
+        self.list_state.reset(self.entries.len());
+        cx.emit(StackFrameListEvent::BuiltEntries);
         cx.notify();
     }
 
@@ -471,6 +539,7 @@ impl StackFrameList {
         cx: &mut Context<Self>,
     ) -> AnyElement {
         let first_stack_frame = &stack_frames[0];
+        let is_selected = Some(ix) == self.selected_ix;
 
         h_flex()
             .rounded_md()
@@ -479,11 +548,15 @@ impl StackFrameList {
             .group("")
             .id(("stack-frame", first_stack_frame.id))
             .p_1()
-            .on_click(cx.listener({
-                let stack_frames = stack_frames.clone();
-                move |this, _, _window, cx| {
-                    this.expand_collapsed_entry(ix, &stack_frames, cx);
-                }
+            .when(is_selected, |this| {
+                this.bg(cx.theme().colors().element_hover)
+            })
+            .on_any_mouse_down(|_, _, cx| {
+                cx.stop_propagation();
+            })
+            .on_click(cx.listener(move |this, _, window, cx| {
+                this.selected_ix = Some(ix);
+                this.activate_selected_entry(window, cx);
             }))
             .hover(|style| style.bg(cx.theme().colors().element_hover).cursor_pointer())
             .child(
@@ -506,7 +579,8 @@ impl StackFrameList {
 
     fn render_entry(&self, ix: usize, cx: &mut Context<Self>) -> AnyElement {
         match &self.entries[ix] {
-            StackFrameEntry::Normal(stack_frame) => self.render_normal_entry(stack_frame, cx),
+            StackFrameEntry::Label(stack_frame) => self.render_label_entry(stack_frame, cx),
+            StackFrameEntry::Normal(stack_frame) => self.render_normal_entry(ix, stack_frame, cx),
             StackFrameEntry::Collapsed(stack_frames) => {
                 self.render_collapsed_entry(ix, stack_frames, cx)
             }
@@ -545,15 +619,130 @@ impl StackFrameList {
             .cursor_default()
             .children(Scrollbar::vertical(self.scrollbar_state.clone()))
     }
+
+    fn select_ix(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
+        self.selected_ix = ix;
+        cx.notify();
+    }
+
+    fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
+        let ix = match self.selected_ix {
+            _ if self.entries.len() == 0 => None,
+            None => Some(0),
+            Some(ix) => {
+                if ix == self.entries.len() - 1 {
+                    Some(0)
+                } else {
+                    Some(ix + 1)
+                }
+            }
+        };
+        self.select_ix(ix, cx);
+    }
+
+    fn select_previous(
+        &mut self,
+        _: &menu::SelectPrevious,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let ix = match self.selected_ix {
+            _ if self.entries.len() == 0 => None,
+            None => Some(self.entries.len() - 1),
+            Some(ix) => {
+                if ix == 0 {
+                    Some(self.entries.len() - 1)
+                } else {
+                    Some(ix - 1)
+                }
+            }
+        };
+        self.select_ix(ix, cx);
+    }
+
+    fn select_first(
+        &mut self,
+        _: &menu::SelectFirst,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let ix = if self.entries.len() > 0 {
+            Some(0)
+        } else {
+            None
+        };
+        self.select_ix(ix, cx);
+    }
+
+    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+        let ix = if self.entries.len() > 0 {
+            Some(self.entries.len() - 1)
+        } else {
+            None
+        };
+        self.select_ix(ix, cx);
+    }
+
+    fn activate_selected_entry(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(ix) = self.selected_ix else {
+            return;
+        };
+        let Some(entry) = self.entries.get_mut(ix) else {
+            return;
+        };
+        match entry {
+            StackFrameEntry::Normal(stack_frame) => {
+                let stack_frame = stack_frame.clone();
+                self.go_to_stack_frame_inner(stack_frame, window, cx)
+                    .detach_and_log_err(cx)
+            }
+            StackFrameEntry::Label(_) => {
+                debug_panic!("You should not be able to select a label stack frame")
+            }
+            StackFrameEntry::Collapsed(_) => self.expand_collapsed_entry(ix, cx),
+        }
+        cx.notify();
+    }
+
+    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        self.activate_selected_entry(window, cx);
+    }
+
+    fn render_list(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        div()
+            .p_1()
+            .size_full()
+            .child(list(self.list_state.clone()).size_full())
+    }
 }
 
 impl Render for StackFrameList {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         div()
             .track_focus(&self.focus_handle)
             .size_full()
-            .p_1()
-            .child(list(self.list.clone()).size_full())
+            .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_previous))
+            .on_action(cx.listener(Self::select_first))
+            .on_action(cx.listener(Self::select_last))
+            .on_action(cx.listener(Self::confirm))
+            .when_some(self.error.clone(), |el, error| {
+                el.child(
+                    h_flex()
+                        .bg(cx.theme().status().warning_background)
+                        .border_b_1()
+                        .border_color(cx.theme().status().warning_border)
+                        .pl_1()
+                        .child(Icon::new(IconName::Warning).color(Color::Warning))
+                        .gap_2()
+                        .child(
+                            Label::new(error)
+                                .size(LabelSize::Small)
+                                .color(Color::Warning),
+                        ),
+                )
+            })
+            .child(self.render_list(window, cx))
             .child(self.render_vertical_scrollbar(cx))
     }
 }

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

@@ -1,18 +1,32 @@
 use super::stack_frame_list::{StackFrameList, StackFrameListEvent};
-use dap::{ScopePresentationHint, StackFrameId, VariablePresentationHintKind, VariableReference};
+use dap::{
+    ScopePresentationHint, StackFrameId, VariablePresentationHint, VariablePresentationHintKind,
+    VariableReference,
+};
 use editor::Editor;
 use gpui::{
-    AnyElement, ClickEvent, ClipboardItem, Context, DismissEvent, Entity, FocusHandle, Focusable,
-    Hsla, MouseButton, MouseDownEvent, Point, Stateful, Subscription, TextStyleRefinement,
-    UniformListScrollHandle, actions, anchored, deferred, uniform_list,
+    Action, AnyElement, ClickEvent, ClipboardItem, Context, DismissEvent, Empty, Entity,
+    FocusHandle, Focusable, Hsla, MouseButton, MouseDownEvent, Point, Stateful, Subscription,
+    TextStyleRefinement, UniformListScrollHandle, actions, anchored, deferred, uniform_list,
 };
 use menu::{SelectFirst, SelectLast, SelectNext, SelectPrevious};
-use project::debugger::session::{Session, SessionEvent};
+use project::debugger::session::{Session, SessionEvent, Watcher};
 use std::{collections::HashMap, ops::Range, sync::Arc};
-use ui::{ContextMenu, ListItem, Scrollbar, ScrollbarState, prelude::*};
-use util::{debug_panic, maybe};
-
-actions!(variable_list, [ExpandSelectedEntry, CollapseSelectedEntry]);
+use ui::{ContextMenu, ListItem, ScrollableHandle, Scrollbar, ScrollbarState, Tooltip, prelude::*};
+use util::debug_panic;
+
+actions!(
+    variable_list,
+    [
+        ExpandSelectedEntry,
+        CollapseSelectedEntry,
+        CopyVariableName,
+        CopyVariableValue,
+        EditVariable,
+        AddWatch,
+        RemoveWatch,
+    ]
+);
 
 #[derive(Debug, Copy, Clone, PartialEq, Eq)]
 pub(crate) struct EntryState {
@@ -29,6 +43,13 @@ pub(crate) struct EntryPath {
 }
 
 impl EntryPath {
+    fn for_watcher(expression: impl Into<SharedString>) -> Self {
+        Self {
+            leaf_name: Some(expression.into()),
+            indices: Arc::new([]),
+        }
+    }
+
     fn for_scope(scope_name: impl Into<SharedString>) -> Self {
         Self {
             leaf_name: Some(scope_name.into()),
@@ -59,11 +80,19 @@ impl EntryPath {
 
 #[derive(Debug, Clone, PartialEq)]
 enum EntryKind {
+    Watcher(Watcher),
     Variable(dap::Variable),
     Scope(dap::Scope),
 }
 
 impl EntryKind {
+    fn as_watcher(&self) -> Option<&Watcher> {
+        match self {
+            EntryKind::Watcher(watcher) => Some(watcher),
+            _ => None,
+        }
+    }
+
     fn as_variable(&self) -> Option<&dap::Variable> {
         match self {
             EntryKind::Variable(dap) => Some(dap),
@@ -78,9 +107,10 @@ impl EntryKind {
         }
     }
 
-    #[allow(dead_code)]
+    #[cfg(test)]
     fn name(&self) -> &str {
         match self {
+            EntryKind::Watcher(watcher) => &watcher.expression,
             EntryKind::Variable(dap) => &dap.name,
             EntryKind::Scope(dap) => &dap.name,
         }
@@ -94,6 +124,10 @@ struct ListEntry {
 }
 
 impl ListEntry {
+    fn as_watcher(&self) -> Option<&Watcher> {
+        self.dap_kind.as_watcher()
+    }
+
     fn as_variable(&self) -> Option<&dap::Variable> {
         self.dap_kind.as_variable()
     }
@@ -105,6 +139,7 @@ impl ListEntry {
     fn item_id(&self) -> ElementId {
         use std::fmt::Write;
         let mut id = match &self.dap_kind {
+            EntryKind::Watcher(watcher) => format!("watcher-{}", watcher.expression),
             EntryKind::Variable(dap) => format!("variable-{}", dap.name),
             EntryKind::Scope(dap) => format!("scope-{}", dap.name),
         };
@@ -117,6 +152,7 @@ impl ListEntry {
     fn item_value_id(&self) -> ElementId {
         use std::fmt::Write;
         let mut id = match &self.dap_kind {
+            EntryKind::Watcher(watcher) => format!("watcher-{}", watcher.expression),
             EntryKind::Variable(dap) => format!("variable-{}", dap.name),
             EntryKind::Scope(dap) => format!("scope-{}", dap.name),
         };
@@ -128,6 +164,11 @@ impl ListEntry {
     }
 }
 
+struct VariableColor {
+    name: Option<Hsla>,
+    value: Option<Hsla>,
+}
+
 pub struct VariableList {
     entries: Vec<ListEntry>,
     entry_states: HashMap<EntryPath, EntryState>,
@@ -154,12 +195,15 @@ impl VariableList {
 
         let _subscriptions = vec![
             cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events),
-            cx.subscribe(&session, |this, _, event, _| match event {
+            cx.subscribe(&session, |this, _, event, cx| match event {
                 SessionEvent::Stopped(_) => {
                     this.selection.take();
                     this.edited_path.take();
                     this.selected_stack_frame_id.take();
                 }
+                SessionEvent::Variables | SessionEvent::Watchers => {
+                    this.build_entries(cx);
+                }
                 _ => {}
             }),
             cx.on_focus_out(&focus_handle, window, |this, _, _, cx| {
@@ -204,6 +248,7 @@ impl VariableList {
         };
 
         let mut entries = vec![];
+
         let scopes: Vec<_> = self.session.update(cx, |session, cx| {
             session.scopes(stack_frame_id, cx).iter().cloned().collect()
         });
@@ -237,11 +282,27 @@ impl VariableList {
             })
             .collect::<Vec<_>>();
 
+        let watches = self.session.read(cx).watchers().clone();
+        stack.extend(
+            watches
+                .into_values()
+                .map(|watcher| {
+                    (
+                        watcher.variables_reference,
+                        watcher.variables_reference,
+                        EntryPath::for_watcher(watcher.expression.clone()),
+                        EntryKind::Watcher(watcher.clone()),
+                    )
+                })
+                .collect::<Vec<_>>(),
+        );
+
         let scopes_count = stack.len();
 
         while let Some((container_reference, variables_reference, mut path, dap_kind)) = stack.pop()
         {
             match &dap_kind {
+                EntryKind::Watcher(watcher) => path = path.with_child(watcher.expression.clone()),
                 EntryKind::Variable(dap) => path = path.with_name(dap.name.clone().into()),
                 EntryKind::Scope(dap) => path = path.with_child(dap.name.clone().into()),
             }
@@ -300,8 +361,12 @@ impl VariableList {
         match event {
             StackFrameListEvent::SelectedStackFrameChanged(stack_frame_id) => {
                 self.selected_stack_frame_id = Some(*stack_frame_id);
-                cx.notify();
+                self.session.update(cx, |session, cx| {
+                    session.refresh_watchers(*stack_frame_id, cx);
+                });
+                self.build_entries(cx);
             }
+            StackFrameListEvent::BuiltEntries => {}
         }
     }
 
@@ -310,7 +375,7 @@ impl VariableList {
             .iter()
             .filter_map(|entry| match &entry.dap_kind {
                 EntryKind::Variable(dap) => Some(dap.clone()),
-                EntryKind::Scope(_) => None,
+                EntryKind::Scope(_) | EntryKind::Watcher { .. } => None,
             })
             .collect()
     }
@@ -329,6 +394,9 @@ impl VariableList {
                     .and_then(|entry| Some(entry).zip(self.entry_states.get(&entry.path)))?;
 
                 match &entry.dap_kind {
+                    EntryKind::Watcher { .. } => {
+                        Some(self.render_watcher(entry, *state, window, cx))
+                    }
                     EntryKind::Variable(_) => Some(self.render_variable(entry, *state, window, cx)),
                     EntryKind::Scope(_) => Some(self.render_scope(entry, *state, cx)),
                 }
@@ -343,27 +411,27 @@ impl VariableList {
         };
 
         entry.is_expanded = !entry.is_expanded;
-        cx.notify();
+        self.build_entries(cx);
     }
 
     fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
-        self.cancel_variable_edit(&Default::default(), window, cx);
+        self.cancel(&Default::default(), window, cx);
         if let Some(variable) = self.entries.first() {
             self.selection = Some(variable.path.clone());
-            cx.notify();
+            self.build_entries(cx);
         }
     }
 
     fn select_last(&mut self, _: &SelectLast, window: &mut Window, cx: &mut Context<Self>) {
-        self.cancel_variable_edit(&Default::default(), window, cx);
+        self.cancel(&Default::default(), window, cx);
         if let Some(variable) = self.entries.last() {
             self.selection = Some(variable.path.clone());
-            cx.notify();
+            self.build_entries(cx);
         }
     }
 
     fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
-        self.cancel_variable_edit(&Default::default(), window, cx);
+        self.cancel(&Default::default(), window, cx);
         if let Some(selection) = &self.selection {
             let index = self.entries.iter().enumerate().find_map(|(ix, var)| {
                 if &var.path == selection && ix > 0 {
@@ -377,7 +445,7 @@ impl VariableList {
                 index.and_then(|ix| self.entries.get(ix).map(|var| var.path.clone()))
             {
                 self.selection = Some(new_selection);
-                cx.notify();
+                self.build_entries(cx);
             } else {
                 self.select_last(&SelectLast, window, cx);
             }
@@ -387,7 +455,7 @@ impl VariableList {
     }
 
     fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
-        self.cancel_variable_edit(&Default::default(), window, cx);
+        self.cancel(&Default::default(), window, cx);
         if let Some(selection) = &self.selection {
             let index = self.entries.iter().enumerate().find_map(|(ix, var)| {
                 if &var.path == selection {
@@ -401,7 +469,7 @@ impl VariableList {
                 index.and_then(|ix| self.entries.get(ix).map(|var| var.path.clone()))
             {
                 self.selection = Some(new_selection);
-                cx.notify();
+                self.build_entries(cx);
             } else {
                 self.select_first(&SelectFirst, window, cx);
             }
@@ -410,40 +478,38 @@ impl VariableList {
         }
     }
 
-    fn cancel_variable_edit(
-        &mut self,
-        _: &menu::Cancel,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
+    fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
         self.edited_path.take();
         self.focus_handle.focus(window);
         cx.notify();
     }
 
-    fn confirm_variable_edit(
-        &mut self,
-        _: &menu::Confirm,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let res = maybe!({
-            let (var_path, editor) = self.edited_path.take()?;
-            let state = self.entry_states.get(&var_path)?;
+    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
+        if let Some((var_path, editor)) = self.edited_path.take() {
+            let Some(state) = self.entry_states.get(&var_path) else {
+                return;
+            };
+
             let variables_reference = state.parent_reference;
-            let name = var_path.leaf_name?;
+            let Some(name) = var_path.leaf_name else {
+                return;
+            };
+
+            let Some(stack_frame_id) = self.selected_stack_frame_id else {
+                return;
+            };
+
             let value = editor.read(cx).text(cx);
 
             self.session.update(cx, |session, cx| {
-                session.set_variable_value(variables_reference, name.into(), value, cx)
+                session.set_variable_value(
+                    stack_frame_id,
+                    variables_reference,
+                    name.into(),
+                    value,
+                    cx,
+                )
             });
-            Some(())
-        });
-
-        if res.is_none() {
-            log::error!(
-                "Couldn't confirm variable edit because variable doesn't have a leaf name or a parent reference id"
-            );
         }
     }
 
@@ -463,7 +529,7 @@ impl VariableList {
                 self.select_prev(&SelectPrevious, window, cx);
             } else {
                 entry_state.is_expanded = false;
-                cx.notify();
+                self.build_entries(cx);
             }
         }
     }
@@ -484,45 +550,43 @@ impl VariableList {
                 self.select_next(&SelectNext, window, cx);
             } else {
                 entry_state.is_expanded = true;
-                cx.notify();
+                self.build_entries(cx);
             }
         }
     }
 
-    fn deploy_variable_context_menu(
+    fn deploy_list_entry_context_menu(
         &mut self,
-        variable: ListEntry,
+        entry: ListEntry,
         position: Point<Pixels>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let Some(dap_var) = variable.as_variable() else {
-            debug_panic!("Trying to open variable context menu on a scope");
-            return;
-        };
-
-        let variable_value = dap_var.value.clone();
-        let variable_name = dap_var.name.clone();
-        let this = cx.entity().clone();
+        let supports_set_variable = self
+            .session
+            .read(cx)
+            .capabilities()
+            .supports_set_variable
+            .unwrap_or_default();
 
         let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
-            menu.entry("Copy name", None, move |_, cx| {
-                cx.write_to_clipboard(ClipboardItem::new_string(variable_name.clone()))
-            })
-            .entry("Copy value", None, {
-                let variable_value = variable_value.clone();
-                move |_, cx| {
-                    cx.write_to_clipboard(ClipboardItem::new_string(variable_value.clone()))
-                }
+            menu.when(entry.as_variable().is_some(), |menu| {
+                menu.action("Copy Name", CopyVariableName.boxed_clone())
+                    .action("Copy Value", CopyVariableValue.boxed_clone())
+                    .when(supports_set_variable, |menu| {
+                        menu.action("Edit Value", EditVariable.boxed_clone())
+                    })
+                    .action("Watch Variable", AddWatch.boxed_clone())
             })
-            .entry("Set value", None, move |window, cx| {
-                this.update(cx, |variable_list, cx| {
-                    let editor = Self::create_variable_editor(&variable_value, window, cx);
-                    variable_list.edited_path = Some((variable.path.clone(), editor));
-
-                    cx.notify();
-                });
+            .when(entry.as_watcher().is_some(), |menu| {
+                menu.action("Copy Name", CopyVariableName.boxed_clone())
+                    .action("Copy Value", CopyVariableValue.boxed_clone())
+                    .when(supports_set_variable, |menu| {
+                        menu.action("Edit Value", EditVariable.boxed_clone())
+                    })
+                    .action("Remove Watch", RemoveWatch.boxed_clone())
             })
+            .context(self.focus_handle.clone())
         });
 
         cx.focus_view(&context_menu, window);
@@ -543,6 +607,128 @@ impl VariableList {
         self.open_context_menu = Some((context_menu, position, subscription));
     }
 
+    fn copy_variable_name(
+        &mut self,
+        _: &CopyVariableName,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(selection) = self.selection.as_ref() else {
+            return;
+        };
+
+        let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
+            return;
+        };
+
+        let variable_name = match &entry.dap_kind {
+            EntryKind::Variable(dap) => dap.name.clone(),
+            EntryKind::Watcher(watcher) => watcher.expression.to_string(),
+            EntryKind::Scope(_) => return,
+        };
+
+        cx.write_to_clipboard(ClipboardItem::new_string(variable_name));
+    }
+
+    fn copy_variable_value(
+        &mut self,
+        _: &CopyVariableValue,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(selection) = self.selection.as_ref() else {
+            return;
+        };
+
+        let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
+            return;
+        };
+
+        let variable_value = match &entry.dap_kind {
+            EntryKind::Variable(dap) => dap.value.clone(),
+            EntryKind::Watcher(watcher) => watcher.value.to_string(),
+            EntryKind::Scope(_) => return,
+        };
+
+        cx.write_to_clipboard(ClipboardItem::new_string(variable_value));
+    }
+
+    fn edit_variable(&mut self, _: &EditVariable, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(selection) = self.selection.as_ref() else {
+            return;
+        };
+
+        let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
+            return;
+        };
+
+        let variable_value = match &entry.dap_kind {
+            EntryKind::Watcher(watcher) => watcher.value.to_string(),
+            EntryKind::Variable(variable) => variable.value.clone(),
+            EntryKind::Scope(_) => return,
+        };
+
+        let editor = Self::create_variable_editor(&variable_value, window, cx);
+        self.edited_path = Some((entry.path.clone(), editor));
+
+        cx.notify();
+    }
+
+    fn add_watcher(&mut self, _: &AddWatch, _: &mut Window, cx: &mut Context<Self>) {
+        let Some(selection) = self.selection.as_ref() else {
+            return;
+        };
+
+        let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
+            return;
+        };
+
+        let Some(variable) = entry.as_variable() else {
+            return;
+        };
+
+        let Some(stack_frame_id) = self.selected_stack_frame_id else {
+            return;
+        };
+
+        let add_watcher_task = self.session.update(cx, |session, cx| {
+            let expression = variable
+                .evaluate_name
+                .clone()
+                .unwrap_or_else(|| variable.name.clone());
+
+            session.add_watcher(expression.into(), stack_frame_id, cx)
+        });
+
+        cx.spawn(async move |this, cx| {
+            add_watcher_task.await?;
+
+            this.update(cx, |this, cx| {
+                this.build_entries(cx);
+            })
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn remove_watcher(&mut self, _: &RemoveWatch, _: &mut Window, cx: &mut Context<Self>) {
+        let Some(selection) = self.selection.as_ref() else {
+            return;
+        };
+
+        let Some(entry) = self.entries.iter().find(|entry| &entry.path == selection) else {
+            return;
+        };
+
+        let Some(watcher) = entry.as_watcher() else {
+            return;
+        };
+
+        self.session.update(cx, |session, _| {
+            session.remove_watcher(watcher.expression.clone());
+        });
+        self.build_entries(cx);
+    }
+
     #[track_caller]
     #[cfg(test)]
     pub(crate) fn assert_visual_entries(&self, expected: Vec<&str>) {
@@ -593,6 +779,7 @@ impl VariableList {
 
         for entry in self.entries.iter() {
             match &entry.dap_kind {
+                EntryKind::Watcher { .. } => continue,
                 EntryKind::Variable(dap) => scopes[idx].1.push(dap.clone()),
                 EntryKind::Scope(scope) => {
                     if scopes.len() > 0 {
@@ -642,6 +829,288 @@ impl VariableList {
         editor
     }
 
+    fn variable_color(
+        &self,
+        presentation_hint: Option<&VariablePresentationHint>,
+        cx: &Context<Self>,
+    ) -> VariableColor {
+        let syntax_color_for = |name| cx.theme().syntax().get(name).color;
+        let name = if self.disabled {
+            Some(Color::Disabled.color(cx))
+        } else {
+            match presentation_hint
+                .as_ref()
+                .and_then(|hint| hint.kind.as_ref())
+                .unwrap_or(&VariablePresentationHintKind::Unknown)
+            {
+                VariablePresentationHintKind::Class
+                | VariablePresentationHintKind::BaseClass
+                | VariablePresentationHintKind::InnerClass
+                | VariablePresentationHintKind::MostDerivedClass => syntax_color_for("type"),
+                VariablePresentationHintKind::Data => syntax_color_for("variable"),
+                VariablePresentationHintKind::Unknown | _ => syntax_color_for("variable"),
+            }
+        };
+        let value = self
+            .disabled
+            .then(|| Color::Disabled.color(cx))
+            .or_else(|| syntax_color_for("variable.special"));
+
+        VariableColor { name, value }
+    }
+
+    fn render_variable_value(
+        &self,
+        entry: &ListEntry,
+        variable_color: &VariableColor,
+        value: String,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        if !value.is_empty() {
+            div()
+                .w_full()
+                .id(entry.item_value_id())
+                .map(|this| {
+                    if let Some((_, editor)) = self
+                        .edited_path
+                        .as_ref()
+                        .filter(|(path, _)| path == &entry.path)
+                    {
+                        this.child(div().size_full().px_2().child(editor.clone()))
+                    } else {
+                        this.text_color(cx.theme().colors().text_muted)
+                            .when(
+                                !self.disabled
+                                    && self
+                                        .session
+                                        .read(cx)
+                                        .capabilities()
+                                        .supports_set_variable
+                                        .unwrap_or_default(),
+                                |this| {
+                                    let path = entry.path.clone();
+                                    let variable_value = value.clone();
+                                    this.on_click(cx.listener(
+                                        move |this, click: &ClickEvent, window, cx| {
+                                            if click.down.click_count < 2 {
+                                                return;
+                                            }
+                                            let editor = Self::create_variable_editor(
+                                                &variable_value,
+                                                window,
+                                                cx,
+                                            );
+                                            this.edited_path = Some((path.clone(), editor));
+
+                                            cx.notify();
+                                        },
+                                    ))
+                                },
+                            )
+                            .child(
+                                Label::new(format!("=  {}", &value))
+                                    .single_line()
+                                    .truncate()
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted)
+                                    .when_some(variable_color.value, |this, color| {
+                                        this.color(Color::from(color))
+                                    }),
+                            )
+                    }
+                })
+                .into_any_element()
+        } else {
+            Empty.into_any_element()
+        }
+    }
+
+    fn center_truncate_string(s: &str, mut max_chars: usize) -> String {
+        const ELLIPSIS: &str = "...";
+        const MIN_LENGTH: usize = 3;
+
+        max_chars = max_chars.max(MIN_LENGTH);
+
+        let char_count = s.chars().count();
+        if char_count <= max_chars {
+            return s.to_string();
+        }
+
+        if ELLIPSIS.len() + MIN_LENGTH > max_chars {
+            return s.chars().take(MIN_LENGTH).collect();
+        }
+
+        let available_chars = max_chars - ELLIPSIS.len();
+
+        let start_chars = available_chars / 2;
+        let end_chars = available_chars - start_chars;
+        let skip_chars = char_count - end_chars;
+
+        let mut start_boundary = 0;
+        let mut end_boundary = s.len();
+
+        for (i, (byte_idx, _)) in s.char_indices().enumerate() {
+            if i == start_chars {
+                start_boundary = byte_idx.max(MIN_LENGTH);
+            }
+
+            if i == skip_chars {
+                end_boundary = byte_idx;
+            }
+        }
+
+        if start_boundary >= end_boundary {
+            return s.chars().take(MIN_LENGTH).collect();
+        }
+
+        format!("{}{}{}", &s[..start_boundary], ELLIPSIS, &s[end_boundary..])
+    }
+
+    fn render_watcher(
+        &self,
+        entry: &ListEntry,
+        state: EntryState,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        let Some(watcher) = &entry.as_watcher() else {
+            debug_panic!("Called render watcher on non watcher variable list entry variant");
+            return div().into_any_element();
+        };
+
+        let variable_color = self.variable_color(watcher.presentation_hint.as_ref(), cx);
+
+        let is_selected = self
+            .selection
+            .as_ref()
+            .is_some_and(|selection| selection == &entry.path);
+        let var_ref = watcher.variables_reference;
+
+        let colors = get_entry_color(cx);
+        let bg_hover_color = if !is_selected {
+            colors.hover
+        } else {
+            colors.default
+        };
+        let border_color = if is_selected {
+            colors.marked_active
+        } else {
+            colors.default
+        };
+        let path = entry.path.clone();
+
+        let weak = cx.weak_entity();
+        let focus_handle = self.focus_handle.clone();
+        let watcher_len = (self.list_handle.content_size().width.0 / 12.0).floor() - 3.0;
+        let watcher_len = watcher_len as usize;
+
+        div()
+            .id(entry.item_id())
+            .group("variable_list_entry")
+            .pl_2()
+            .border_1()
+            .border_r_2()
+            .border_color(border_color)
+            .flex()
+            .w_full()
+            .h_full()
+            .hover(|style| style.bg(bg_hover_color))
+            .on_click(cx.listener({
+                let path = path.clone();
+                move |this, _, _window, cx| {
+                    this.selection = Some(path.clone());
+                    cx.notify();
+                }
+            }))
+            .child(
+                ListItem::new(SharedString::from(format!(
+                    "watcher-{}",
+                    watcher.expression
+                )))
+                .selectable(false)
+                .disabled(self.disabled)
+                .selectable(false)
+                .indent_level(state.depth)
+                .indent_step_size(px(10.))
+                .always_show_disclosure_icon(true)
+                .when(var_ref > 0, |list_item| {
+                    list_item.toggle(state.is_expanded).on_toggle(cx.listener({
+                        let var_path = entry.path.clone();
+                        move |this, _, _, cx| {
+                            this.session.update(cx, |session, cx| {
+                                session.variables(var_ref, cx);
+                            });
+
+                            this.toggle_entry(&var_path, cx);
+                        }
+                    }))
+                })
+                .on_secondary_mouse_down(cx.listener({
+                    let path = path.clone();
+                    let entry = entry.clone();
+                    move |this, event: &MouseDownEvent, window, cx| {
+                        this.selection = Some(path.clone());
+                        this.deploy_list_entry_context_menu(
+                            entry.clone(),
+                            event.position,
+                            window,
+                            cx,
+                        );
+                        cx.stop_propagation();
+                    }
+                }))
+                .child(
+                    h_flex()
+                        .gap_1()
+                        .text_ui_sm(cx)
+                        .w_full()
+                        .child(
+                            Label::new(&Self::center_truncate_string(
+                                watcher.expression.as_ref(),
+                                watcher_len,
+                            ))
+                            .when_some(variable_color.name, |this, color| {
+                                this.color(Color::from(color))
+                            }),
+                        )
+                        .child(self.render_variable_value(
+                            &entry,
+                            &variable_color,
+                            watcher.value.to_string(),
+                            cx,
+                        )),
+                )
+                .end_slot(
+                    IconButton::new(
+                        SharedString::from(format!("watcher-{}-remove-button", watcher.expression)),
+                        IconName::Close,
+                    )
+                    .on_click({
+                        let weak = weak.clone();
+                        let path = path.clone();
+                        move |_, window, cx| {
+                            weak.update(cx, |variable_list, cx| {
+                                variable_list.selection = Some(path.clone());
+                                variable_list.remove_watcher(&RemoveWatch, window, cx);
+                            })
+                            .ok();
+                        }
+                    })
+                    .tooltip(move |window, cx| {
+                        Tooltip::for_action_in(
+                            "Remove Watch",
+                            &RemoveWatch,
+                            &focus_handle,
+                            window,
+                            cx,
+                        )
+                    })
+                    .icon_size(ui::IconSize::Indicator),
+                ),
+            )
+            .into_any()
+    }
+
     fn render_scope(
         &self,
         entry: &ListEntry,
@@ -721,36 +1190,12 @@ impl VariableList {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> AnyElement {
-        let dap = match &variable.dap_kind {
-            EntryKind::Variable(dap) => dap,
-            EntryKind::Scope(_) => {
-                debug_panic!("Called render variable on variable list entry kind scope");
-                return div().into_any_element();
-            }
+        let Some(dap) = &variable.as_variable() else {
+            debug_panic!("Called render variable on non variable variable list entry variant");
+            return div().into_any_element();
         };
 
-        let syntax_color_for = |name| cx.theme().syntax().get(name).color;
-        let variable_name_color = if self.disabled {
-            Some(Color::Disabled.color(cx))
-        } else {
-            match &dap
-                .presentation_hint
-                .as_ref()
-                .and_then(|hint| hint.kind.as_ref())
-                .unwrap_or(&VariablePresentationHintKind::Unknown)
-            {
-                VariablePresentationHintKind::Class
-                | VariablePresentationHintKind::BaseClass
-                | VariablePresentationHintKind::InnerClass
-                | VariablePresentationHintKind::MostDerivedClass => syntax_color_for("type"),
-                VariablePresentationHintKind::Data => syntax_color_for("variable"),
-                VariablePresentationHintKind::Unknown | _ => syntax_color_for("variable"),
-            }
-        };
-        let variable_color = self
-            .disabled
-            .then(|| Color::Disabled.color(cx))
-            .or_else(|| syntax_color_for("variable.special"));
+        let variable_color = self.variable_color(dap.presentation_hint.as_ref(), cx);
 
         let var_ref = dap.variables_reference;
         let colors = get_entry_color(cx);
@@ -781,6 +1226,7 @@ impl VariableList {
             .size_full()
             .hover(|style| style.bg(bg_hover_color))
             .on_click(cx.listener({
+                let path = path.clone();
                 move |this, _, _window, cx| {
                     this.selection = Some(path.clone());
                     cx.notify();
@@ -809,14 +1255,17 @@ impl VariableList {
                     }))
                 })
                 .on_secondary_mouse_down(cx.listener({
-                    let variable = variable.clone();
+                    let path = path.clone();
+                    let entry = variable.clone();
                     move |this, event: &MouseDownEvent, window, cx| {
-                        this.deploy_variable_context_menu(
-                            variable.clone(),
+                        this.selection = Some(path.clone());
+                        this.deploy_list_entry_context_menu(
+                            entry.clone(),
                             event.position,
                             window,
                             cx,
-                        )
+                        );
+                        cx.stop_propagation();
                     }
                 }))
                 .child(
@@ -825,62 +1274,16 @@ impl VariableList {
                         .text_ui_sm(cx)
                         .w_full()
                         .child(
-                            Label::new(&dap.name).when_some(variable_name_color, |this, color| {
+                            Label::new(&dap.name).when_some(variable_color.name, |this, color| {
                                 this.color(Color::from(color))
                             }),
                         )
-                        .when(!dap.value.is_empty(), |this| {
-                            this.child(div().w_full().id(variable.item_value_id()).map(|this| {
-                                if let Some((_, editor)) = self
-                                    .edited_path
-                                    .as_ref()
-                                    .filter(|(path, _)| path == &variable.path)
-                                {
-                                    this.child(div().size_full().px_2().child(editor.clone()))
-                                } else {
-                                    this.text_color(cx.theme().colors().text_muted)
-                                        .when(
-                                            !self.disabled
-                                                && self
-                                                    .session
-                                                    .read(cx)
-                                                    .capabilities()
-                                                    .supports_set_variable
-                                                    .unwrap_or_default(),
-                                            |this| {
-                                                let path = variable.path.clone();
-                                                let variable_value = dap.value.clone();
-                                                this.on_click(cx.listener(
-                                                    move |this, click: &ClickEvent, window, cx| {
-                                                        if click.down.click_count < 2 {
-                                                            return;
-                                                        }
-                                                        let editor = Self::create_variable_editor(
-                                                            &variable_value,
-                                                            window,
-                                                            cx,
-                                                        );
-                                                        this.edited_path =
-                                                            Some((path.clone(), editor));
-
-                                                        cx.notify();
-                                                    },
-                                                ))
-                                            },
-                                        )
-                                        .child(
-                                            Label::new(format!("=  {}", &dap.value))
-                                                .single_line()
-                                                .truncate()
-                                                .size(LabelSize::Small)
-                                                .color(Color::Muted)
-                                                .when_some(variable_color, |this, color| {
-                                                    this.color(Color::from(color))
-                                                }),
-                                        )
-                                }
-                            }))
-                        }),
+                        .child(self.render_variable_value(
+                            &variable,
+                            &variable_color,
+                            dap.value.clone(),
+                            cx,
+                        )),
                 ),
             )
             .into_any()
@@ -928,8 +1331,6 @@ impl Focusable for VariableList {
 
 impl Render for VariableList {
     fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        self.build_entries(cx);
-
         v_flex()
             .track_focus(&self.focus_handle)
             .key_context("VariableList")
@@ -941,17 +1342,22 @@ impl Render for VariableList {
             .on_action(cx.listener(Self::select_last))
             .on_action(cx.listener(Self::select_prev))
             .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::cancel))
+            .on_action(cx.listener(Self::confirm))
             .on_action(cx.listener(Self::expand_selected_entry))
             .on_action(cx.listener(Self::collapse_selected_entry))
-            .on_action(cx.listener(Self::cancel_variable_edit))
-            .on_action(cx.listener(Self::confirm_variable_edit))
-            //
+            .on_action(cx.listener(Self::copy_variable_name))
+            .on_action(cx.listener(Self::copy_variable_value))
+            .on_action(cx.listener(Self::edit_variable))
+            .on_action(cx.listener(Self::add_watcher))
+            .on_action(cx.listener(Self::remove_watcher))
             .child(
                 uniform_list(
-                    cx.entity().clone(),
                     "variable-list",
                     self.entries.len(),
-                    move |this, range, window, cx| this.render_entries(range, window, cx),
+                    cx.processor(move |this, range: Range<usize>, window, cx| {
+                        this.render_entries(range, window, cx)
+                    }),
                 )
                 .track_scroll(self.list_handle.clone())
                 .gap_1_5()

crates/debugger_ui/src/stack_trace_view.rs 🔗

@@ -0,0 +1,454 @@
+use std::any::{Any, TypeId};
+
+use collections::HashMap;
+use dap::StackFrameId;
+use editor::{
+    Anchor, Bias, DebugStackFrameLine, Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer,
+    RowHighlightOptions, SelectionEffects, ToPoint, scroll::Autoscroll,
+};
+use gpui::{
+    AnyView, App, AppContext, Entity, EventEmitter, Focusable, IntoElement, Render, SharedString,
+    Subscription, Task, WeakEntity, Window,
+};
+use language::{BufferSnapshot, Capability, Point, Selection, SelectionGoal, TreeSitterOptions};
+use project::{Project, ProjectPath};
+use ui::{ActiveTheme as _, Context, ParentElement as _, Styled as _, div};
+use util::ResultExt as _;
+use workspace::{
+    Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
+    item::{BreadcrumbText, ItemEvent, SaveOptions},
+    searchable::SearchableItemHandle,
+};
+
+use crate::session::running::stack_frame_list::{StackFrameList, StackFrameListEvent};
+use anyhow::Result;
+
+pub(crate) struct StackTraceView {
+    editor: Entity<Editor>,
+    multibuffer: Entity<MultiBuffer>,
+    workspace: WeakEntity<Workspace>,
+    project: Entity<Project>,
+    stack_frame_list: Entity<StackFrameList>,
+    selected_stack_frame_id: Option<StackFrameId>,
+    highlights: Vec<(StackFrameId, Anchor)>,
+    excerpt_for_frames: collections::HashMap<ExcerptId, StackFrameId>,
+    refresh_task: Option<Task<Result<()>>>,
+    _subscription: Option<Subscription>,
+}
+
+impl StackTraceView {
+    pub(crate) fn new(
+        workspace: WeakEntity<Workspace>,
+        project: Entity<Project>,
+        stack_frame_list: Entity<StackFrameList>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
+        let editor = cx.new(|cx| {
+            let mut editor =
+                Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
+            editor.set_vertical_scroll_margin(5, cx);
+            editor
+        });
+
+        cx.subscribe_in(&editor, window, |this, editor, event, window, cx| {
+            if let EditorEvent::SelectionsChanged { local: true } = event {
+                let excerpt_id = editor.update(cx, |editor, cx| {
+                    let position: Point = editor.selections.newest(cx).head();
+
+                    editor
+                        .snapshot(window, cx)
+                        .buffer_snapshot
+                        .excerpt_containing(position..position)
+                        .map(|excerpt| excerpt.id())
+                });
+
+                if let Some(stack_frame_id) = excerpt_id
+                    .and_then(|id| this.excerpt_for_frames.get(&id))
+                    .filter(|id| Some(**id) != this.selected_stack_frame_id)
+                {
+                    this.stack_frame_list.update(cx, |list, cx| {
+                        list.go_to_stack_frame(*stack_frame_id, window, cx).detach();
+                    });
+                }
+            }
+        })
+        .detach();
+
+        cx.subscribe_in(
+            &stack_frame_list,
+            window,
+            |this, stack_frame_list, event, window, cx| match event {
+                StackFrameListEvent::BuiltEntries => {
+                    this.selected_stack_frame_id =
+                        stack_frame_list.read(cx).opened_stack_frame_id();
+                    this.update_excerpts(window, cx);
+                }
+                StackFrameListEvent::SelectedStackFrameChanged(selected_frame_id) => {
+                    this.selected_stack_frame_id = Some(*selected_frame_id);
+                    this.update_highlights(window, cx);
+
+                    if let Some(frame_anchor) = this
+                        .highlights
+                        .iter()
+                        .find(|(frame_id, _)| frame_id == selected_frame_id)
+                        .map(|highlight| highlight.1)
+                    {
+                        this.editor.update(cx, |editor, cx| {
+                            if frame_anchor.excerpt_id
+                                != editor.selections.newest_anchor().head().excerpt_id
+                            {
+                                let effects = SelectionEffects::scroll(
+                                    Autoscroll::center().for_anchor(frame_anchor),
+                                );
+
+                                editor.change_selections(effects, window, cx, |selections| {
+                                    let selection_id = selections.new_selection_id();
+
+                                    let selection = Selection {
+                                        id: selection_id,
+                                        start: frame_anchor,
+                                        end: frame_anchor,
+                                        goal: SelectionGoal::None,
+                                        reversed: false,
+                                    };
+
+                                    selections.select_anchors(vec![selection]);
+                                })
+                            }
+                        });
+                    }
+                }
+            },
+        )
+        .detach();
+
+        let mut this = Self {
+            editor,
+            multibuffer,
+            workspace,
+            project,
+            excerpt_for_frames: HashMap::default(),
+            highlights: Vec::default(),
+            stack_frame_list,
+            selected_stack_frame_id: None,
+            refresh_task: None,
+            _subscription: None,
+        };
+
+        this.update_excerpts(window, cx);
+        this
+    }
+
+    fn update_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.refresh_task.take();
+        self.editor.update(cx, |editor, cx| {
+            editor.clear_highlights::<DebugStackFrameLine>(cx)
+        });
+
+        let stack_frames = self
+            .stack_frame_list
+            .read_with(cx, |list, _| list.flatten_entries(false, false));
+
+        let frames_to_open: Vec<_> = stack_frames
+            .into_iter()
+            .filter_map(|frame| {
+                Some((
+                    frame.id,
+                    frame.line as u32 - 1,
+                    StackFrameList::abs_path_from_stack_frame(&frame)?,
+                ))
+            })
+            .collect();
+
+        self.multibuffer
+            .update(cx, |multi_buffer, cx| multi_buffer.clear(cx));
+
+        let task = cx.spawn_in(window, async move |this, cx| {
+            let mut to_highlights = Vec::default();
+
+            for (stack_frame_id, line, abs_path) in frames_to_open {
+                let (worktree, relative_path) = this
+                    .update(cx, |this, cx| {
+                        this.workspace.update(cx, |workspace, cx| {
+                            workspace.project().update(cx, |this, cx| {
+                                this.find_or_create_worktree(&abs_path, false, cx)
+                            })
+                        })
+                    })??
+                    .await?;
+
+                let project_path = ProjectPath {
+                    worktree_id: worktree.read_with(cx, |tree, _| tree.id())?,
+                    path: relative_path.into(),
+                };
+
+                if let Some(buffer) = this
+                    .read_with(cx, |this, _| this.project.clone())?
+                    .update(cx, |project, cx| project.open_buffer(project_path, cx))?
+                    .await
+                    .log_err()
+                {
+                    this.update(cx, |this, cx| {
+                        this.multibuffer.update(cx, |multi_buffer, cx| {
+                            let line_point = Point::new(line, 0);
+                            let start_context = Self::heuristic_syntactic_expand(
+                                &buffer.read(cx).snapshot(),
+                                line_point,
+                            );
+
+                            // Users will want to see what happened before an active debug line in most cases
+                            let range = ExcerptRange {
+                                context: start_context..Point::new(line.saturating_add(1), 0),
+                                primary: line_point..line_point,
+                            };
+                            multi_buffer.push_excerpts(buffer.clone(), vec![range], cx);
+
+                            let line_anchor =
+                                multi_buffer.buffer_point_to_anchor(&buffer, line_point, cx);
+
+                            if let Some(line_anchor) = line_anchor {
+                                this.excerpt_for_frames
+                                    .insert(line_anchor.excerpt_id, stack_frame_id);
+                                to_highlights.push((stack_frame_id, line_anchor));
+                            }
+                        });
+                    })
+                    .ok();
+                }
+            }
+
+            this.update_in(cx, |this, window, cx| {
+                this.highlights = to_highlights;
+                this.update_highlights(window, cx);
+            })
+            .ok();
+
+            anyhow::Ok(())
+        });
+
+        self.refresh_task = Some(task);
+    }
+
+    fn update_highlights(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.editor.update(cx, |editor, _| {
+            editor.clear_row_highlights::<DebugStackFrameLine>()
+        });
+
+        let stack_frames = self
+            .stack_frame_list
+            .read_with(cx, |session, _| session.flatten_entries(false, false));
+
+        let active_idx = self
+            .selected_stack_frame_id
+            .and_then(|id| {
+                stack_frames
+                    .iter()
+                    .enumerate()
+                    .find_map(|(idx, frame)| if frame.id == id { Some(idx) } else { None })
+            })
+            .unwrap_or(0);
+
+        self.editor.update(cx, |editor, cx| {
+            let snapshot = editor.snapshot(window, cx).display_snapshot;
+            let first_color = cx.theme().colors().editor_debugger_active_line_background;
+
+            let color = first_color.opacity(0.5);
+
+            let mut is_first = true;
+
+            for (_, highlight) in self.highlights.iter().skip(active_idx) {
+                let position = highlight.to_point(&snapshot.buffer_snapshot);
+                let color = if is_first {
+                    is_first = false;
+                    first_color
+                } else {
+                    color
+                };
+
+                let start = snapshot
+                    .buffer_snapshot
+                    .clip_point(Point::new(position.row, 0), Bias::Left);
+                let end = start + Point::new(1, 0);
+                let start = snapshot.buffer_snapshot.anchor_before(start);
+                let end = snapshot.buffer_snapshot.anchor_before(end);
+                editor.highlight_rows::<DebugStackFrameLine>(
+                    start..end,
+                    color,
+                    RowHighlightOptions::default(),
+                    cx,
+                );
+            }
+        })
+    }
+
+    fn heuristic_syntactic_expand(snapshot: &BufferSnapshot, selected_point: Point) -> Point {
+        let mut text_objects = snapshot.text_object_ranges(
+            selected_point..selected_point,
+            TreeSitterOptions::max_start_depth(4),
+        );
+
+        let mut start_position = text_objects
+            .find(|(_, obj)| matches!(obj, language::TextObject::AroundFunction))
+            .map(|(range, _)| snapshot.offset_to_point(range.start))
+            .map(|point| Point::new(point.row.max(selected_point.row.saturating_sub(8)), 0))
+            .unwrap_or(selected_point);
+
+        if start_position.row == selected_point.row {
+            start_position.row = start_position.row.saturating_sub(1);
+        }
+
+        start_position
+    }
+}
+
+impl Render for StackTraceView {
+    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+        div().size_full().child(self.editor.clone())
+    }
+}
+
+impl EventEmitter<EditorEvent> for StackTraceView {}
+impl Focusable for StackTraceView {
+    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
+        self.editor.focus_handle(cx)
+    }
+}
+
+impl Item for StackTraceView {
+    type Event = EditorEvent;
+
+    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
+        Editor::to_item_events(event, f)
+    }
+
+    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.editor
+            .update(cx, |editor, cx| editor.deactivated(window, cx));
+    }
+
+    fn navigate(
+        &mut self,
+        data: Box<dyn Any>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> bool {
+        self.editor
+            .update(cx, |editor, cx| editor.navigate(data, window, cx))
+    }
+
+    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
+        Some("Stack Frame Viewer".into())
+    }
+
+    fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
+        "Stack Frames".into()
+    }
+
+    fn for_each_project_item(
+        &self,
+        cx: &App,
+        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
+    ) {
+        self.editor.for_each_project_item(cx, f)
+    }
+
+    fn is_singleton(&self, _: &App) -> bool {
+        false
+    }
+
+    fn set_nav_history(
+        &mut self,
+        nav_history: ItemNavHistory,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.editor.update(cx, |editor, _| {
+            editor.set_nav_history(Some(nav_history));
+        });
+    }
+
+    fn is_dirty(&self, cx: &App) -> bool {
+        self.multibuffer.read(cx).is_dirty(cx)
+    }
+
+    fn has_deleted_file(&self, cx: &App) -> bool {
+        self.multibuffer.read(cx).has_deleted_file(cx)
+    }
+
+    fn has_conflict(&self, cx: &App) -> bool {
+        self.multibuffer.read(cx).has_conflict(cx)
+    }
+
+    fn can_save(&self, _: &App) -> bool {
+        true
+    }
+
+    fn save(
+        &mut self,
+        options: SaveOptions,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        self.editor.save(options, project, window, cx)
+    }
+
+    fn save_as(
+        &mut self,
+        _: Entity<Project>,
+        _: ProjectPath,
+        _window: &mut Window,
+        _: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        unreachable!()
+    }
+
+    fn reload(
+        &mut self,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        self.editor.reload(project, window, cx)
+    }
+
+    fn act_as_type<'a>(
+        &'a self,
+        type_id: TypeId,
+        self_handle: &'a Entity<Self>,
+        _: &'a App,
+    ) -> Option<AnyView> {
+        if type_id == TypeId::of::<Self>() {
+            Some(self_handle.to_any())
+        } else if type_id == TypeId::of::<Editor>() {
+            Some(self.editor.to_any())
+        } else {
+            None
+        }
+    }
+
+    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+        Some(Box::new(self.editor.clone()))
+    }
+
+    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
+        ToolbarItemLocation::PrimaryLeft
+    }
+
+    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
+        self.editor.breadcrumbs(theme, cx)
+    }
+
+    fn added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.editor.update(cx, |editor, cx| {
+            editor.added_to_workspace(workspace, window, cx)
+        });
+    }
+}

crates/debugger_ui/src/tests.rs 🔗

@@ -1,8 +1,8 @@
 use std::sync::Arc;
 
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use dap::adapters::DebugTaskDefinition;
-use dap::{DebugRequest, client::DebugAdapterClient};
+use dap::client::DebugAdapterClient;
 use gpui::{Entity, TestAppContext, WindowHandle};
 use project::{Project, debugger::session::Session};
 use settings::SettingsStore;
@@ -25,6 +25,8 @@ mod inline_values;
 #[cfg(test)]
 mod module_list;
 #[cfg(test)]
+mod new_process_modal;
+#[cfg(test)]
 mod persistence;
 #[cfg(test)]
 mod stack_frame_list;
@@ -32,9 +34,8 @@ mod stack_frame_list;
 mod variable_list;
 
 pub fn init_test(cx: &mut gpui::TestAppContext) {
-    if std::env::var("RUST_LOG").is_ok() {
-        env_logger::try_init().ok();
-    }
+    #[cfg(test)]
+    zlog::init_test();
 
     cx.update(|cx| {
         let settings = SettingsStore::test(cx);
@@ -125,7 +126,7 @@ pub fn start_debug_session_with<T: Fn(&Arc<DebugAdapterClient>) + 'static>(
             .and_then(|panel| panel.read(cx).active_session())
             .map(|session| session.read(cx).running_state().read(cx).session())
             .cloned()
-            .ok_or_else(|| anyhow!("Failed to get active session"))
+            .context("Failed to get active session")
     })??;
 
     Ok(session)
@@ -136,16 +137,18 @@ pub fn start_debug_session<T: Fn(&Arc<DebugAdapterClient>) + 'static>(
     cx: &mut gpui::TestAppContext,
     configure: T,
 ) -> Result<Entity<Session>> {
+    use serde_json::json;
+
     start_debug_session_with(
         workspace,
         cx,
         DebugTaskDefinition {
             adapter: "fake-adapter".into(),
-            request: DebugRequest::Launch(Default::default()),
             label: "test".into(),
-            initialize_args: None,
+            config: json!({
+                "request": "launch"
+            }),
             tcp_connection: None,
-            stop_on_entry: None,
         },
         configure,
     )

crates/debugger_ui/src/tests/attach_modal.rs 🔗

@@ -5,7 +5,7 @@ use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
 use menu::Confirm;
 use project::{FakeFs, Project};
 use serde_json::json;
-use task::{AttachRequest, TcpArgumentsTemplate};
+use task::AttachRequest;
 use tests::{init_test, init_test_workspace};
 use util::path;
 
@@ -32,13 +32,12 @@ async fn test_direct_attach_to_process(executor: BackgroundExecutor, cx: &mut Te
         cx,
         DebugTaskDefinition {
             adapter: "fake-adapter".into(),
-            request: dap::DebugRequest::Attach(AttachRequest {
-                process_id: Some(10),
-            }),
             label: "label".into(),
-            initialize_args: None,
+            config: json!({
+               "request": "attach",
+              "process_id": 10,
+            }),
             tcp_connection: None,
-            stop_on_entry: None,
         },
         |client| {
             client.on_request::<dap::requests::Attach, _>(move |_, args| {
@@ -107,13 +106,10 @@ async fn test_show_attach_modal_and_select_process(
             workspace.toggle_modal(window, cx, |window, cx| {
                 AttachModal::with_processes(
                     workspace_handle,
-                    DebugTaskDefinition {
+                    task::ZedDebugConfig {
                         adapter: FakeAdapter::ADAPTER_NAME.into(),
-
                         request: dap::DebugRequest::Attach(AttachRequest::default()),
                         label: "attach example".into(),
-                        initialize_args: None,
-                        tcp_connection: Some(TcpArgumentsTemplate::default()),
                         stop_on_entry: None,
                     },
                     vec![

crates/debugger_ui/src/tests/console.rs 🔗

@@ -3,6 +3,7 @@ use crate::{
     *,
 };
 use dap::requests::StackTrace;
+use editor::{DisplayPoint, display_map::DisplayRow};
 use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
 use project::{FakeFs, Project};
 use serde_json::json;
@@ -33,7 +34,7 @@ async fn test_handle_output_event(executor: BackgroundExecutor, cx: &mut TestApp
         .unwrap();
 
     let session = start_debug_session(&workspace, cx, |_| {}).unwrap();
-    let client = session.update(cx, |session, _| session.adapter_client().unwrap());
+    let client = session.read_with(cx, |session, _| session.adapter_client().unwrap());
 
     client.on_request::<StackTrace, _>(move |_, _| {
         Ok(dap::StackTraceResponse {
@@ -110,7 +111,7 @@ async fn test_handle_output_event(executor: BackgroundExecutor, cx: &mut TestApp
     client
         .fake_event(dap::messages::Events::Output(dap::OutputEvent {
             category: Some(dap::OutputEventCategory::Stdout),
-            output: "Second output line after thread stopped!".to_string(),
+            output: "\tSecond output line after thread stopped!".to_string(),
             data: None,
             variables_reference: None,
             source: None,
@@ -124,7 +125,7 @@ async fn test_handle_output_event(executor: BackgroundExecutor, cx: &mut TestApp
     client
         .fake_event(dap::messages::Events::Output(dap::OutputEvent {
             category: Some(dap::OutputEventCategory::Console),
-            output: "Second console output line after thread stopped!".to_string(),
+            output: "\tSecond console output line after thread stopped!".to_string(),
             data: None,
             variables_reference: None,
             source: None,
@@ -150,13 +151,209 @@ async fn test_handle_output_event(executor: BackgroundExecutor, cx: &mut TestApp
                 .unwrap();
 
             assert_eq!(
-                "First console output line before thread stopped!\nFirst output line before thread stopped!\nSecond output line after thread stopped!\nSecond console output line after thread stopped!\n",
+                "First console output line before thread stopped!\nFirst output line before thread stopped!\n\tSecond output line after thread stopped!\n\tSecond console output line after thread stopped!\n",
                 active_session_panel.read(cx).running_state().read(cx).console().read(cx).editor().read(cx).text(cx).as_str()
             );
         })
         .unwrap();
 }
 
+#[gpui::test]
+async fn test_escape_code_processing(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            "main.rs": "First line\nSecond line\nThird line\nFourth line",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+    workspace
+        .update(cx, |workspace, window, cx| {
+            workspace.focus_panel::<DebugPanel>(window, cx);
+        })
+        .unwrap();
+
+    let session = start_debug_session(&workspace, cx, |_| {}).unwrap();
+    let client = session.read_with(cx, |session, _| session.adapter_client().unwrap());
+
+    client.on_request::<StackTrace, _>(move |_, _| {
+        Ok(dap::StackTraceResponse {
+            stack_frames: Vec::default(),
+            total_frames: None,
+        })
+    });
+
+    client
+        .fake_event(dap::messages::Events::Output(dap::OutputEvent {
+            category: None,
+            output: "Checking latest version of JavaScript...".to_string(),
+            data: None,
+            variables_reference: None,
+            source: None,
+            line: None,
+            column: None,
+            group: None,
+            location_reference: None,
+        }))
+        .await;
+    client
+        .fake_event(dap::messages::Events::Output(dap::OutputEvent {
+            category: None,
+            output: "   \u{1b}[1m\u{1b}[38;2;173;127;168m▲ Next.js 15.1.5\u{1b}[39m\u{1b}[22m"
+                .to_string(),
+            data: None,
+            variables_reference: None,
+            source: None,
+            line: None,
+            column: None,
+            group: None,
+            location_reference: None,
+        }))
+        .await;
+    client
+        .fake_event(dap::messages::Events::Output(dap::OutputEvent {
+            category: None,
+            output: "   - Local:        http://localhost:3000\n   - Network:      http://192.168.1.144:3000\n\n \u{1b}[32m\u{1b}[1m✓\u{1b}[22m\u{1b}[39m Starting..."
+                .to_string(),
+            data: None,
+            variables_reference: None,
+            source: None,
+            line: None,
+            column: None,
+            group: None,
+            location_reference: None,
+        }))
+        .await;
+    // [crates/debugger_ui/src/session/running/console.rs:147:9] &to_insert = "Could not read source map for file:///Users/cole/roles-at/node_modules/.pnpm/typescript@5.7.3/node_modules/typescript/lib/typescript.js: ENOENT: no such file or directory, open '/Users/cole/roles-at/node_modules/.pnpm/typescript@5.7.3/node_modules/typescript/lib/typescript.js.map'\n"
+    client
+        .fake_event(dap::messages::Events::Output(dap::OutputEvent {
+            category: None,
+            output: "Something else...".to_string(),
+            data: None,
+            variables_reference: None,
+            source: None,
+            line: None,
+            column: None,
+            group: None,
+            location_reference: None,
+        }))
+        .await;
+    client
+        .fake_event(dap::messages::Events::Output(dap::OutputEvent {
+            category: None,
+            output: " \u{1b}[32m\u{1b}[1m✓\u{1b}[22m\u{1b}[39m Ready in 1009ms\n".to_string(),
+            data: None,
+            variables_reference: None,
+            source: None,
+            line: None,
+            column: None,
+            group: None,
+            location_reference: None,
+        }))
+        .await;
+
+    // introduce some background highlight
+    client
+        .fake_event(dap::messages::Events::Output(dap::OutputEvent {
+            category: None,
+            output: "\u{1b}[41m\u{1b}[37mBoth background and foreground!".to_string(),
+            data: None,
+            variables_reference: None,
+            source: None,
+            line: None,
+            column: None,
+            group: None,
+            location_reference: None,
+        }))
+        .await;
+    // another random line
+    client
+        .fake_event(dap::messages::Events::Output(dap::OutputEvent {
+            category: None,
+            output: "Even more...".to_string(),
+            data: None,
+            variables_reference: None,
+            source: None,
+            line: None,
+            column: None,
+            group: None,
+            location_reference: None,
+        }))
+        .await;
+
+    cx.run_until_parked();
+
+    let _running_state =
+        active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| {
+            cx.focus_self(window);
+            item.running_state().clone()
+        });
+
+    cx.run_until_parked();
+
+    workspace
+        .update(cx, |workspace, window, cx| {
+            let debug_panel = workspace.panel::<DebugPanel>(cx).unwrap();
+            let active_debug_session_panel = debug_panel
+                .update(cx, |this, _| this.active_session())
+                .unwrap();
+
+            let editor =
+                active_debug_session_panel
+                    .read(cx)
+                    .running_state()
+                    .read(cx)
+                    .console()
+                    .read(cx)
+                    .editor().clone();
+
+            assert_eq!(
+                "Checking latest version of JavaScript...\n   ▲ Next.js 15.1.5\n   - Local:        http://localhost:3000\n   - Network:      http://192.168.1.144:3000\n\n ✓ Starting...\nSomething else...\n ✓ Ready in 1009ms\nBoth background and foreground!\nEven more...\n",
+                editor
+                    .read(cx)
+                    .text(cx)
+                    .as_str()
+            );
+
+            let text_highlights = editor.update(cx, |editor, cx| {
+                let mut text_highlights = editor.all_text_highlights(window, cx).into_iter().flat_map(|(_, ranges)| ranges).collect::<Vec<_>>();
+                text_highlights.sort_by(|a, b| a.start.cmp(&b.start));
+                text_highlights
+            });
+            pretty_assertions::assert_eq!(
+                text_highlights,
+                [
+                    DisplayPoint::new(DisplayRow(1), 3)..DisplayPoint::new(DisplayRow(1), 21),
+                    DisplayPoint::new(DisplayRow(1), 21)..DisplayPoint::new(DisplayRow(2), 0),
+                    DisplayPoint::new(DisplayRow(5), 1)..DisplayPoint::new(DisplayRow(5), 4),
+                    DisplayPoint::new(DisplayRow(5), 4)..DisplayPoint::new(DisplayRow(6), 0),
+                    DisplayPoint::new(DisplayRow(7), 1)..DisplayPoint::new(DisplayRow(7), 4),
+                    DisplayPoint::new(DisplayRow(7), 4)..DisplayPoint::new(DisplayRow(8), 0),
+                    DisplayPoint::new(DisplayRow(8), 0)..DisplayPoint::new(DisplayRow(9), 0),
+                ]
+            );
+
+            let background_highlights = editor.update(cx, |editor, cx| {
+                editor.all_text_background_highlights(window, cx).into_iter().map(|(range, _)| range).collect::<Vec<_>>()
+            });
+            pretty_assertions::assert_eq!(
+                background_highlights,
+                [
+                    DisplayPoint::new(DisplayRow(8), 0)..DisplayPoint::new(DisplayRow(9), 0),
+                ]
+            )
+        })
+        .unwrap();
+}
+
 // #[gpui::test]
 // async fn test_grouped_output(executor: BackgroundExecutor, cx: &mut TestAppContext) {
 //     init_test(cx);

crates/debugger_ui/src/tests/debugger_panel.rs 🔗

@@ -24,17 +24,16 @@ use project::{
 };
 use serde_json::json;
 use std::{
-    collections::HashMap,
     path::Path,
     sync::{
         Arc,
         atomic::{AtomicBool, Ordering},
     },
 };
-use task::LaunchRequest;
 use terminal_view::terminal_panel::TerminalPanel;
 use tests::{active_debug_session_panel, init_test, init_test_workspace};
 use util::path;
+use workspace::item::SaveOptions;
 use workspace::{Item, dock::Panel};
 
 #[gpui::test]
@@ -425,6 +424,13 @@ async fn test_handle_start_debugging_request(
         }
     });
 
+    let sessions = workspace
+        .update(cx, |workspace, _window, cx| {
+            let debug_panel = workspace.panel::<DebugPanel>(cx).unwrap();
+            debug_panel.read(cx).sessions()
+        })
+        .unwrap();
+    assert_eq!(sessions.len(), 1);
     client
         .fake_reverse_request::<StartDebugging>(StartDebuggingRequestArguments {
             request: StartDebuggingRequestArgumentsRequest::Launch,
@@ -437,20 +443,49 @@ async fn test_handle_start_debugging_request(
     workspace
         .update(cx, |workspace, _window, cx| {
             let debug_panel = workspace.panel::<DebugPanel>(cx).unwrap();
+
+            // Active session changes on spawn, as the parent has never stopped.
             let active_session = debug_panel
                 .read(cx)
                 .active_session()
                 .unwrap()
                 .read(cx)
                 .session(cx);
-            let parent_session = active_session.read(cx).parent_session().unwrap();
-            let mut original_binary = parent_session.read(cx).binary().clone();
+            let current_sessions = debug_panel.read(cx).sessions();
+            assert_eq!(active_session, current_sessions[1].read(cx).session(cx));
+            assert_eq!(
+                active_session.read(cx).parent_session(),
+                Some(&current_sessions[0].read(cx).session(cx))
+            );
+
+            assert_eq!(current_sessions.len(), 2);
+            assert_eq!(current_sessions[0], sessions[0]);
+
+            let parent_session = current_sessions[1]
+                .read(cx)
+                .session(cx)
+                .read(cx)
+                .parent_session()
+                .unwrap();
+            assert_eq!(parent_session, &sessions[0].read(cx).session(cx));
+
+            // We should preserve the original binary (params to spawn process etc.) except for launch params
+            // (as they come from reverse spawn request).
+            let mut original_binary = parent_session.read(cx).binary().cloned().unwrap();
             original_binary.request_args = StartDebuggingRequestArguments {
                 request: StartDebuggingRequestArgumentsRequest::Launch,
                 configuration: fake_config.clone(),
             };
 
-            assert_eq!(active_session.read(cx).binary(), &original_binary);
+            assert_eq!(
+                current_sessions[1]
+                    .read(cx)
+                    .session(cx)
+                    .read(cx)
+                    .binary()
+                    .unwrap(),
+                &original_binary
+            );
         })
         .unwrap();
 
@@ -988,7 +1023,7 @@ async fn test_debug_panel_item_thread_status_reset_on_failure(
     cx.run_until_parked();
 
     let running_state = active_debug_session_panel(workspace, cx)
-        .update(cx, |item, _| item.running_state().clone());
+        .read_with(cx, |item, _| item.running_state().clone());
 
     cx.run_until_parked();
     let thread_id = ThreadId(1);
@@ -1186,7 +1221,15 @@ async fn test_send_breakpoints_when_editor_has_been_saved(
 
     editor
         .update_in(cx, |editor, window, cx| {
-            editor.save(true, project.clone(), window, cx)
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
         })
         .await
         .unwrap();
@@ -1388,16 +1431,15 @@ async fn test_we_send_arguments_from_user_config(
     let cx = &mut VisualTestContext::from_window(*workspace, cx);
     let debug_definition = DebugTaskDefinition {
         adapter: "fake-adapter".into(),
-        request: dap::DebugRequest::Launch(LaunchRequest {
-            program: "main.rs".to_owned(),
-            args: vec!["arg1".to_owned(), "arg2".to_owned()],
-            cwd: Some(path!("/Random_path").into()),
-            env: HashMap::from_iter(vec![("KEY".to_owned(), "VALUE".to_owned())]),
+        config: json!({
+            "request": "launch",
+            "program": "main.rs".to_owned(),
+            "args": vec!["arg1".to_owned(), "arg2".to_owned()],
+            "cwd": path!("/Random_path"),
+            "env": json!({ "KEY": "VALUE" }),
         }),
         label: "test".into(),
-        initialize_args: None,
         tcp_connection: None,
-        stop_on_entry: None,
     };
 
     let launch_handler_called = Arc::new(AtomicBool::new(false));
@@ -1413,13 +1455,7 @@ async fn test_we_send_arguments_from_user_config(
             client.on_request::<dap::requests::Launch, _>(move |_, args| {
                 launch_handler_called.store(true, Ordering::SeqCst);
 
-                let obj = args.raw.as_object().unwrap();
-                let sent_definition = serde_json::from_value::<DebugTaskDefinition>(
-                    obj.get(&"raw_request".to_owned()).unwrap().clone(),
-                )
-                .unwrap();
-
-                assert_eq!(sent_definition, debug_definition);
+                assert_eq!(args.raw, debug_definition.config);
 
                 Ok(())
             });
@@ -1719,3 +1755,195 @@ async fn test_active_debug_line_setting(executor: BackgroundExecutor, cx: &mut T
         );
     });
 }
+
+#[gpui::test]
+async fn test_debug_adapters_shutdown_on_app_quit(
+    executor: BackgroundExecutor,
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            "main.rs": "First line\nSecond line\nThird line\nFourth line",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    let session = start_debug_session(&workspace, cx, |_| {}).unwrap();
+    let client = session.update(cx, |session, _| session.adapter_client().unwrap());
+
+    let disconnect_request_received = Arc::new(AtomicBool::new(false));
+    let disconnect_clone = disconnect_request_received.clone();
+
+    let disconnect_clone_for_handler = disconnect_clone.clone();
+    client.on_request::<Disconnect, _>(move |_, _| {
+        disconnect_clone_for_handler.store(true, Ordering::SeqCst);
+        Ok(())
+    });
+
+    executor.run_until_parked();
+
+    workspace
+        .update(cx, |workspace, _, cx| {
+            let panel = workspace.panel::<DebugPanel>(cx).unwrap();
+            panel.read_with(cx, |panel, _| {
+                assert!(
+                    !panel.sessions().is_empty(),
+                    "Debug session should be active"
+                );
+            });
+        })
+        .unwrap();
+
+    cx.update(|_, cx| cx.defer(|cx| cx.shutdown()));
+
+    executor.run_until_parked();
+
+    assert!(
+        disconnect_request_received.load(Ordering::SeqCst),
+        "Disconnect request should have been sent to the adapter on app shutdown"
+    );
+}
+
+#[gpui::test]
+async fn test_adapter_shutdown_with_child_sessions_on_app_quit(
+    executor: BackgroundExecutor,
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            "main.rs": "First line\nSecond line\nThird line\nFourth line",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    let parent_session = start_debug_session(&workspace, cx, |_| {}).unwrap();
+    let parent_session_id = cx.read(|cx| parent_session.read(cx).session_id());
+    let parent_client = parent_session.update(cx, |session, _| session.adapter_client().unwrap());
+
+    let disconnect_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
+    let parent_disconnect_called = Arc::new(AtomicBool::new(false));
+    let parent_disconnect_clone = parent_disconnect_called.clone();
+    let disconnect_count_clone = disconnect_count.clone();
+
+    parent_client.on_request::<Disconnect, _>(move |_, _| {
+        parent_disconnect_clone.store(true, Ordering::SeqCst);
+        disconnect_count_clone.fetch_add(1, Ordering::SeqCst);
+
+        for _ in 0..50 {
+            if disconnect_count_clone.load(Ordering::SeqCst) >= 2 {
+                break;
+            }
+            std::thread::sleep(std::time::Duration::from_millis(1));
+        }
+
+        Ok(())
+    });
+
+    parent_client
+        .on_response::<StartDebugging, _>(move |_| {})
+        .await;
+    let _subscription = project::debugger::test::intercept_debug_sessions(cx, |_| {});
+
+    parent_client
+        .fake_reverse_request::<StartDebugging>(StartDebuggingRequestArguments {
+            configuration: json!({}),
+            request: StartDebuggingRequestArgumentsRequest::Launch,
+        })
+        .await;
+
+    cx.run_until_parked();
+
+    let child_session = project.update(cx, |project, cx| {
+        project
+            .dap_store()
+            .read(cx)
+            .session_by_id(SessionId(1))
+            .unwrap()
+    });
+    let child_session_id = cx.read(|cx| child_session.read(cx).session_id());
+    let child_client = child_session.update(cx, |session, _| session.adapter_client().unwrap());
+
+    let child_disconnect_called = Arc::new(AtomicBool::new(false));
+    let child_disconnect_clone = child_disconnect_called.clone();
+    let disconnect_count_clone = disconnect_count.clone();
+
+    child_client.on_request::<Disconnect, _>(move |_, _| {
+        child_disconnect_clone.store(true, Ordering::SeqCst);
+        disconnect_count_clone.fetch_add(1, Ordering::SeqCst);
+
+        for _ in 0..50 {
+            if disconnect_count_clone.load(Ordering::SeqCst) >= 2 {
+                break;
+            }
+            std::thread::sleep(std::time::Duration::from_millis(1));
+        }
+
+        Ok(())
+    });
+
+    executor.run_until_parked();
+
+    project.update(cx, |project, cx| {
+        let store = project.dap_store().read(cx);
+        assert!(store.session_by_id(parent_session_id).is_some());
+        assert!(store.session_by_id(child_session_id).is_some());
+    });
+
+    cx.update(|_, cx| cx.defer(|cx| cx.shutdown()));
+
+    executor.run_until_parked();
+
+    let parent_disconnect_check = parent_disconnect_called.clone();
+    let child_disconnect_check = child_disconnect_called.clone();
+    let both_disconnected = executor
+        .spawn(async move {
+            let parent_disconnect = parent_disconnect_check;
+            let child_disconnect = child_disconnect_check;
+
+            // We only have 100ms to shutdown the app
+            for _ in 0..100 {
+                if parent_disconnect.load(Ordering::SeqCst)
+                    && child_disconnect.load(Ordering::SeqCst)
+                {
+                    return true;
+                }
+
+                gpui::Timer::after(std::time::Duration::from_millis(1)).await;
+            }
+
+            false
+        })
+        .await;
+
+    assert!(
+        both_disconnected,
+        "Both parent and child sessions should receive disconnect requests"
+    );
+
+    assert!(
+        parent_disconnect_called.load(Ordering::SeqCst),
+        "Parent session should have received disconnect request"
+    );
+    assert!(
+        child_disconnect_called.load(Ordering::SeqCst),
+        "Child session should have received disconnect request"
+    );
+}

crates/debugger_ui/src/tests/inline_values.rs 🔗

@@ -1,7 +1,7 @@
 use std::{path::Path, sync::Arc};
 
 use dap::{Scope, StackFrame, Variable, requests::Variables};
-use editor::{Editor, EditorMode, MultiBuffer, actions::ToggleInlineValues};
+use editor::{Editor, EditorMode, MultiBuffer};
 use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
 use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_python, tree_sitter_rust};
 use project::{FakeFs, Project};
@@ -239,21 +239,17 @@ fn main() {
     });
     cx.run_until_parked();
 
-    editor.update_in(cx, |editor, window, cx| {
-        if !editor.inline_values_enabled() {
-            editor.toggle_inline_values(&ToggleInlineValues, window, cx);
-        }
-    });
+    editor.update(cx, |editor, cx| editor.refresh_inline_values(cx));
 
     cx.run_until_parked();
 
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 1: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
-        let x = 10;
+        let x: 10 = 10;
         let value = 42;
         let y = 4;
         let tester = {
@@ -307,11 +303,11 @@ fn main() {
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 1: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
         let x: 10 = 10;
-        let value = 42;
+        let value: 42 = 42;
         let y = 4;
         let tester = {
             let y = 10;
@@ -364,12 +360,12 @@ fn main() {
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 1: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
         let x: 10 = 10;
         let value: 42 = 42;
-        let y = 4;
+        let y: 4 = 4;
         let tester = {
             let y = 10;
             let y = 5;
@@ -421,7 +417,7 @@ fn main() {
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 1: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
         let x: 10 = 10;
@@ -478,14 +474,14 @@ fn main() {
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 1: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
         let x: 10 = 10;
         let value: 42 = 42;
         let y: 4 = 4;
         let tester = {
-            let y = 10;
+            let y: 4 = 10;
             let y = 5;
             let b = 3;
             vec![y, 20, 30]
@@ -585,15 +581,15 @@ fn main() {
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 1: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
         let x: 10 = 10;
         let value: 42 = 42;
-        let y = 4;
+        let y: 10 = 4;
         let tester = {
             let y: 10 = 10;
-            let y = 5;
+            let y: 10 = 5;
             let b = 3;
             vec![y, 20, 30]
         };
@@ -692,14 +688,14 @@ fn main() {
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 1: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
         let x: 10 = 10;
         let value: 42 = 42;
-        let y = 4;
+        let y: 5 = 4;
         let tester = {
-            let y = 10;
+            let y: 5 = 10;
             let y: 5 = 5;
             let b = 3;
             vec![y, 20, 30]
@@ -811,17 +807,17 @@ fn main() {
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 1: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
         let x: 10 = 10;
         let value: 42 = 42;
-        let y = 4;
+        let y: 5 = 4;
         let tester = {
-            let y = 10;
+            let y: 5 = 10;
             let y: 5 = 5;
             let b: 3 = 3;
-            vec![y, 20, 30]
+            vec![y: 5, 20, 30]
         };
 
         let caller = || {
@@ -930,7 +926,7 @@ fn main() {
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 1: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
         let x: 10 = 10;
@@ -1062,7 +1058,7 @@ fn main() {
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 1: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
         let x: 10 = 10;
@@ -1119,21 +1115,21 @@ fn main() {
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 1: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
-        let x = 10;
-        let value = 42;
-        let y = 4;
-        let tester = {
+        let x: 10 = 10;
+        let value: 42 = 42;
+        let y: 4 = 4;
+        let tester: size=3 = {
             let y = 10;
             let y = 5;
             let b = 3;
             vec![y, 20, 30]
         };
 
-        let caller = || {
-            let x = 3;
+        let caller: <not available> = || {
+            let x: 10 = 3;
             println!("x={}", x);
         };
 
@@ -1197,10 +1193,10 @@ fn main() {
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 1: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
-        let x = 10;
+        let x: 3 = 10;
         let value = 42;
         let y = 4;
         let tester = {
@@ -1212,7 +1208,7 @@ fn main() {
 
         let caller = || {
             let x: 3 = 3;
-            println!("x={}", x);
+            println!("x={}", x: 3);
         };
 
         caller();
@@ -1342,7 +1338,7 @@ fn main() {
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 2: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
         let x: 10 = 10;
@@ -1366,7 +1362,7 @@ fn main() {
             GLOBAL = 2;
         }
 
-        let result = value * 2 * x;
+        let result = value: 42 * 2 * x: 10;
         println!("Simple test executed: value={}, result={}", value, result);
         assert!(true);
     }
@@ -1487,7 +1483,7 @@ fn main() {
     editor.update_in(cx, |editor, window, cx| {
         pretty_assertions::assert_eq!(
             r#"
-    static mut GLOBAL: 2: usize = 1;
+    static mut GLOBAL: usize = 1;
 
     fn main() {
         let x: 10 = 10;
@@ -1511,8 +1507,8 @@ fn main() {
             GLOBAL = 2;
         }
 
-        let result: 840 = value * 2 * x;
-        println!("Simple test executed: value={}, result={}", value, result);
+        let result: 840 = value: 42 * 2 * x: 10;
+        println!("Simple test executed: value={}, result={}", value: 42, result: 840);
         assert!(true);
     }
     "#
@@ -1523,6 +1519,7 @@ fn main() {
 }
 
 fn rust_lang() -> Language {
+    let debug_variables_query = include_str!("../../../languages/src/rust/debugger.scm");
     Language::new(
         LanguageConfig {
             name: "Rust".into(),
@@ -1534,6 +1531,8 @@ fn rust_lang() -> Language {
         },
         Some(tree_sitter_rust::LANGUAGE.into()),
     )
+    .with_debug_variables_query(debug_variables_query)
+    .unwrap()
 }
 
 #[gpui::test]
@@ -1604,11 +1603,7 @@ def process_data(untyped_param, typed_param: int, another_typed: str):
         )
     });
 
-    editor.update_in(cx, |editor, window, cx| {
-        if !editor.inline_values_enabled() {
-            editor.toggle_inline_values(&ToggleInlineValues, window, cx);
-        }
-    });
+    editor.update(cx, |editor, cx| editor.refresh_inline_values(cx));
 
     client.on_request::<dap::requests::Threads, _>(move |_, _| {
         Ok(dap::ThreadsResponse {
@@ -1826,8 +1821,8 @@ def process_data(untyped_param, typed_param: int, another_typed: str):
         def process_data(untyped_param: test_value, typed_param: 42: int, another_typed: world: str):
             # Local variables
             x: 10 = 10
-            result: 84 = typed_param * 2
-            text: Hello, world = "Hello, " + another_typed
+            result: 84 = typed_param: 42 * 2
+            text: Hello, world = "Hello, " + another_typed: world
 
             # For loop with range
             sum_value: 10 = 0
@@ -1845,6 +1840,7 @@ def process_data(untyped_param, typed_param: int, another_typed: str):
 }
 
 fn python_lang() -> Language {
+    let debug_variables_query = include_str!("../../../languages/src/python/debugger.scm");
     Language::new(
         LanguageConfig {
             name: "Python".into(),
@@ -1856,4 +1852,392 @@ fn python_lang() -> Language {
         },
         Some(tree_sitter_python::LANGUAGE.into()),
     )
+    .with_debug_variables_query(debug_variables_query)
+    .unwrap()
+}
+
+fn go_lang() -> Language {
+    let debug_variables_query = include_str!("../../../languages/src/go/debugger.scm");
+    Language::new(
+        LanguageConfig {
+            name: "Go".into(),
+            matcher: LanguageMatcher {
+                path_suffixes: vec!["go".to_string()],
+                ..Default::default()
+            },
+            ..Default::default()
+        },
+        Some(tree_sitter_go::LANGUAGE.into()),
+    )
+    .with_debug_variables_query(debug_variables_query)
+    .unwrap()
+}
+
+/// Test utility function for inline values testing
+///
+/// # Arguments
+/// * `variables` - List of tuples containing (variable_name, variable_value)
+/// * `before` - Source code before inline values are applied
+/// * `after` - Expected source code after inline values are applied
+/// * `language` - Language configuration to use for parsing
+/// * `executor` - Background executor for async operations
+/// * `cx` - Test app context
+async fn test_inline_values_util(
+    local_variables: &[(&str, &str)],
+    global_variables: &[(&str, &str)],
+    before: &str,
+    after: &str,
+    active_debug_line: Option<usize>,
+    language: Language,
+    executor: BackgroundExecutor,
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+
+    let lines_count = before.lines().count();
+    let stop_line =
+        active_debug_line.unwrap_or_else(|| if lines_count > 6 { 6 } else { lines_count - 1 });
+
+    let fs = FakeFs::new(executor.clone());
+    fs.insert_tree(path!("/project"), json!({ "main.rs": before.to_string() }))
+        .await;
+
+    let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    workspace
+        .update(cx, |workspace, window, cx| {
+            workspace.focus_panel::<DebugPanel>(window, cx);
+        })
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    let session = start_debug_session(&workspace, cx, |_| {}).unwrap();
+    let client = session.update(cx, |session, _| session.adapter_client().unwrap());
+
+    client.on_request::<dap::requests::Threads, _>(|_, _| {
+        Ok(dap::ThreadsResponse {
+            threads: vec![dap::Thread {
+                id: 1,
+                name: "main".into(),
+            }],
+        })
+    });
+
+    client.on_request::<dap::requests::StackTrace, _>(move |_, _| {
+        Ok(dap::StackTraceResponse {
+            stack_frames: vec![dap::StackFrame {
+                id: 1,
+                name: "main".into(),
+                source: Some(dap::Source {
+                    name: Some("main.rs".into()),
+                    path: Some(path!("/project/main.rs").into()),
+                    source_reference: None,
+                    presentation_hint: None,
+                    origin: None,
+                    sources: None,
+                    adapter_data: None,
+                    checksums: None,
+                }),
+                line: stop_line as u64,
+                column: 1,
+                end_line: None,
+                end_column: None,
+                can_restart: None,
+                instruction_pointer_reference: None,
+                module_id: None,
+                presentation_hint: None,
+            }],
+            total_frames: None,
+        })
+    });
+
+    let local_vars: Vec<Variable> = local_variables
+        .iter()
+        .map(|(name, value)| Variable {
+            name: (*name).into(),
+            value: (*value).into(),
+            type_: None,
+            presentation_hint: None,
+            evaluate_name: None,
+            variables_reference: 0,
+            named_variables: None,
+            indexed_variables: None,
+            memory_reference: None,
+            declaration_location_reference: None,
+            value_location_reference: None,
+        })
+        .collect();
+
+    let global_vars: Vec<Variable> = global_variables
+        .iter()
+        .map(|(name, value)| Variable {
+            name: (*name).into(),
+            value: (*value).into(),
+            type_: None,
+            presentation_hint: None,
+            evaluate_name: None,
+            variables_reference: 0,
+            named_variables: None,
+            indexed_variables: None,
+            memory_reference: None,
+            declaration_location_reference: None,
+            value_location_reference: None,
+        })
+        .collect();
+
+    client.on_request::<Variables, _>({
+        let local_vars = Arc::new(local_vars.clone());
+        let global_vars = Arc::new(global_vars.clone());
+        move |_, args| {
+            let variables = match args.variables_reference {
+                2 => (*local_vars).clone(),
+                3 => (*global_vars).clone(),
+                _ => vec![],
+            };
+            Ok(dap::VariablesResponse { variables })
+        }
+    });
+
+    client.on_request::<dap::requests::Scopes, _>(move |_, _| {
+        Ok(dap::ScopesResponse {
+            scopes: vec![
+                Scope {
+                    name: "Local".into(),
+                    presentation_hint: None,
+                    variables_reference: 2,
+                    named_variables: None,
+                    indexed_variables: None,
+                    expensive: false,
+                    source: None,
+                    line: None,
+                    column: None,
+                    end_line: None,
+                    end_column: None,
+                },
+                Scope {
+                    name: "Global".into(),
+                    presentation_hint: None,
+                    variables_reference: 3,
+                    named_variables: None,
+                    indexed_variables: None,
+                    expensive: false,
+                    source: None,
+                    line: None,
+                    column: None,
+                    end_line: None,
+                    end_column: None,
+                },
+            ],
+        })
+    });
+
+    if !global_variables.is_empty() {
+        let global_evaluate_map: std::collections::HashMap<String, String> = global_variables
+            .iter()
+            .map(|(name, value)| (name.to_string(), value.to_string()))
+            .collect();
+
+        client.on_request::<dap::requests::Evaluate, _>(move |_, args| {
+            let value = global_evaluate_map
+                .get(&args.expression)
+                .unwrap_or(&"undefined".to_string())
+                .clone();
+
+            Ok(dap::EvaluateResponse {
+                result: value,
+                type_: None,
+                presentation_hint: None,
+                variables_reference: 0,
+                named_variables: None,
+                indexed_variables: None,
+                memory_reference: None,
+                value_location_reference: None,
+            })
+        });
+    }
+
+    client
+        .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
+            reason: dap::StoppedEventReason::Pause,
+            description: None,
+            thread_id: Some(1),
+            preserve_focus_hint: None,
+            text: None,
+            all_threads_stopped: None,
+            hit_breakpoint_ids: None,
+        }))
+        .await;
+
+    cx.run_until_parked();
+
+    let project_path = Path::new(path!("/project"));
+    let worktree = project
+        .update(cx, |project, cx| project.find_worktree(project_path, cx))
+        .expect("This worktree should exist in project")
+        .0;
+
+    let worktree_id = workspace
+        .update(cx, |_, _, cx| worktree.read(cx).id())
+        .unwrap();
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_buffer((worktree_id, "main.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    buffer.update(cx, |buffer, cx| {
+        buffer.set_language(Some(Arc::new(language)), cx);
+    });
+
+    let (editor, cx) = cx.add_window_view(|window, cx| {
+        Editor::new(
+            EditorMode::full(),
+            MultiBuffer::build_from_buffer(buffer, cx),
+            Some(project),
+            window,
+            cx,
+        )
+    });
+
+    active_debug_session_panel(workspace, cx).update_in(cx, |_, window, cx| {
+        cx.focus_self(window);
+    });
+    cx.run_until_parked();
+
+    editor.update(cx, |editor, cx| editor.refresh_inline_values(cx));
+
+    cx.run_until_parked();
+
+    editor.update_in(cx, |editor, window, cx| {
+        pretty_assertions::assert_eq!(after, editor.snapshot(window, cx).text());
+    });
+}
+
+#[gpui::test]
+async fn test_inline_values_example(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    let variables = [("x", "10"), ("y", "20"), ("result", "30")];
+
+    let before = r#"
+fn main() {
+    let x = 10;
+    let y = 20;
+    let result = x + y;
+    println!("Result: {}", result);
+}
+"#
+    .unindent();
+
+    let after = r#"
+fn main() {
+    let x: 10 = 10;
+    let y: 20 = 20;
+    let result: 30 = x: 10 + y: 20;
+    println!("Result: {}", result: 30);
+}
+"#
+    .unindent();
+
+    test_inline_values_util(
+        &variables,
+        &[],
+        &before,
+        &after,
+        None,
+        rust_lang(),
+        executor,
+        cx,
+    )
+    .await;
+}
+
+#[gpui::test]
+async fn test_inline_values_with_globals(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    let variables = [("x", "5"), ("y", "10")];
+
+    let before = r#"
+static mut GLOBAL_COUNTER: usize = 42;
+
+fn main() {
+    let x = 5;
+    let y = 10;
+    unsafe {
+        GLOBAL_COUNTER += 1;
+    }
+    println!("x={}, y={}, global={}", x, y, unsafe { GLOBAL_COUNTER });
+}
+"#
+    .unindent();
+
+    let after = r#"
+static mut GLOBAL_COUNTER: 42: usize = 42;
+
+fn main() {
+    let x: 5 = 5;
+    let y: 10 = 10;
+    unsafe {
+        GLOBAL_COUNTER += 1;
+    }
+    println!("x={}, y={}, global={}", x, y, unsafe { GLOBAL_COUNTER });
+}
+"#
+    .unindent();
+
+    test_inline_values_util(
+        &variables,
+        &[("GLOBAL_COUNTER", "42")],
+        &before,
+        &after,
+        None,
+        rust_lang(),
+        executor,
+        cx,
+    )
+    .await;
+}
+
+#[gpui::test]
+async fn test_go_inline_values(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    let variables = [("x", "42"), ("y", "hello")];
+
+    let before = r#"
+package main
+
+var globalCounter int = 100
+
+func main() {
+    x := 42
+    y := "hello"
+    z := x + 10
+    println(x, y, z)
+}
+"#
+    .unindent();
+
+    let after = r#"
+package main
+
+var globalCounter: 100 int = 100
+
+func main() {
+    x: 42 := 42
+    y := "hello"
+    z := x + 10
+    println(x, y, z)
+}
+"#
+    .unindent();
+
+    test_inline_values_util(
+        &variables,
+        &[("globalCounter", "100")],
+        &before,
+        &after,
+        None,
+        go_lang(),
+        executor,
+        cx,
+    )
+    .await;
 }

crates/debugger_ui/src/tests/module_list.rs 🔗

@@ -1,5 +1,6 @@
 use crate::{
     debugger_panel::DebugPanel,
+    persistence::DebuggerPaneItem,
     tests::{active_debug_session_panel, init_test, init_test_workspace, start_debug_session},
 };
 use dap::{
@@ -110,7 +111,8 @@ async fn test_module_list(executor: BackgroundExecutor, cx: &mut TestAppContext)
         });
 
     running_state.update_in(cx, |this, window, cx| {
-        this.activate_item(crate::persistence::DebuggerPaneItem::Modules, window, cx);
+        this.ensure_pane_item(DebuggerPaneItem::Modules, window, cx);
+        this.activate_item(DebuggerPaneItem::Modules, window, cx);
         cx.refresh_windows();
     });
 

crates/debugger_ui/src/tests/new_process_modal.rs 🔗

@@ -0,0 +1,351 @@
+use dap::DapRegistry;
+use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
+use project::{FakeFs, Project};
+use serde_json::json;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+use task::{DebugRequest, DebugScenario, LaunchRequest, TaskContext, VariableName, ZedDebugConfig};
+use util::path;
+
+// use crate::new_process_modal::NewProcessMode;
+use crate::tests::{init_test, init_test_workspace};
+
+#[gpui::test]
+async fn test_debug_session_substitutes_variables_and_relativizes_paths(
+    executor: BackgroundExecutor,
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            "main.rs": "fn main() {}"
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    let test_variables = vec![(
+        VariableName::WorktreeRoot,
+        path!("/test/worktree/path").to_string(),
+    )]
+    .into_iter()
+    .collect();
+
+    let task_context = TaskContext {
+        cwd: None,
+        task_variables: test_variables,
+        project_env: Default::default(),
+    };
+
+    let home_dir = paths::home_dir();
+
+    let test_cases: Vec<(&'static str, &'static str)> = vec![
+        // Absolute path - should not be relativized
+        (
+            path!("/absolute/path/to/program"),
+            path!("/absolute/path/to/program"),
+        ),
+        // Relative path - should be prefixed with worktree root
+        (
+            format!(".{0}src{0}program", std::path::MAIN_SEPARATOR).leak(),
+            path!("/test/worktree/path/src/program"),
+        ),
+        // Home directory path - should be expanded to full home directory path
+        (
+            format!("~{0}src{0}program", std::path::MAIN_SEPARATOR).leak(),
+            home_dir
+                .join("src")
+                .join("program")
+                .to_string_lossy()
+                .to_string()
+                .leak(),
+        ),
+        // Path with $ZED_WORKTREE_ROOT - should be substituted without double appending
+        (
+            format!(
+                "$ZED_WORKTREE_ROOT{0}src{0}program",
+                std::path::MAIN_SEPARATOR
+            )
+            .leak(),
+            path!("/test/worktree/path/src/program"),
+        ),
+    ];
+
+    let called_launch = Arc::new(AtomicBool::new(false));
+
+    for (input_path, expected_path) in test_cases {
+        let _subscription = project::debugger::test::intercept_debug_sessions(cx, {
+            let called_launch = called_launch.clone();
+            move |client| {
+                client.on_request::<dap::requests::Launch, _>({
+                    let called_launch = called_launch.clone();
+
+                    move |_, args| {
+                        let config = args.raw.as_object().unwrap();
+
+                        assert_eq!(
+                            config["program"].as_str().unwrap(),
+                            expected_path,
+                            "Program path was not correctly substituted for input: {}",
+                            input_path
+                        );
+
+                        assert_eq!(
+                            config["cwd"].as_str().unwrap(),
+                            expected_path,
+                            "CWD path was not correctly substituted for input: {}",
+                            input_path
+                        );
+
+                        let expected_other_field = if input_path.contains("$ZED_WORKTREE_ROOT") {
+                            input_path
+                                .replace("$ZED_WORKTREE_ROOT", &path!("/test/worktree/path"))
+                                .to_owned()
+                        } else {
+                            input_path.to_string()
+                        };
+
+                        assert_eq!(
+                            config["otherField"].as_str().unwrap(),
+                            &expected_other_field,
+                            "Other field was incorrectly modified for input: {}",
+                            input_path
+                        );
+
+                        called_launch.store(true, Ordering::SeqCst);
+
+                        Ok(())
+                    }
+                });
+            }
+        });
+
+        let scenario = DebugScenario {
+            adapter: "fake-adapter".into(),
+            label: "test-debug-session".into(),
+            build: None,
+            config: json!({
+                "request": "launch",
+                "program": input_path,
+                "cwd": input_path,
+                "otherField": input_path
+            }),
+            tcp_connection: None,
+        };
+
+        workspace
+            .update(cx, |workspace, window, cx| {
+                workspace.start_debug_session(scenario, task_context.clone(), None, window, cx)
+            })
+            .unwrap();
+
+        cx.run_until_parked();
+
+        assert!(called_launch.load(Ordering::SeqCst));
+        called_launch.store(false, Ordering::SeqCst);
+    }
+}
+
+// #[gpui::test]
+// async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+//     init_test(cx);
+
+//     let fs = FakeFs::new(executor.clone());
+//     fs.insert_tree(
+//         path!("/project"),
+//         json!({
+//             "main.rs": "fn main() {}"
+//         }),
+//     )
+//     .await;
+
+//     let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+//     let workspace = init_test_workspace(&project, cx).await;
+//     let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+//     workspace
+//         .update(cx, |workspace, window, cx| {
+//             crate::new_process_modal::NewProcessModal::show(
+//                 workspace,
+//                 window,
+//                 NewProcessMode::Debug,
+//                 None,
+//                 cx,
+//             );
+//         })
+//         .unwrap();
+
+//     cx.run_until_parked();
+
+//     let modal = workspace
+//         .update(cx, |workspace, _, cx| {
+//             workspace.active_modal::<crate::new_process_modal::NewProcessModal>(cx)
+//         })
+//         .unwrap()
+//         .expect("Modal should be active");
+
+//     modal.update_in(cx, |modal, window, cx| {
+//         modal.set_configure("/project/main", "/project", false, window, cx);
+//         modal.save_scenario(window, cx);
+//     });
+
+//     cx.executor().run_until_parked();
+
+//     let debug_json_content = fs
+//         .load(path!("/project/.zed/debug.json").as_ref())
+//         .await
+//         .expect("debug.json should exist");
+
+//     let expected_content = vec![
+//         "[",
+//         "  {",
+//         r#"    "adapter": "fake-adapter","#,
+//         r#"    "label": "main (fake-adapter)","#,
+//         r#"    "request": "launch","#,
+//         r#"    "program": "/project/main","#,
+//         r#"    "cwd": "/project","#,
+//         r#"    "args": [],"#,
+//         r#"    "env": {}"#,
+//         "  }",
+//         "]",
+//     ];
+
+//     let actual_lines: Vec<&str> = debug_json_content.lines().collect();
+//     pretty_assertions::assert_eq!(expected_content, actual_lines);
+
+//     modal.update_in(cx, |modal, window, cx| {
+//         modal.set_configure("/project/other", "/project", true, window, cx);
+//         modal.save_scenario(window, cx);
+//     });
+
+//     cx.executor().run_until_parked();
+
+//     let debug_json_content = fs
+//         .load(path!("/project/.zed/debug.json").as_ref())
+//         .await
+//         .expect("debug.json should exist after second save");
+
+//     let expected_content = vec![
+//         "[",
+//         "  {",
+//         r#"    "adapter": "fake-adapter","#,
+//         r#"    "label": "main (fake-adapter)","#,
+//         r#"    "request": "launch","#,
+//         r#"    "program": "/project/main","#,
+//         r#"    "cwd": "/project","#,
+//         r#"    "args": [],"#,
+//         r#"    "env": {}"#,
+//         "  },",
+//         "  {",
+//         r#"    "adapter": "fake-adapter","#,
+//         r#"    "label": "other (fake-adapter)","#,
+//         r#"    "request": "launch","#,
+//         r#"    "program": "/project/other","#,
+//         r#"    "cwd": "/project","#,
+//         r#"    "args": [],"#,
+//         r#"    "env": {}"#,
+//         "  }",
+//         "]",
+//     ];
+
+//     let actual_lines: Vec<&str> = debug_json_content.lines().collect();
+//     pretty_assertions::assert_eq!(expected_content, actual_lines);
+// }
+
+#[gpui::test]
+async fn test_dap_adapter_config_conversion_and_validation(cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let mut expected_adapters = vec![
+        "CodeLLDB",
+        "Debugpy",
+        "PHP",
+        "JavaScript",
+        "Ruby",
+        "Delve",
+        "GDB",
+        "fake-adapter",
+    ];
+
+    let adapter_names = cx.update(|cx| {
+        let registry = DapRegistry::global(cx);
+        registry.enumerate_adapters()
+    });
+
+    let zed_config = ZedDebugConfig {
+        label: "test_debug_session".into(),
+        adapter: "test_adapter".into(),
+        request: DebugRequest::Launch(LaunchRequest {
+            program: "test_program".into(),
+            cwd: None,
+            args: vec![],
+            env: Default::default(),
+        }),
+        stop_on_entry: Some(true),
+    };
+
+    for adapter_name in adapter_names {
+        let adapter_str = adapter_name.to_string();
+        if let Some(pos) = expected_adapters.iter().position(|&x| x == adapter_str) {
+            expected_adapters.remove(pos);
+        }
+
+        let adapter = cx
+            .update(|cx| {
+                let registry = DapRegistry::global(cx);
+                registry.adapter(adapter_name.as_ref())
+            })
+            .unwrap_or_else(|| panic!("Adapter {} should exist", adapter_name));
+
+        let mut adapter_specific_config = zed_config.clone();
+        adapter_specific_config.adapter = adapter_name.to_string().into();
+
+        let debug_scenario = adapter
+            .config_from_zed_format(adapter_specific_config)
+            .await
+            .unwrap_or_else(|_| {
+                panic!(
+                    "Adapter {} should successfully convert from Zed format",
+                    adapter_name
+                )
+            });
+
+        assert!(
+            debug_scenario.config.is_object(),
+            "Adapter {} should produce a JSON object for config",
+            adapter_name
+        );
+
+        let request_type = adapter
+            .request_kind(&debug_scenario.config)
+            .await
+            .unwrap_or_else(|_| {
+                panic!(
+                    "Adapter {} should validate the config successfully",
+                    adapter_name
+                )
+            });
+
+        match request_type {
+            dap::StartDebuggingRequestArgumentsRequest::Launch => {}
+            dap::StartDebuggingRequestArgumentsRequest::Attach => {
+                panic!(
+                    "Expected Launch request but got Attach for adapter {}",
+                    adapter_name
+                );
+            }
+        }
+    }
+
+    assert!(
+        expected_adapters.is_empty(),
+        "The following expected adapters were not found in the registry: {:?}",
+        expected_adapters
+    );
+}

crates/debugger_ui/src/tests/stack_frame_list.rs 🔗

@@ -168,7 +168,7 @@ async fn test_fetch_initial_stack_frames_and_go_to_stack_frame(
             .update(cx, |state, _| state.stack_frame_list().clone());
 
         stack_frame_list.update(cx, |stack_frame_list, cx| {
-            assert_eq!(Some(1), stack_frame_list.selected_stack_frame_id());
+            assert_eq!(Some(1), stack_frame_list.opened_stack_frame_id());
             assert_eq!(stack_frames, stack_frame_list.dap_stack_frames(cx));
         });
     });
@@ -373,14 +373,14 @@ async fn test_select_stack_frame(executor: BackgroundExecutor, cx: &mut TestAppC
         .unwrap();
 
     stack_frame_list.update(cx, |stack_frame_list, cx| {
-        assert_eq!(Some(1), stack_frame_list.selected_stack_frame_id());
+        assert_eq!(Some(1), stack_frame_list.opened_stack_frame_id());
         assert_eq!(stack_frames, stack_frame_list.dap_stack_frames(cx));
     });
 
     // select second stack frame
     stack_frame_list
         .update_in(cx, |stack_frame_list, window, cx| {
-            stack_frame_list.select_stack_frame(&stack_frames[1], true, window, cx)
+            stack_frame_list.go_to_stack_frame(stack_frames[1].id, window, cx)
         })
         .await
         .unwrap();
@@ -388,7 +388,7 @@ async fn test_select_stack_frame(executor: BackgroundExecutor, cx: &mut TestAppC
     cx.run_until_parked();
 
     stack_frame_list.update(cx, |stack_frame_list, cx| {
-        assert_eq!(Some(2), stack_frame_list.selected_stack_frame_id());
+        assert_eq!(Some(2), stack_frame_list.opened_stack_frame_id());
         assert_eq!(stack_frames, stack_frame_list.dap_stack_frames(cx));
     });
 
@@ -718,11 +718,7 @@ async fn test_collapsed_entries(executor: BackgroundExecutor, cx: &mut TestAppCo
                 stack_frame_list.entries()
             );
 
-            stack_frame_list.expand_collapsed_entry(
-                1,
-                &vec![stack_frames[1].clone(), stack_frames[2].clone()],
-                cx,
-            );
+            stack_frame_list.expand_collapsed_entry(1, cx);
 
             assert_eq!(
                 &vec![
@@ -739,11 +735,7 @@ async fn test_collapsed_entries(executor: BackgroundExecutor, cx: &mut TestAppCo
                 stack_frame_list.entries()
             );
 
-            stack_frame_list.expand_collapsed_entry(
-                4,
-                &vec![stack_frames[4].clone(), stack_frames[5].clone()],
-                cx,
-            );
+            stack_frame_list.expand_collapsed_entry(4, cx);
 
             assert_eq!(
                 &vec![

crates/debugger_ui/src/tests/variable_list.rs 🔗

@@ -5,18 +5,22 @@ use std::sync::{
 
 use crate::{
     DebugPanel,
-    session::running::variable_list::{CollapseSelectedEntry, ExpandSelectedEntry},
+    persistence::DebuggerPaneItem,
+    session::running::variable_list::{
+        AddWatch, CollapseSelectedEntry, ExpandSelectedEntry, RemoveWatch,
+    },
     tests::{active_debug_session_panel, init_test, init_test_workspace, start_debug_session},
 };
 use collections::HashMap;
 use dap::{
     Scope, StackFrame, Variable,
-    requests::{Initialize, Launch, Scopes, StackTrace, Variables},
+    requests::{Evaluate, Initialize, Launch, Scopes, StackTrace, Variables},
 };
 use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
 use menu::{SelectFirst, SelectNext, SelectPrevious};
 use project::{FakeFs, Project};
 use serde_json::json;
+use ui::SharedString;
 use unindent::Unindent as _;
 use util::path;
 
@@ -190,7 +194,10 @@ async fn test_basic_fetch_initial_scope_and_variables(
     running_state.update(cx, |running_state, cx| {
         let (stack_frame_list, stack_frame_id) =
             running_state.stack_frame_list().update(cx, |list, _| {
-                (list.flatten_entries(), list.selected_stack_frame_id())
+                (
+                    list.flatten_entries(true, true),
+                    list.opened_stack_frame_id(),
+                )
             });
 
         assert_eq!(stack_frames, stack_frame_list);
@@ -431,7 +438,10 @@ async fn test_fetch_variables_for_multiple_scopes(
     running_state.update(cx, |running_state, cx| {
         let (stack_frame_list, stack_frame_id) =
             running_state.stack_frame_list().update(cx, |list, _| {
-                (list.flatten_entries(), list.selected_stack_frame_id())
+                (
+                    list.flatten_entries(true, true),
+                    list.opened_stack_frame_id(),
+                )
             });
 
         assert_eq!(Some(1), stack_frame_id);
@@ -706,7 +716,13 @@ async fn test_keyboard_navigation(executor: BackgroundExecutor, cx: &mut TestApp
             cx.focus_self(window);
             let running = item.running_state().clone();
 
-            let variable_list = running.read_with(cx, |state, _| state.variable_list().clone());
+            let variable_list = running.update(cx, |state, cx| {
+                // have to do this because the variable list pane should be shown/active
+                // for testing keyboard navigation
+                state.activate_item(DebuggerPaneItem::Variables, window, cx);
+
+                state.variable_list().clone()
+            });
             variable_list.update(cx, |_, cx| cx.focus_self(window));
             running
         });
@@ -1452,7 +1468,10 @@ async fn test_variable_list_only_sends_requests_when_rendering(
     running_state.update(cx, |running_state, cx| {
         let (stack_frame_list, stack_frame_id) =
             running_state.stack_frame_list().update(cx, |list, _| {
-                (list.flatten_entries(), list.selected_stack_frame_id())
+                (
+                    list.flatten_entries(true, true),
+                    list.opened_stack_frame_id(),
+                )
             });
 
         assert_eq!(Some(1), stack_frame_id);
@@ -1734,7 +1753,10 @@ async fn test_it_fetches_scopes_variables_when_you_select_a_stack_frame(
     running_state.update(cx, |running_state, cx| {
         let (stack_frame_list, stack_frame_id) =
             running_state.stack_frame_list().update(cx, |list, _| {
-                (list.flatten_entries(), list.selected_stack_frame_id())
+                (
+                    list.flatten_entries(true, true),
+                    list.opened_stack_frame_id(),
+                )
             });
 
         let variable_list = running_state.variable_list().read(cx);
@@ -1745,7 +1767,7 @@ async fn test_it_fetches_scopes_variables_when_you_select_a_stack_frame(
             running_state
                 .stack_frame_list()
                 .read(cx)
-                .selected_stack_frame_id(),
+                .opened_stack_frame_id(),
             Some(1)
         );
 
@@ -1778,7 +1800,7 @@ async fn test_it_fetches_scopes_variables_when_you_select_a_stack_frame(
             running_state
                 .stack_frame_list()
                 .update(cx, |stack_frame_list, cx| {
-                    stack_frame_list.select_stack_frame(&stack_frames[1], true, window, cx)
+                    stack_frame_list.go_to_stack_frame(stack_frames[1].id, window, cx)
                 })
         })
         .await
@@ -1789,7 +1811,10 @@ async fn test_it_fetches_scopes_variables_when_you_select_a_stack_frame(
     running_state.update(cx, |running_state, cx| {
         let (stack_frame_list, stack_frame_id) =
             running_state.stack_frame_list().update(cx, |list, _| {
-                (list.flatten_entries(), list.selected_stack_frame_id())
+                (
+                    list.flatten_entries(true, true),
+                    list.opened_stack_frame_id(),
+                )
             });
 
         let variable_list = running_state.variable_list().read(cx);
@@ -1806,3 +1831,515 @@ async fn test_it_fetches_scopes_variables_when_you_select_a_stack_frame(
         assert_eq!(variables, frame_2_variables,);
     });
 }
+
+#[gpui::test]
+async fn test_add_and_remove_watcher(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+
+    let test_file_content = r#"
+        const variable1 = "Value 1";
+        const variable2 = "Value 2";
+    "#
+    .unindent();
+
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+           "src": {
+               "test.js": test_file_content,
+           }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    workspace
+        .update(cx, |workspace, window, cx| {
+            workspace.focus_panel::<DebugPanel>(window, cx);
+        })
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+    let session = start_debug_session(&workspace, cx, |_| {}).unwrap();
+    let client = session.update(cx, |session, _| session.adapter_client().unwrap());
+
+    client.on_request::<dap::requests::Threads, _>(move |_, _| {
+        Ok(dap::ThreadsResponse {
+            threads: vec![dap::Thread {
+                id: 1,
+                name: "Thread 1".into(),
+            }],
+        })
+    });
+
+    let stack_frames = vec![StackFrame {
+        id: 1,
+        name: "Stack Frame 1".into(),
+        source: Some(dap::Source {
+            name: Some("test.js".into()),
+            path: Some(path!("/project/src/test.js").into()),
+            source_reference: None,
+            presentation_hint: None,
+            origin: None,
+            sources: None,
+            adapter_data: None,
+            checksums: None,
+        }),
+        line: 1,
+        column: 1,
+        end_line: None,
+        end_column: None,
+        can_restart: None,
+        instruction_pointer_reference: None,
+        module_id: None,
+        presentation_hint: None,
+    }];
+
+    client.on_request::<StackTrace, _>({
+        let stack_frames = Arc::new(stack_frames.clone());
+        move |_, args| {
+            assert_eq!(1, args.thread_id);
+
+            Ok(dap::StackTraceResponse {
+                stack_frames: (*stack_frames).clone(),
+                total_frames: None,
+            })
+        }
+    });
+
+    let scopes = vec![Scope {
+        name: "Scope 1".into(),
+        presentation_hint: None,
+        variables_reference: 2,
+        named_variables: None,
+        indexed_variables: None,
+        expensive: false,
+        source: None,
+        line: None,
+        column: None,
+        end_line: None,
+        end_column: None,
+    }];
+
+    client.on_request::<Scopes, _>({
+        let scopes = Arc::new(scopes.clone());
+        move |_, args| {
+            assert_eq!(1, args.frame_id);
+
+            Ok(dap::ScopesResponse {
+                scopes: (*scopes).clone(),
+            })
+        }
+    });
+
+    let variables = vec![
+        Variable {
+            name: "variable1".into(),
+            value: "value 1".into(),
+            type_: None,
+            presentation_hint: None,
+            evaluate_name: None,
+            variables_reference: 0,
+            named_variables: None,
+            indexed_variables: None,
+            memory_reference: None,
+            declaration_location_reference: None,
+            value_location_reference: None,
+        },
+        Variable {
+            name: "variable2".into(),
+            value: "value 2".into(),
+            type_: None,
+            presentation_hint: None,
+            evaluate_name: None,
+            variables_reference: 0,
+            named_variables: None,
+            indexed_variables: None,
+            memory_reference: None,
+            declaration_location_reference: None,
+            value_location_reference: None,
+        },
+    ];
+
+    client.on_request::<Variables, _>({
+        let variables = Arc::new(variables.clone());
+        move |_, args| {
+            assert_eq!(2, args.variables_reference);
+
+            Ok(dap::VariablesResponse {
+                variables: (*variables).clone(),
+            })
+        }
+    });
+
+    client.on_request::<Evaluate, _>({
+        move |_, args| {
+            assert_eq!("variable1", args.expression);
+
+            Ok(dap::EvaluateResponse {
+                result: "value1".to_owned(),
+                type_: None,
+                presentation_hint: None,
+                variables_reference: 2,
+                named_variables: None,
+                indexed_variables: None,
+                memory_reference: None,
+                value_location_reference: None,
+            })
+        }
+    });
+
+    client
+        .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
+            reason: dap::StoppedEventReason::Pause,
+            description: None,
+            thread_id: Some(1),
+            preserve_focus_hint: None,
+            text: None,
+            all_threads_stopped: None,
+            hit_breakpoint_ids: None,
+        }))
+        .await;
+
+    cx.run_until_parked();
+
+    let running_state =
+        active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| {
+            cx.focus_self(window);
+            let running = item.running_state().clone();
+
+            let variable_list = running.update(cx, |state, cx| {
+                // have to do this because the variable list pane should be shown/active
+                // for testing the variable list
+                state.activate_item(DebuggerPaneItem::Variables, window, cx);
+
+                state.variable_list().clone()
+            });
+            variable_list.update(cx, |_, cx| cx.focus_self(window));
+            running
+        });
+    cx.run_until_parked();
+
+    // select variable 1 from first scope
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |_, cx| {
+            cx.dispatch_action(&SelectFirst);
+            cx.dispatch_action(&SelectNext);
+        });
+    });
+    cx.run_until_parked();
+
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |_, cx| {
+            cx.dispatch_action(&AddWatch);
+        });
+    });
+    cx.run_until_parked();
+
+    // assert watcher for variable1 was added
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |list, _| {
+            list.assert_visual_entries(vec![
+                "> variable1",
+                "v Scope 1",
+                "    > variable1 <=== selected",
+                "    > variable2",
+            ]);
+        });
+    });
+
+    session.update(cx, |session, _| {
+        let watcher = session
+            .watchers()
+            .get(&SharedString::from("variable1"))
+            .unwrap();
+
+        assert_eq!("value1", watcher.value.to_string());
+        assert_eq!("variable1", watcher.expression.to_string());
+        assert_eq!(2, watcher.variables_reference);
+    });
+
+    // select added watcher for variable1
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |_, cx| {
+            cx.dispatch_action(&SelectFirst);
+        });
+    });
+    cx.run_until_parked();
+
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |_, cx| {
+            cx.dispatch_action(&RemoveWatch);
+        });
+    });
+    cx.run_until_parked();
+
+    // assert watcher for variable1 was removed
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |list, _| {
+            list.assert_visual_entries(vec!["v Scope 1", "    > variable1", "    > variable2"]);
+        });
+    });
+}
+
+#[gpui::test]
+async fn test_refresh_watchers(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+
+    let test_file_content = r#"
+        const variable1 = "Value 1";
+        const variable2 = "Value 2";
+    "#
+    .unindent();
+
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+           "src": {
+               "test.js": test_file_content,
+           }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    workspace
+        .update(cx, |workspace, window, cx| {
+            workspace.focus_panel::<DebugPanel>(window, cx);
+        })
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+    let session = start_debug_session(&workspace, cx, |_| {}).unwrap();
+    let client = session.update(cx, |session, _| session.adapter_client().unwrap());
+
+    client.on_request::<dap::requests::Threads, _>(move |_, _| {
+        Ok(dap::ThreadsResponse {
+            threads: vec![dap::Thread {
+                id: 1,
+                name: "Thread 1".into(),
+            }],
+        })
+    });
+
+    let stack_frames = vec![StackFrame {
+        id: 1,
+        name: "Stack Frame 1".into(),
+        source: Some(dap::Source {
+            name: Some("test.js".into()),
+            path: Some(path!("/project/src/test.js").into()),
+            source_reference: None,
+            presentation_hint: None,
+            origin: None,
+            sources: None,
+            adapter_data: None,
+            checksums: None,
+        }),
+        line: 1,
+        column: 1,
+        end_line: None,
+        end_column: None,
+        can_restart: None,
+        instruction_pointer_reference: None,
+        module_id: None,
+        presentation_hint: None,
+    }];
+
+    client.on_request::<StackTrace, _>({
+        let stack_frames = Arc::new(stack_frames.clone());
+        move |_, args| {
+            assert_eq!(1, args.thread_id);
+
+            Ok(dap::StackTraceResponse {
+                stack_frames: (*stack_frames).clone(),
+                total_frames: None,
+            })
+        }
+    });
+
+    let scopes = vec![Scope {
+        name: "Scope 1".into(),
+        presentation_hint: None,
+        variables_reference: 2,
+        named_variables: None,
+        indexed_variables: None,
+        expensive: false,
+        source: None,
+        line: None,
+        column: None,
+        end_line: None,
+        end_column: None,
+    }];
+
+    client.on_request::<Scopes, _>({
+        let scopes = Arc::new(scopes.clone());
+        move |_, args| {
+            assert_eq!(1, args.frame_id);
+
+            Ok(dap::ScopesResponse {
+                scopes: (*scopes).clone(),
+            })
+        }
+    });
+
+    let variables = vec![
+        Variable {
+            name: "variable1".into(),
+            value: "value 1".into(),
+            type_: None,
+            presentation_hint: None,
+            evaluate_name: None,
+            variables_reference: 0,
+            named_variables: None,
+            indexed_variables: None,
+            memory_reference: None,
+            declaration_location_reference: None,
+            value_location_reference: None,
+        },
+        Variable {
+            name: "variable2".into(),
+            value: "value 2".into(),
+            type_: None,
+            presentation_hint: None,
+            evaluate_name: None,
+            variables_reference: 0,
+            named_variables: None,
+            indexed_variables: None,
+            memory_reference: None,
+            declaration_location_reference: None,
+            value_location_reference: None,
+        },
+    ];
+
+    client.on_request::<Variables, _>({
+        let variables = Arc::new(variables.clone());
+        move |_, args| {
+            assert_eq!(2, args.variables_reference);
+
+            Ok(dap::VariablesResponse {
+                variables: (*variables).clone(),
+            })
+        }
+    });
+
+    client.on_request::<Evaluate, _>({
+        move |_, args| {
+            assert_eq!("variable1", args.expression);
+
+            Ok(dap::EvaluateResponse {
+                result: "value1".to_owned(),
+                type_: None,
+                presentation_hint: None,
+                variables_reference: 2,
+                named_variables: None,
+                indexed_variables: None,
+                memory_reference: None,
+                value_location_reference: None,
+            })
+        }
+    });
+
+    client
+        .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
+            reason: dap::StoppedEventReason::Pause,
+            description: None,
+            thread_id: Some(1),
+            preserve_focus_hint: None,
+            text: None,
+            all_threads_stopped: None,
+            hit_breakpoint_ids: None,
+        }))
+        .await;
+
+    cx.run_until_parked();
+
+    let running_state =
+        active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| {
+            cx.focus_self(window);
+            let running = item.running_state().clone();
+
+            let variable_list = running.update(cx, |state, cx| {
+                // have to do this because the variable list pane should be shown/active
+                // for testing the variable list
+                state.activate_item(DebuggerPaneItem::Variables, window, cx);
+
+                state.variable_list().clone()
+            });
+            variable_list.update(cx, |_, cx| cx.focus_self(window));
+            running
+        });
+    cx.run_until_parked();
+
+    // select variable 1 from first scope
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |_, cx| {
+            cx.dispatch_action(&SelectFirst);
+            cx.dispatch_action(&SelectNext);
+        });
+    });
+    cx.run_until_parked();
+
+    running_state.update(cx, |running_state, cx| {
+        running_state.variable_list().update(cx, |_, cx| {
+            cx.dispatch_action(&AddWatch);
+        });
+    });
+    cx.run_until_parked();
+
+    session.update(cx, |session, _| {
+        let watcher = session
+            .watchers()
+            .get(&SharedString::from("variable1"))
+            .unwrap();
+
+        assert_eq!("value1", watcher.value.to_string());
+        assert_eq!("variable1", watcher.expression.to_string());
+        assert_eq!(2, watcher.variables_reference);
+    });
+
+    client.on_request::<Evaluate, _>({
+        move |_, args| {
+            assert_eq!("variable1", args.expression);
+
+            Ok(dap::EvaluateResponse {
+                result: "value updated".to_owned(),
+                type_: None,
+                presentation_hint: None,
+                variables_reference: 3,
+                named_variables: None,
+                indexed_variables: None,
+                memory_reference: None,
+                value_location_reference: None,
+            })
+        }
+    });
+
+    client
+        .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
+            reason: dap::StoppedEventReason::Pause,
+            description: None,
+            thread_id: Some(1),
+            preserve_focus_hint: None,
+            text: None,
+            all_threads_stopped: None,
+            hit_breakpoint_ids: None,
+        }))
+        .await;
+
+    cx.run_until_parked();
+
+    session.update(cx, |session, _| {
+        let watcher = session
+            .watchers()
+            .get(&SharedString::from("variable1"))
+            .unwrap();
+
+        assert_eq!("value updated", watcher.value.to_string());
+        assert_eq!("variable1", watcher.expression.to_string());
+        assert_eq!(3, watcher.variables_reference);
+    });
+}

crates/deepseek/src/deepseek.rs 🔗

@@ -29,7 +29,7 @@ impl TryFrom<String> for Role {
             "assistant" => Ok(Self::Assistant),
             "system" => Ok(Self::System),
             "tool" => Ok(Self::Tool),
-            _ => Err(anyhow!("invalid role '{value}'")),
+            _ => anyhow::bail!("invalid role '{value}'"),
         }
     }
 }
@@ -58,8 +58,8 @@ pub enum Model {
         name: String,
         /// The name displayed in the UI, such as in the assistant panel model dropdown menu.
         display_name: Option<String>,
-        max_tokens: usize,
-        max_output_tokens: Option<u32>,
+        max_tokens: u64,
+        max_output_tokens: Option<u64>,
     },
 }
 
@@ -72,7 +72,7 @@ impl Model {
         match id {
             "deepseek-chat" => Ok(Self::Chat),
             "deepseek-reasoner" => Ok(Self::Reasoner),
-            _ => Err(anyhow!("invalid model id")),
+            _ => anyhow::bail!("invalid model id {id}"),
         }
     }
 
@@ -94,14 +94,14 @@ impl Model {
         }
     }
 
-    pub fn max_token_count(&self) -> usize {
+    pub fn max_token_count(&self) -> u64 {
         match self {
             Self::Chat | Self::Reasoner => 64_000,
             Self::Custom { max_tokens, .. } => *max_tokens,
         }
     }
 
-    pub fn max_output_tokens(&self) -> Option<u32> {
+    pub fn max_output_tokens(&self) -> Option<u64> {
         match self {
             Self::Chat => Some(8_192),
             Self::Reasoner => Some(8_192),
@@ -118,7 +118,7 @@ pub struct Request {
     pub messages: Vec<RequestMessage>,
     pub stream: bool,
     #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub max_tokens: Option<u32>,
+    pub max_tokens: Option<u64>,
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub temperature: Option<f32>,
     #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -201,13 +201,13 @@ pub struct Response {
 
 #[derive(Serialize, Deserialize, Debug)]
 pub struct Usage {
-    pub prompt_tokens: u32,
-    pub completion_tokens: u32,
-    pub total_tokens: u32,
+    pub prompt_tokens: u64,
+    pub completion_tokens: u64,
+    pub total_tokens: u64,
     #[serde(default)]
-    pub prompt_cache_hit_tokens: u32,
+    pub prompt_cache_hit_tokens: u64,
     #[serde(default)]
-    pub prompt_cache_miss_tokens: u32,
+    pub prompt_cache_miss_tokens: u64,
 }
 
 #[derive(Serialize, Deserialize, Debug)]
@@ -224,6 +224,7 @@ pub struct StreamResponse {
     pub created: u64,
     pub model: String,
     pub choices: Vec<StreamChoice>,
+    pub usage: Option<Usage>,
 }
 
 #[derive(Serialize, Deserialize, Debug)]
@@ -296,10 +297,10 @@ pub async fn stream_completion(
     } else {
         let mut body = String::new();
         response.body_mut().read_to_string(&mut body).await?;
-        Err(anyhow!(
+        anyhow::bail!(
             "Failed to connect to DeepSeek API: {} {}",
             response.status(),
             body,
-        ))
+        );
     }
 }

crates/diagnostics/Cargo.toml 🔗

@@ -18,12 +18,10 @@ collections.workspace = true
 component.workspace = true
 ctor.workspace = true
 editor.workspace = true
-env_logger.workspace = true
 futures.workspace = true
 gpui.workspace = true
 indoc.workspace = true
 language.workspace = true
-linkme.workspace = true
 log.workspace = true
 lsp.workspace = true
 markdown.workspace = true
@@ -45,9 +43,10 @@ editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
 markdown = { workspace = true, features = ["test-support"] }
-lsp = { workspace = true, features = ["test-support"] }
+lsp = { workspace = true, features=["test-support"] }
 serde_json.workspace = true
 theme = { workspace = true, features = ["test-support"] }
 unindent.workspace = true
 workspace = { workspace = true, features = ["test-support"] }
 pretty_assertions.workspace = true
+zlog.workspace = true

crates/diagnostics/src/diagnostic_renderer.rs 🔗

@@ -4,7 +4,6 @@ use editor::{
     Anchor, Editor, EditorSnapshot, ToOffset,
     display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle},
     hover_popover::diagnostics_markdown_style,
-    scroll::Autoscroll,
 };
 use gpui::{AppContext, Entity, Focusable, WeakEntity};
 use language::{BufferId, Diagnostic, DiagnosticEntry};
@@ -256,7 +255,7 @@ impl DiagnosticBlock {
 
         if let Some(diagnostics_editor) = diagnostics_editor {
             if let Some(diagnostic) = diagnostics_editor
-                .update(cx, |diagnostics, _| {
+                .read_with(cx, |diagnostics, _| {
                     diagnostics
                         .diagnostics
                         .get(&buffer_id)
@@ -311,7 +310,7 @@ impl DiagnosticBlock {
         let range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot);
 
         editor.unfold_ranges(&[range.start..range.end], true, false, cx);
-        editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+        editor.change_selections(Default::default(), window, cx, |s| {
             s.select_ranges([range.start..range.start]);
         });
         window.focus(&editor.focus_handle(cx));

crates/diagnostics/src/diagnostics.rs 🔗

@@ -12,7 +12,6 @@ use diagnostic_renderer::DiagnosticBlock;
 use editor::{
     DEFAULT_MULTIBUFFER_CONTEXT, Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey,
     display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
-    scroll::Autoscroll,
 };
 use futures::future::join_all;
 use gpui::{
@@ -43,7 +42,7 @@ use ui::{Icon, IconName, Label, h_flex, prelude::*};
 use util::ResultExt;
 use workspace::{
     ItemNavHistory, ToolbarItemLocation, Workspace,
-    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
+    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
     searchable::SearchableItemHandle,
 };
 
@@ -626,7 +625,7 @@ impl ProjectDiagnosticsEditor {
                     if let Some(anchor_range) = anchor_ranges.first() {
                         let range_to_select = anchor_range.start..anchor_range.start;
                         this.editor.update(cx, |editor, cx| {
-                            editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+                            editor.change_selections(Default::default(), window, cx, |s| {
                                 s.select_anchor_ranges([range_to_select]);
                             })
                         });
@@ -849,12 +848,12 @@ impl Item for ProjectDiagnosticsEditor {
 
     fn save(
         &mut self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        self.editor.save(format, project, window, cx)
+        self.editor.save(options, project, window, cx)
     }
 
     fn save_as(

crates/diagnostics/src/diagnostics_tests.rs 🔗

@@ -1,7 +1,7 @@
 use super::*;
 use collections::{HashMap, HashSet};
 use editor::{
-    DisplayPoint, EditorSettings, InlayId,
+    DisplayPoint, EditorSettings,
     actions::{GoToDiagnostic, GoToPreviousDiagnostic, Hover, MoveToBeginning},
     display_map::{DisplayRow, Inlay},
     test::{
@@ -11,7 +11,7 @@ use editor::{
 };
 use gpui::{TestAppContext, VisualTestContext};
 use indoc::indoc;
-use language::Rope;
+use language::{DiagnosticSourceKind, Rope};
 use lsp::LanguageServerId;
 use pretty_assertions::assert_eq;
 use project::FakeFs;
@@ -27,9 +27,7 @@ use util::{RandomCharIter, path, post_inc};
 
 #[ctor::ctor]
 fn init_logger() {
-    if env::var("RUST_LOG").is_ok() {
-        env_logger::init();
-    }
+    zlog::init_test();
 }
 
 #[gpui::test]
@@ -107,7 +105,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
             }
             ],
             version: None
-        }, &[], cx).unwrap();
+        }, None, DiagnosticSourceKind::Pushed, &[], cx).unwrap();
     });
 
     // Open the project diagnostics view while there are already diagnostics.
@@ -178,6 +176,8 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
                     }],
                     version: None,
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -263,6 +263,8 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
                     ],
                     version: None,
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -370,6 +372,8 @@ async fn test_diagnostics_with_folds(cx: &mut TestAppContext) {
                     }],
                     version: None,
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -467,6 +471,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
                     }],
                     version: None,
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -509,6 +515,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
                     }],
                     version: None,
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -550,6 +558,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
                     }],
                     version: None,
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -562,6 +572,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
                     diagnostics: vec![],
                     version: None,
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -602,6 +614,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
                     }],
                     version: None,
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -734,6 +748,8 @@ async fn test_random_diagnostics_blocks(cx: &mut TestAppContext, mut rng: StdRng
                                 diagnostics: diagnostics.clone(),
                                 version: None,
                             },
+                            None,
+                            DiagnosticSourceKind::Pushed,
                             &[],
                             cx,
                         )
@@ -854,11 +870,11 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S
 
                         editor.splice_inlays(
                             &[],
-                            vec![Inlay {
-                                id: InlayId::InlineCompletion(post_inc(&mut next_inlay_id)),
-                                position: snapshot.buffer_snapshot.anchor_before(position),
-                                text: Rope::from(format!("Test inlay {next_inlay_id}")),
-                            }],
+                            vec![Inlay::inline_completion(
+                                post_inc(&mut next_inlay_id),
+                                snapshot.buffer_snapshot.anchor_before(position),
+                                format!("Test inlay {next_inlay_id}"),
+                            )],
                             cx,
                         );
                     }
@@ -921,6 +937,8 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S
                                 diagnostics: diagnostics.clone(),
                                 version: None,
                             },
+                            None,
+                            DiagnosticSourceKind::Pushed,
                             &[],
                             cx,
                         )
@@ -976,6 +994,8 @@ async fn active_diagnostics_dismiss_after_invalidation(cx: &mut TestAppContext)
                             ..Default::default()
                         }],
                     },
+                    None,
+                    DiagnosticSourceKind::Pushed,
                     &[],
                     cx,
                 )
@@ -1009,6 +1029,8 @@ async fn active_diagnostics_dismiss_after_invalidation(cx: &mut TestAppContext)
                         version: None,
                         diagnostics: Vec::new(),
                     },
+                    None,
+                    DiagnosticSourceKind::Pushed,
                     &[],
                     cx,
                 )
@@ -1090,6 +1112,8 @@ async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) {
                             },
                         ],
                     },
+                    None,
+                    DiagnosticSourceKind::Pushed,
                     &[],
                     cx,
                 )
@@ -1228,6 +1252,8 @@ async fn test_diagnostics_with_links(cx: &mut TestAppContext) {
                         ..Default::default()
                     }],
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -1279,6 +1305,8 @@ async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext)
                         ..Default::default()
                     }],
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -1380,6 +1408,8 @@ async fn test_diagnostics_with_code(cx: &mut TestAppContext) {
                     ],
                     version: None,
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -1413,7 +1443,7 @@ async fn test_diagnostics_with_code(cx: &mut TestAppContext) {
 
 fn init_test(cx: &mut TestAppContext) {
     cx.update(|cx| {
-        env_logger::try_init().ok();
+        zlog::init_test();
         let settings = SettingsStore::test(cx);
         cx.set_global(settings);
         theme::init(theme::LoadThemes::JustBase, cx);

crates/diagnostics/src/items.rs 🔗

@@ -6,6 +6,8 @@ use gpui::{
     WeakEntity, Window,
 };
 use language::Diagnostic;
+use project::project_settings::ProjectSettings;
+use settings::Settings;
 use ui::{Button, ButtonLike, Color, Icon, IconName, Label, Tooltip, h_flex, prelude::*};
 use workspace::{StatusItemView, ToolbarItemEvent, Workspace, item::ItemHandle};
 
@@ -22,6 +24,11 @@ pub struct DiagnosticIndicator {
 
 impl Render for DiagnosticIndicator {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let indicator = h_flex().gap_2();
+        if !ProjectSettings::get_global(cx).diagnostics.button {
+            return indicator;
+        }
+
         let diagnostic_indicator = match (self.summary.error_count, self.summary.warning_count) {
             (0, 0) => h_flex().map(|this| {
                 this.child(
@@ -84,8 +91,7 @@ impl Render for DiagnosticIndicator {
             None
         };
 
-        h_flex()
-            .gap_2()
+        indicator
             .child(
                 ButtonLike::new("diagnostic-indicator")
                     .child(diagnostic_indicator)

crates/docs_preprocessor/Cargo.toml 🔗

@@ -15,13 +15,13 @@ settings.workspace = true
 regex.workspace = true
 util.workspace = true
 workspace-hack.workspace = true
+zed.workspace = true
+gpui.workspace = true
+command_palette.workspace = true
 
 [lints]
 workspace = true
 
-[lib]
-path = "src/docs_preprocessor.rs"
-
 [[bin]]
 name = "docs_preprocessor"
 path = "src/main.rs"

crates/docs_preprocessor/src/docs_preprocessor.rs 🔗

@@ -1,94 +0,0 @@
-use anyhow::Result;
-use mdbook::book::{Book, BookItem};
-use mdbook::errors::Error;
-use mdbook::preprocess::{Preprocessor, PreprocessorContext as MdBookContext};
-use settings::KeymapFile;
-use std::sync::Arc;
-use util::asset_str;
-
-mod templates;
-
-use templates::{ActionTemplate, KeybindingTemplate, Template};
-
-pub struct PreprocessorContext {
-    macos_keymap: Arc<KeymapFile>,
-    linux_keymap: Arc<KeymapFile>,
-}
-
-impl PreprocessorContext {
-    pub fn new() -> Result<Self> {
-        let macos_keymap = Arc::new(load_keymap("keymaps/default-macos.json")?);
-        let linux_keymap = Arc::new(load_keymap("keymaps/default-linux.json")?);
-        Ok(Self {
-            macos_keymap,
-            linux_keymap,
-        })
-    }
-
-    pub fn find_binding(&self, os: &str, action: &str) -> Option<String> {
-        let keymap = match os {
-            "macos" => &self.macos_keymap,
-            "linux" => &self.linux_keymap,
-            _ => return None,
-        };
-
-        // Find the binding in reverse order, as the last binding takes precedence.
-        keymap.sections().rev().find_map(|section| {
-            section.bindings().rev().find_map(|(keystroke, a)| {
-                if a.to_string() == action {
-                    Some(keystroke.to_string())
-                } else {
-                    None
-                }
-            })
-        })
-    }
-}
-
-fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
-    let content = asset_str::<settings::SettingsAssets>(asset_path);
-    KeymapFile::parse(content.as_ref())
-}
-
-pub struct ZedDocsPreprocessor {
-    context: PreprocessorContext,
-    templates: Vec<Box<dyn Template>>,
-}
-
-impl ZedDocsPreprocessor {
-    pub fn new() -> Result<Self> {
-        let context = PreprocessorContext::new()?;
-        let templates: Vec<Box<dyn Template>> = vec![
-            Box::new(KeybindingTemplate::new()),
-            Box::new(ActionTemplate::new()),
-        ];
-        Ok(Self { context, templates })
-    }
-
-    fn process_content(&self, content: &str) -> String {
-        let mut processed = content.to_string();
-        for template in &self.templates {
-            processed = template.process(&self.context, &processed);
-        }
-        processed
-    }
-}
-
-impl Preprocessor for ZedDocsPreprocessor {
-    fn name(&self) -> &str {
-        "zed-docs-preprocessor"
-    }
-
-    fn run(&self, _ctx: &MdBookContext, mut book: Book) -> Result<Book, Error> {
-        book.for_each_mut(|item| {
-            if let BookItem::Chapter(chapter) = item {
-                chapter.content = self.process_content(&chapter.content);
-            }
-        });
-        Ok(book)
-    }
-
-    fn supports_renderer(&self, renderer: &str) -> bool {
-        renderer != "not-supported"
-    }
-}

crates/docs_preprocessor/src/main.rs 🔗

@@ -1,9 +1,24 @@
-use anyhow::{Context as _, Result};
+use anyhow::Result;
 use clap::{Arg, ArgMatches, Command};
-use docs_preprocessor::ZedDocsPreprocessor;
-use mdbook::preprocess::{CmdPreprocessor, Preprocessor};
+use mdbook::BookItem;
+use mdbook::book::{Book, Chapter};
+use mdbook::preprocess::CmdPreprocessor;
+use regex::Regex;
+use settings::KeymapFile;
+use std::collections::HashSet;
 use std::io::{self, Read};
 use std::process;
+use std::sync::LazyLock;
+
+static KEYMAP_MACOS: LazyLock<KeymapFile> = LazyLock::new(|| {
+    load_keymap("keymaps/default-macos.json").expect("Failed to load MacOS keymap")
+});
+
+static KEYMAP_LINUX: LazyLock<KeymapFile> = LazyLock::new(|| {
+    load_keymap("keymaps/default-linux.json").expect("Failed to load Linux keymap")
+});
+
+static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(dump_all_gpui_actions);
 
 pub fn make_app() -> Command {
     Command::new("zed-docs-preprocessor")
@@ -17,42 +32,226 @@ pub fn make_app() -> Command {
 
 fn main() -> Result<()> {
     let matches = make_app().get_matches();
-
-    let preprocessor =
-        ZedDocsPreprocessor::new().context("Failed to create ZedDocsPreprocessor")?;
+    // 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();
 
     if let Some(sub_args) = matches.subcommand_matches("supports") {
-        handle_supports(&preprocessor, sub_args);
+        handle_supports(sub_args);
     } else {
-        handle_preprocessing(&preprocessor)?;
+        handle_preprocessing()?;
     }
 
     Ok(())
 }
 
-fn handle_preprocessing(pre: &dyn Preprocessor) -> Result<()> {
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+enum Error {
+    ActionNotFound { action_name: String },
+    DeprecatedActionUsed { used: String, should_be: String },
+}
+
+impl Error {
+    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 {
+                    return Error::DeprecatedActionUsed {
+                        used: action_name.clone(),
+                        should_be: action.name.to_string(),
+                    };
+                }
+            }
+        }
+        Error::ActionNotFound {
+            action_name: action_name.to_string(),
+        }
+    }
+}
+
+impl std::fmt::Display for Error {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Error::ActionNotFound { action_name } => write!(f, "Action not found: {}", action_name),
+            Error::DeprecatedActionUsed { used, should_be } => write!(
+                f,
+                "Deprecated action used: {} should be {}",
+                used, should_be
+            ),
+        }
+    }
+}
+
+fn handle_preprocessing() -> Result<()> {
     let mut stdin = io::stdin();
     let mut input = String::new();
     stdin.read_to_string(&mut input)?;
 
-    let (ctx, book) = CmdPreprocessor::parse_input(input.as_bytes())?;
+    let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?;
+
+    let mut errors = HashSet::<Error>::new();
 
-    let processed_book = pre.run(&ctx, book)?;
+    template_and_validate_keybindings(&mut book, &mut errors);
+    template_and_validate_actions(&mut book, &mut errors);
 
-    serde_json::to_writer(io::stdout(), &processed_book)?;
+    if !errors.is_empty() {
+        const ANSI_RED: &'static str = "\x1b[31m";
+        const ANSI_RESET: &'static str = "\x1b[0m";
+        for error in &errors {
+            eprintln!("{ANSI_RED}ERROR{ANSI_RESET}: {}", error);
+        }
+        return Err(anyhow::anyhow!("Found {} errors in docs", errors.len()));
+    }
+
+    serde_json::to_writer(io::stdout(), &book)?;
 
     Ok(())
 }
 
-fn handle_supports(pre: &dyn Preprocessor, sub_args: &ArgMatches) -> ! {
+fn handle_supports(sub_args: &ArgMatches) -> ! {
     let renderer = sub_args
         .get_one::<String>("renderer")
         .expect("Required argument");
-    let supported = pre.supports_renderer(renderer);
-
+    let supported = renderer != "not-supported";
     if supported {
         process::exit(0);
     } else {
         process::exit(1);
     }
 }
+
+fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Error>) {
+    let regex = Regex::new(r"\{#kb (.*?)\}").unwrap();
+
+    for_each_chapter_mut(book, |chapter| {
+        chapter.content = regex
+            .replace_all(&chapter.content, |caps: &regex::Captures| {
+                let action = caps[1].trim();
+                if find_action_by_name(action).is_none() {
+                    errors.insert(Error::new_for_not_found_action(action.to_string()));
+                    return String::new();
+                }
+                let macos_binding = find_binding("macos", action).unwrap_or_default();
+                let linux_binding = find_binding("linux", action).unwrap_or_default();
+
+                if macos_binding.is_empty() && linux_binding.is_empty() {
+                    return "<div>No default binding</div>".to_string();
+                }
+
+                format!("<kbd class=\"keybinding\">{macos_binding}|{linux_binding}</kbd>")
+            })
+            .into_owned()
+    });
+}
+
+fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Error>) {
+    let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
+
+    for_each_chapter_mut(book, |chapter| {
+        chapter.content = regex
+            .replace_all(&chapter.content, |caps: &regex::Captures| {
+                let name = caps[1].trim();
+                let Some(action) = find_action_by_name(name) else {
+                    errors.insert(Error::new_for_not_found_action(name.to_string()));
+                    return String::new();
+                };
+                format!("<code class=\"hljs\">{}</code>", &action.human_name)
+            })
+            .into_owned()
+    });
+}
+
+fn find_action_by_name(name: &str) -> Option<&ActionDef> {
+    ALL_ACTIONS
+        .binary_search_by(|action| action.name.cmp(name))
+        .ok()
+        .map(|index| &ALL_ACTIONS[index])
+}
+
+fn find_binding(os: &str, action: &str) -> Option<String> {
+    let keymap = match os {
+        "macos" => &KEYMAP_MACOS,
+        "linux" | "freebsd" => &KEYMAP_LINUX,
+        _ => unreachable!("Not a valid OS: {}", os),
+    };
+
+    // Find the binding in reverse order, as the last binding takes precedence.
+    keymap.sections().rev().find_map(|section| {
+        section.bindings().rev().find_map(|(keystroke, a)| {
+            if name_for_action(a.to_string()) == action {
+                Some(keystroke.to_string())
+            } else {
+                None
+            }
+        })
+    })
+}
+
+/// Removes any configurable options from the stringified action if existing,
+/// ensuring that only the actual action name is returned. If the action consists
+/// only of a string and nothing else, the string is returned as-is.
+///
+/// Example:
+///
+/// This will return the action name unmodified.
+///
+/// ```
+/// let action_as_str = "assistant::Assist";
+/// let action_name = name_for_action(action_as_str);
+/// assert_eq!(action_name, "assistant::Assist");
+/// ```
+///
+/// This will return the action name with any trailing options removed.
+///
+///
+/// ```
+/// let action_as_str = "\"editor::ToggleComments\", {\"advance_downwards\":false}";
+/// let action_name = name_for_action(action_as_str);
+/// assert_eq!(action_name, "editor::ToggleComments");
+/// ```
+fn name_for_action(action_as_str: String) -> String {
+    action_as_str
+        .split(",")
+        .next()
+        .map(|name| name.trim_matches('"').to_string())
+        .unwrap_or(action_as_str)
+}
+
+fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
+    let content = util::asset_str::<settings::SettingsAssets>(asset_path);
+    KeymapFile::parse(content.as_ref())
+}
+
+fn for_each_chapter_mut<F>(book: &mut Book, mut func: F)
+where
+    F: FnMut(&mut Chapter),
+{
+    book.for_each_mut(|item| {
+        let BookItem::Chapter(chapter) = item else {
+            return;
+        };
+        func(chapter);
+    });
+}
+
+#[derive(Debug, serde::Serialize)]
+struct ActionDef {
+    name: &'static str,
+    human_name: String,
+    deprecated_aliases: &'static [&'static str],
+}
+
+fn dump_all_gpui_actions() -> Vec<ActionDef> {
+    let mut actions = gpui::generate_list_of_all_registered_actions()
+        .into_iter()
+        .map(|action| ActionDef {
+            name: action.name,
+            human_name: command_palette::humanize_action_name(action.name),
+            deprecated_aliases: action.deprecated_aliases,
+        })
+        .collect::<Vec<ActionDef>>();
+
+    actions.sort_by_key(|a| a.name);
+
+    return actions;
+}

crates/docs_preprocessor/src/templates.rs 🔗

@@ -1,25 +0,0 @@
-use crate::PreprocessorContext;
-use regex::Regex;
-use std::collections::HashMap;
-
-mod action;
-mod keybinding;
-
-pub use action::*;
-pub use keybinding::*;
-
-pub trait Template {
-    fn key(&self) -> &'static str;
-    fn regex(&self) -> Regex;
-    fn parse_args(&self, args: &str) -> HashMap<String, String>;
-    fn render(&self, context: &PreprocessorContext, args: &HashMap<String, String>) -> String;
-
-    fn process(&self, context: &PreprocessorContext, content: &str) -> String {
-        self.regex()
-            .replace_all(content, |caps: &regex::Captures| {
-                let args = self.parse_args(&caps[1]);
-                self.render(context, &args)
-            })
-            .into_owned()
-    }
-}

crates/docs_preprocessor/src/templates/action.rs 🔗

@@ -1,50 +0,0 @@
-use crate::PreprocessorContext;
-use regex::Regex;
-use std::collections::HashMap;
-
-use super::Template;
-
-pub struct ActionTemplate;
-
-impl ActionTemplate {
-    pub fn new() -> Self {
-        ActionTemplate
-    }
-}
-
-impl Template for ActionTemplate {
-    fn key(&self) -> &'static str {
-        "action"
-    }
-
-    fn regex(&self) -> Regex {
-        Regex::new(&format!(r"\{{#{}(.*?)\}}", self.key())).unwrap()
-    }
-
-    fn parse_args(&self, args: &str) -> HashMap<String, String> {
-        let mut map = HashMap::new();
-        map.insert("name".to_string(), args.trim().to_string());
-        map
-    }
-
-    fn render(&self, _context: &PreprocessorContext, args: &HashMap<String, String>) -> String {
-        let name = args.get("name").map(String::as_str).unwrap_or_default();
-
-        let formatted_name = name
-            .chars()
-            .enumerate()
-            .map(|(i, c)| {
-                if i > 0 && c.is_uppercase() {
-                    format!(" {}", c.to_lowercase())
-                } else {
-                    c.to_string()
-                }
-            })
-            .collect::<String>()
-            .trim()
-            .to_string()
-            .replace("::", ":");
-
-        format!("<code class=\"hljs\">{}</code>", formatted_name)
-    }
-}

crates/docs_preprocessor/src/templates/keybinding.rs 🔗

@@ -1,41 +0,0 @@
-use crate::PreprocessorContext;
-use regex::Regex;
-use std::collections::HashMap;
-
-use super::Template;
-
-pub struct KeybindingTemplate;
-
-impl KeybindingTemplate {
-    pub fn new() -> Self {
-        KeybindingTemplate
-    }
-}
-
-impl Template for KeybindingTemplate {
-    fn key(&self) -> &'static str {
-        "kb"
-    }
-
-    fn regex(&self) -> Regex {
-        Regex::new(&format!(r"\{{#{}(.*?)\}}", self.key())).unwrap()
-    }
-
-    fn parse_args(&self, args: &str) -> HashMap<String, String> {
-        let mut map = HashMap::new();
-        map.insert("action".to_string(), args.trim().to_string());
-        map
-    }
-
-    fn render(&self, context: &PreprocessorContext, args: &HashMap<String, String>) -> String {
-        let action = args.get("action").map(String::as_str).unwrap_or("");
-        let macos_binding = context.find_binding("macos", action).unwrap_or_default();
-        let linux_binding = context.find_binding("linux", action).unwrap_or_default();
-
-        if macos_binding.is_empty() && linux_binding.is_empty() {
-            return "<div>No default binding</div>".to_string();
-        }
-
-        format!("<kbd class=\"keybinding\">{macos_binding}|{linux_binding}</kbd>")
-    }
-}

crates/editor/Cargo.toml 🔗

@@ -35,12 +35,11 @@ assets.workspace = true
 client.workspace = true
 clock.workspace = true
 collections.workspace = true
-command_palette_hooks.workspace = true
 convert_case.workspace = true
+dap.workspace = true
 db.workspace = true
 buffer_diff.workspace = true
 emojis.workspace = true
-feature_flags.workspace = true
 file_icons.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
@@ -79,6 +78,7 @@ theme.workspace = true
 tree-sitter-html = { workspace = true, optional = true }
 tree-sitter-rust = { workspace = true, optional = true }
 tree-sitter-typescript = { workspace = true, optional = true }
+tree-sitter-python = { workspace = true, optional = true }
 unicode-segmentation.workspace = true
 unicode-script.workspace = true
 unindent = { workspace = true, optional = true }
@@ -92,11 +92,11 @@ workspace-hack.workspace = true
 
 [dev-dependencies]
 ctor.workspace = true
-env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
 languages = {workspace = true, features = ["test-support"] }
 lsp = { workspace = true, features = ["test-support"] }
+markdown = { workspace = true, features = ["test-support"] }
 multi_buffer = { workspace = true, features = ["test-support"] }
 project = { workspace = true, features = ["test-support"] }
 release_channel.workspace = true
@@ -112,3 +112,4 @@ unindent.workspace = true
 util = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
 http_client = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/editor/src/actions.rs 🔗

@@ -1,24 +1,27 @@
 //! This module contains all actions supported by [`Editor`].
 use super::*;
-use gpui::{action_as, action_with_deprecated_aliases, actions};
+use gpui::{Action, actions};
 use schemars::JsonSchema;
 use util::serde::default_true;
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct SelectNext {
     #[serde(default)]
     pub replace_newest: bool,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct SelectPrevious {
     #[serde(default)]
     pub replace_newest: bool,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct MoveToBeginningOfLine {
     #[serde(default = "default_true")]
@@ -27,7 +30,8 @@ pub struct MoveToBeginningOfLine {
     pub stop_at_indent: bool,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct SelectToBeginningOfLine {
     #[serde(default)]
@@ -36,76 +40,93 @@ pub struct SelectToBeginningOfLine {
     pub stop_at_indent: bool,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct DeleteToBeginningOfLine {
     #[serde(default)]
     pub(super) stop_at_indent: bool,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct MovePageUp {
     #[serde(default)]
     pub(super) center_cursor: bool,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct MovePageDown {
     #[serde(default)]
     pub(super) center_cursor: bool,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct MoveToEndOfLine {
     #[serde(default = "default_true")]
     pub stop_at_soft_wraps: bool,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct SelectToEndOfLine {
     #[serde(default)]
     pub(super) stop_at_soft_wraps: bool,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct ToggleCodeActions {
-    // Display row from which the action was deployed.
+    // Source from which the action was deployed.
     #[serde(default)]
     #[serde(skip)]
-    pub deployed_from_indicator: Option<DisplayRow>,
+    pub deployed_from: Option<CodeActionSource>,
     // Run first available task if there is only one.
     #[serde(default)]
     #[serde(skip)]
     pub quick_launch: bool,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Debug)]
+pub enum CodeActionSource {
+    Indicator(DisplayRow),
+    RunMenu(DisplayRow),
+    QuickActionBar,
+}
+
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct ConfirmCompletion {
     #[serde(default)]
     pub item_ix: Option<usize>,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct ComposeCompletion {
     #[serde(default)]
     pub item_ix: Option<usize>,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct ConfirmCodeAction {
     #[serde(default)]
     pub item_ix: Option<usize>,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct ToggleComments {
     #[serde(default)]
@@ -114,83 +135,96 @@ pub struct ToggleComments {
     pub ignore_indent: bool,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct MoveUpByLines {
     #[serde(default)]
     pub(super) lines: u32,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct MoveDownByLines {
     #[serde(default)]
     pub(super) lines: u32,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct SelectUpByLines {
     #[serde(default)]
     pub(super) lines: u32,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct SelectDownByLines {
     #[serde(default)]
     pub(super) lines: u32,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct ExpandExcerpts {
     #[serde(default)]
     pub(super) lines: u32,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct ExpandExcerptsUp {
     #[serde(default)]
     pub(super) lines: u32,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct ExpandExcerptsDown {
     #[serde(default)]
     pub(super) lines: u32,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct ShowCompletions {
     #[serde(default)]
     pub(super) trigger: Option<String>,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 pub struct HandleInput(pub String);
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct DeleteToNextWordEnd {
     #[serde(default)]
     pub ignore_newlines: bool,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct DeleteToPreviousWordStart {
     #[serde(default)]
     pub ignore_newlines: bool,
 }
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 pub struct FoldAtLevel(pub u32);
 
-#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = editor)]
 #[serde(deny_unknown_fields)]
 pub struct SpawnNearestTask {
     #[serde(default)]
@@ -204,36 +238,13 @@ pub enum UuidVersion {
     V7,
 }
 
-impl_actions!(
-    editor,
+actions!(debugger, [RunToCursor, EvaluateSelectedText]);
+
+actions!(
+    go_to_line,
     [
-        ComposeCompletion,
-        ConfirmCodeAction,
-        ConfirmCompletion,
-        DeleteToBeginningOfLine,
-        DeleteToNextWordEnd,
-        DeleteToPreviousWordStart,
-        ExpandExcerpts,
-        ExpandExcerptsDown,
-        ExpandExcerptsUp,
-        HandleInput,
-        MoveDownByLines,
-        MovePageDown,
-        MovePageUp,
-        MoveToBeginningOfLine,
-        MoveToEndOfLine,
-        MoveUpByLines,
-        SelectDownByLines,
-        SelectNext,
-        SelectPrevious,
-        SelectToBeginningOfLine,
-        SelectToEndOfLine,
-        SelectUpByLines,
-        SpawnNearestTask,
-        ShowCompletions,
-        ToggleCodeActions,
-        ToggleComments,
-        FoldAtLevel,
+        #[action(name = "Toggle")]
+        ToggleGoToLine
     ]
 );
 
@@ -259,6 +270,8 @@ actions!(
         ContextMenuLast,
         ContextMenuNext,
         ContextMenuPrevious,
+        ConvertIndentationToSpaces,
+        ConvertIndentationToTabs,
         ConvertToKebabCase,
         ConvertToLowerCamelCase,
         ConvertToLowerCase,
@@ -287,6 +300,8 @@ actions!(
         DuplicateLineDown,
         DuplicateLineUp,
         DuplicateSelection,
+        #[action(deprecated_aliases = ["editor::ExpandAllHunkDiffs"])]
+        ExpandAllDiffHunks,
         ExpandMacroRecursively,
         FindAllReferences,
         FindNextMatch,
@@ -356,6 +371,8 @@ actions!(
         OpenProposedChangesEditor,
         OpenDocs,
         OpenPermalinkToLine,
+        #[action(deprecated_aliases = ["editor::OpenFile"])]
+        OpenSelectedFilename,
         OpenSelectionsInMultibuffer,
         OpenUrl,
         OrganizeImports,
@@ -371,7 +388,6 @@ actions!(
         RestartLanguageServer,
         RevealInFileManager,
         ReverseLines,
-        RevertFile,
         ReloadFile,
         Rewrap,
         RunFlycheck,
@@ -420,8 +436,6 @@ actions!(
         DisableBreakpoint,
         EnableBreakpoint,
         EditLogBreakpoint,
-        DebuggerRunToCursor,
-        DebuggerEvaluateSelectedText,
         ToggleAutoSignatureHelp,
         ToggleGitBlameInline,
         OpenGitBlameCommit,
@@ -436,6 +450,8 @@ actions!(
         SwapSelectionEnds,
         SetMark,
         ToggleRelativeLineNumbers,
+        #[action(deprecated_aliases = ["editor::ToggleHunkDiff"])]
+        ToggleSelectedDiffHunks,
         ToggleSelectionMenu,
         ToggleSoftWrap,
         ToggleTabBar,
@@ -449,9 +465,3 @@ actions!(
         UniqueLinesCaseSensitive,
     ]
 );
-
-action_as!(go_to_line, ToggleGoToLine as Toggle);
-
-action_with_deprecated_aliases!(editor, OpenSelectedFilename, ["editor::OpenFile"]);
-action_with_deprecated_aliases!(editor, ToggleSelectedDiffHunks, ["editor::ToggleHunkDiff"]);
-action_with_deprecated_aliases!(editor, ExpandAllDiffHunks, ["editor::ExpandAllHunkDiffs"]);

crates/editor/src/clangd_ext.rs 🔗

@@ -39,12 +39,12 @@ pub fn switch_source_header(
         else {
             return Ok(());
         };
-        let source_file = buffer.update(cx, |buffer, _| {
+        let source_file = buffer.read_with(cx, |buffer, _| {
             buffer.file().map(|file| file.path()).map(|path| path.to_string_lossy().to_string()).unwrap_or_else(|| "Unknown".to_string())
         })?;
 
         let switch_source_header = if let Some((client, project_id)) = upstream_client {
-            let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id())?;
+            let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id())?;
             let request = proto::LspExtSwitchSourceHeader {
                 project_id,
                 buffer_id: buffer_id.to_proto(),

crates/editor/src/code_completion_tests.rs 🔗

@@ -1,2000 +1,313 @@
-use crate::{
-    code_context_menus::{CompletionsMenu, SortableMatch},
-    editor_settings::SnippetSortOrder,
-};
-use fuzzy::StringMatch;
+use crate::{code_context_menus::CompletionsMenu, editor_settings::SnippetSortOrder};
+use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::TestAppContext;
+use language::CodeLabel;
+use lsp::{CompletionItem, CompletionItemKind, LanguageServerId};
+use project::{Completion, CompletionSource};
+use std::sync::Arc;
+use std::sync::atomic::AtomicBool;
+use text::Anchor;
 
 #[gpui::test]
-fn test_sort_matches_local_variable_over_global_variable(_cx: &mut TestAppContext) {
-    // Case 1: "foo"
-    let query: Option<&str> = Some("foo");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.2727272727272727,
-                positions: vec![],
-                string: "foo_bar_baz".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (2, "foo_bar_baz"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.2727272727272727,
-                positions: vec![],
-                string: "foo_bar_qux".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7ffffffe"),
-            sort_key: (1, "foo_bar_qux"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.22499999999999998,
-                positions: vec![],
-                string: "floorf64".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "floorf64"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.22499999999999998,
-                positions: vec![],
-                string: "floorf32".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "floorf32"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.22499999999999998,
-                positions: vec![],
-                string: "floorf16".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "floorf16"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.2,
-                positions: vec![],
-                string: "floorf128".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "floorf128"),
-        },
+async fn test_sort_kind(cx: &mut TestAppContext) {
+    let completions = vec![
+        CompletionBuilder::function("floorf128", None, "80000000"),
+        CompletionBuilder::constant("foo_bar_baz", None, "80000000"),
+        CompletionBuilder::variable("foo_bar_qux", None, "80000000"),
     ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.string.as_str(),
-        "foo_bar_qux",
-        "Match order not expected"
-    );
-    assert_eq!(
-        matches[1].string_match.string.as_str(),
-        "foo_bar_baz",
-        "Match order not expected"
-    );
-    assert_eq!(
-        matches[2].string_match.string.as_str(),
-        "floorf16",
-        "Match order not expected"
-    );
-    assert_eq!(
-        matches[3].string_match.string.as_str(),
-        "floorf32",
-        "Match order not expected"
-    );
+    let matches =
+        filter_and_sort_matches("foo", &completions, SnippetSortOrder::default(), cx).await;
 
-    // Case 2: "foobar"
-    let query: Option<&str> = Some("foobar");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.4363636363636364,
-                positions: vec![],
-                string: "foo_bar_baz".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (2, "foo_bar_baz"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.4363636363636364,
-                positions: vec![],
-                string: "foo_bar_qux".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7ffffffe"),
-            sort_key: (1, "foo_bar_qux"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
+    // variable takes precedence over constant
+    // constant take precedence over function
     assert_eq!(
-        matches[0].string_match.string.as_str(),
-        "foo_bar_qux",
-        "Match order not expected"
-    );
-    assert_eq!(
-        matches[1].string_match.string.as_str(),
-        "foo_bar_baz",
-        "Match order not expected"
+        matches
+            .iter()
+            .map(|m| m.string.as_str())
+            .collect::<Vec<_>>(),
+        vec!["foo_bar_qux", "foo_bar_baz", "floorf128"]
     );
+
+    // fuzzy score should match for first two items as query is common prefix
+    assert_eq!(matches[0].score, matches[1].score);
 }
 
 #[gpui::test]
-fn test_sort_matches_local_variable_over_global_enum(_cx: &mut TestAppContext) {
-    // Case 1: "ele"
-    let query: Option<&str> = Some("ele");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.2727272727272727,
-                positions: vec![],
-                string: "ElementType".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (2, "ElementType"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.25,
-                positions: vec![],
-                string: "element_type".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7ffffffe"),
-            sort_key: (1, "element_type"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.16363636363636364,
-                positions: vec![],
-                string: "simd_select".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "simd_select"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.16,
-                positions: vec![],
-                string: "while let".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (0, "while let"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.string.as_str(),
-        "element_type",
-        "Match order not expected"
-    );
-    assert_eq!(
-        matches[1].string_match.string.as_str(),
-        "ElementType",
-        "Match order not expected"
-    );
+async fn test_fuzzy_score(cx: &mut TestAppContext) {
+    // first character sensitive over sort_text and sort_kind
+    {
+        let completions = vec![
+            CompletionBuilder::variable("element_type", None, "7ffffffe"),
+            CompletionBuilder::constant("ElementType", None, "7fffffff"),
+        ];
+        let matches =
+            filter_and_sort_matches("Elem", &completions, SnippetSortOrder::default(), cx).await;
+        assert_eq!(
+            matches
+                .iter()
+                .map(|m| m.string.as_str())
+                .collect::<Vec<_>>(),
+            vec!["ElementType", "element_type"]
+        );
+        assert!(matches[0].score > matches[1].score);
+    }
 
-    // Case 2: "eleme"
-    let query: Option<&str> = Some("eleme");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.4545454545454546,
-                positions: vec![],
-                string: "ElementType".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (2, "ElementType"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.41666666666666663,
-                positions: vec![],
-                string: "element_type".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7ffffffe"),
-            sort_key: (1, "element_type"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.04714285714285713,
-                positions: vec![],
-                string: "REPLACEMENT_CHARACTER".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "REPLACEMENT_CHARACTER"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.string.as_str(),
-        "element_type",
-        "Match order not expected"
-    );
-    assert_eq!(
-        matches[1].string_match.string.as_str(),
-        "ElementType",
-        "Match order not expected"
-    );
+    // fuzzy takes over sort_text and sort_kind
+    {
+        let completions = vec![
+            CompletionBuilder::function("onAbort?", None, "12"),
+            CompletionBuilder::function("onAuxClick?", None, "12"),
+            CompletionBuilder::variable("onPlay?", None, "12"),
+            CompletionBuilder::variable("onLoad?", None, "12"),
+            CompletionBuilder::variable("onDrag?", None, "12"),
+            CompletionBuilder::function("onPause?", None, "10"),
+            CompletionBuilder::function("onPaste?", None, "10"),
+            CompletionBuilder::function("onAnimationEnd?", None, "12"),
+            CompletionBuilder::function("onAbortCapture?", None, "12"),
+            CompletionBuilder::constant("onChange?", None, "12"),
+            CompletionBuilder::constant("onWaiting?", None, "12"),
+            CompletionBuilder::function("onCanPlay?", None, "12"),
+        ];
+        let matches =
+            filter_and_sort_matches("ona", &completions, SnippetSortOrder::default(), cx).await;
+        for i in 0..4 {
+            assert!(matches[i].string.to_lowercase().starts_with("ona"));
+        }
+    }
 
-    // Case 3: "Elem"
-    let query: Option<&str> = Some("Elem");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.36363636363636365,
-                positions: vec![],
-                string: "ElementType".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (2, "ElementType"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.0003333333333333333,
-                positions: vec![],
-                string: "element_type".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7ffffffe"),
-            sort_key: (1, "element_type"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.string.as_str(),
-        "ElementType",
-        "Match order not expected"
-    );
-    assert_eq!(
-        matches[1].string_match.string.as_str(),
-        "element_type",
-        "Match order not expected"
-    );
+    // plain fuzzy prefix match
+    {
+        let completions = vec![
+            CompletionBuilder::function("set_text", None, "7fffffff"),
+            CompletionBuilder::function("set_placeholder_text", None, "7fffffff"),
+            CompletionBuilder::function("set_text_style_refinement", None, "7fffffff"),
+            CompletionBuilder::function("set_context_menu_options", None, "7fffffff"),
+            CompletionBuilder::function("select_to_next_word_end", None, "7fffffff"),
+            CompletionBuilder::function("select_to_next_subword_end", None, "7fffffff"),
+            CompletionBuilder::function("set_custom_context_menu", None, "7fffffff"),
+            CompletionBuilder::function("select_to_end_of_excerpt", None, "7fffffff"),
+            CompletionBuilder::function("select_to_start_of_excerpt", None, "7fffffff"),
+            CompletionBuilder::function("select_to_start_of_next_excerpt", None, "7fffffff"),
+            CompletionBuilder::function("select_to_end_of_previous_excerpt", None, "7fffffff"),
+        ];
+        let matches =
+            filter_and_sort_matches("set_text", &completions, SnippetSortOrder::Top, cx).await;
+        assert_eq!(matches[0].string, "set_text");
+        assert_eq!(matches[1].string, "set_text_style_refinement");
+        assert_eq!(matches[2].string, "set_context_menu_options");
+    }
+
+    // fuzzy filter text over label, sort_text and sort_kind
+    {
+        // Case 1: "awa"
+        let completions = vec![
+            CompletionBuilder::method("await", Some("await"), "7fffffff"),
+            CompletionBuilder::method("await.ne", Some("ne"), "80000010"),
+            CompletionBuilder::method("await.eq", Some("eq"), "80000010"),
+            CompletionBuilder::method("await.or", Some("or"), "7ffffff8"),
+            CompletionBuilder::method("await.zip", Some("zip"), "80000006"),
+            CompletionBuilder::method("await.xor", Some("xor"), "7ffffff8"),
+            CompletionBuilder::method("await.and", Some("and"), "80000006"),
+            CompletionBuilder::method("await.map", Some("map"), "80000006"),
+        ];
+
+        test_for_each_prefix("await", &completions, cx, |matches| {
+            // for each prefix, first item should always be one with lower sort_text
+            assert_eq!(matches[0].string, "await");
+        })
+        .await;
+    }
 }
 
 #[gpui::test]
-fn test_sort_matches_for_unreachable(_cx: &mut TestAppContext) {
-    // Case 1: "unre"
-    let query: Option<&str> = Some("unre");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.36363636363636365,
-                positions: vec![],
-                string: "unreachable".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "unreachable"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.26666666666666666,
-                positions: vec![],
-                string: "unreachable!(…)".to_string(),
-            },
-            is_snippet: true,
-            sort_text: Some("7fffffff"),
-            sort_key: (2, "unreachable!(…)"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.24615384615384617,
-                positions: vec![],
-                string: "unchecked_rem".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "unchecked_rem"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.19047619047619047,
-                positions: vec![],
-                string: "unreachable_unchecked".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "unreachable_unchecked"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.string.as_str(),
-        "unreachable!(…)",
-        "Match order not expected"
-    );
+async fn test_sort_text(cx: &mut TestAppContext) {
+    // sort text takes precedance over sort_kind, when fuzzy is same
+    {
+        let completions = vec![
+            CompletionBuilder::variable("unreachable", None, "80000000"),
+            CompletionBuilder::function("unreachable!(…)", None, "7fffffff"),
+            CompletionBuilder::function("unchecked_rem", None, "80000010"),
+            CompletionBuilder::function("unreachable_unchecked", None, "80000020"),
+        ];
 
-    // Case 2: "unrea"
-    let query: Option<&str> = Some("unrea");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.4545454545454546,
-                positions: vec![],
-                string: "unreachable".to_string(),
-            },
-            is_snippet: true,
-            sort_text: Some("80000000"),
-            sort_key: (3, "unreachable"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.3333333333333333,
-                positions: vec![],
-                string: "unreachable!(…)".to_string(),
-            },
-            is_snippet: true,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "unreachable!(…)"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.23809523809523808,
-                positions: vec![],
-                string: "unreachable_unchecked".to_string(),
-            },
-            is_snippet: true,
-            sort_text: Some("80000000"),
-            sort_key: (3, "unreachable_unchecked"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.string.as_str(),
-        "unreachable!(…)",
-        "Match order not expected"
-    );
+        test_for_each_prefix("unreachabl", &completions, cx, |matches| {
+            // for each prefix, first item should always be one with lower sort_text
+            assert_eq!(matches[0].string, "unreachable!(…)");
+            assert_eq!(matches[1].string, "unreachable");
 
-    // Case 3: "unreach"
-    let query: Option<&str> = Some("unreach");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.6363636363636364,
-                positions: vec![],
-                string: "unreachable".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "unreachable"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.4666666666666667,
-                positions: vec![],
-                string: "unreachable!(…)".to_string(),
-            },
-            is_snippet: true,
-            sort_text: Some("7fffffff"),
-            sort_key: (2, "unreachable!(…)"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.3333333333333333,
-                positions: vec![],
-                string: "unreachable_unchecked".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "unreachable_unchecked"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.string.as_str(),
-        "unreachable!(…)",
-        "Match order not expected"
-    );
+            // fuzzy score should match for first two items as query is common prefix
+            assert_eq!(matches[0].score, matches[1].score);
+        })
+        .await;
 
-    // Case 4: "unreachabl"
-    let query: Option<&str> = Some("unreachable");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.9090909090909092,
-                positions: vec![],
-                string: "unreachable".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (3, "unreachable"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.6666666666666666,
-                positions: vec![],
-                string: "unreachable!(…)".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "unreachable!(…)"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.47619047619047616,
-                positions: vec![],
-                string: "unreachable_unchecked".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (3, "unreachable_unchecked"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.string.as_str(),
-        "unreachable!(…)",
-        "Match order not expected"
-    );
+        let matches =
+            filter_and_sort_matches("unreachable", &completions, SnippetSortOrder::Top, cx).await;
+        // exact match comes first
+        assert_eq!(matches[0].string, "unreachable");
+        assert_eq!(matches[1].string, "unreachable!(…)");
 
-    // Case 5: "unreachable"
-    let query: Option<&str> = Some("unreachable");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 1.0,
-                positions: vec![],
-                string: "unreachable".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "unreachable"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.7333333333333333,
-                positions: vec![],
-                string: "unreachable!(…)".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (2, "unreachable!(…)"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.5238095238095237,
-                positions: vec![],
-                string: "unreachable_unchecked".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "unreachable_unchecked"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.string.as_str(),
-        "unreachable",
-        "Perfect fuzzy match should be preferred over others"
-    );
+        // fuzzy score should match for first two items as query is common prefix
+        assert_eq!(matches[0].score, matches[1].score);
+    }
 }
 
 #[gpui::test]
-fn test_sort_matches_variable_and_constants_over_function(_cx: &mut TestAppContext) {
-    // Case 1: "var" as variable
-    let query: Option<&str> = Some("var");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 1.0,
-                positions: vec![],
-                string: "var".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "var"), // function
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 1,
-                score: 1.0,
-                positions: vec![],
-                string: "var".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (1, "var"), // variable
-        },
+async fn test_sort_snippet(cx: &mut TestAppContext) {
+    let completions = vec![
+        CompletionBuilder::constant("println", None, "7fffffff"),
+        CompletionBuilder::snippet("println!(…)", None, "80000000"),
     ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.candidate_id, 1,
-        "Match order not expected"
-    );
-    assert_eq!(
-        matches[1].string_match.candidate_id, 0,
-        "Match order not expected"
-    );
+    let matches = filter_and_sort_matches("prin", &completions, SnippetSortOrder::Top, cx).await;
 
-    // Case 2:  "var" as constant
-    let query: Option<&str> = Some("var");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 1.0,
-                positions: vec![],
-                string: "var".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "var"), // function
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 1,
-                score: 1.0,
-                positions: vec![],
-                string: "var".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (2, "var"), // constant
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.candidate_id, 1,
-        "Match order not expected"
-    );
-    assert_eq!(
-        matches[1].string_match.candidate_id, 0,
-        "Match order not expected"
-    );
+    // snippet take precedence over sort_text and sort_kind
+    assert_eq!(matches[0].string, "println!(…)");
 }
 
 #[gpui::test]
-fn test_sort_matches_jsx_event_handler(_cx: &mut TestAppContext) {
-    // Case 1: "on"
-    let query: Option<&str> = Some("on");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.3333333333333333,
-                positions: vec![],
-                string: "onCut?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onCut?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.2857142857142857,
-                positions: vec![],
-                string: "onPlay?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onPlay?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.25,
-                positions: vec![],
-                string: "color?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "color?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.25,
-                positions: vec![],
-                string: "defaultValue?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "defaultValue?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.25,
-                positions: vec![],
-                string: "style?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "style?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.20,
-                positions: vec![],
-                string: "className?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "className?"),
-        },
+async fn test_sort_exact(cx: &mut TestAppContext) {
+    // sort_text takes over if no exact match
+    let completions = vec![
+        CompletionBuilder::function("into", None, "80000004"),
+        CompletionBuilder::function("try_into", None, "80000004"),
+        CompletionBuilder::snippet("println", None, "80000004"),
+        CompletionBuilder::function("clone_into", None, "80000004"),
+        CompletionBuilder::function("into_searcher", None, "80000000"),
+        CompletionBuilder::snippet("eprintln", None, "80000004"),
     ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.string, "onCut?",
-        "Match order not expected"
-    );
-    assert_eq!(
-        matches[1].string_match.string, "onPlay?",
-        "Match order not expected"
-    );
+    let matches =
+        filter_and_sort_matches("int", &completions, SnippetSortOrder::default(), cx).await;
+    assert_eq!(matches[0].string, "into_searcher");
 
-    // Case 2: "ona"
-    let query: Option<&str> = Some("ona");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.375,
-                positions: vec![],
-                string: "onAbort?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onAbort?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.2727272727272727,
-                positions: vec![],
-                string: "onAuxClick?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onAuxClick?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.23571428571428565,
-                positions: vec![],
-                string: "onPlay?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onPlay?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.23571428571428565,
-                positions: vec![],
-                string: "onLoad?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onLoad?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.23571428571428565,
-                positions: vec![],
-                string: "onDrag?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onDrag?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.22499999999999998,
-                positions: vec![],
-                string: "onPause?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onPause?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.22499999999999998,
-                positions: vec![],
-                string: "onPaste?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onPaste?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.2,
-                positions: vec![],
-                string: "onAnimationEnd?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onAnimationEnd?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.2,
-                positions: vec![],
-                string: "onAbortCapture?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onAbortCapture?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.1833333333333333,
-                positions: vec![],
-                string: "onChange?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onChange?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.18,
-                positions: vec![],
-                string: "onWaiting?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onWaiting?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.18,
-                positions: vec![],
-                string: "onCanPlay?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onCanPlay?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.1764705882352941,
-                positions: vec![],
-                string: "onAnimationStart?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onAnimationStart?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.16666666666666666,
-                positions: vec![],
-                string: "onAuxClickCapture?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onAuxClickCapture?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.16499999999999998,
-                positions: vec![],
-                string: "onStalled?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onStalled?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.16499999999999998,
-                positions: vec![],
-                string: "onPlaying?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onPlaying?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.16499999999999998,
-                positions: vec![],
-                string: "onDragEnd?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onDragEnd?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.15000000000000002,
-                positions: vec![],
-                string: "onInvalid?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onInvalid?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.15,
-                positions: vec![],
-                string: "onDragOver?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onDragOver?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.15,
-                positions: vec![],
-                string: "onDragExit?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onDragExit?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.14285714285714285,
-                positions: vec![],
-                string: "onAnimationIteration?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onAnimationIteration?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.13846153846153847,
-                positions: vec![],
-                string: "onRateChange?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onRateChange?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.13749999999999996,
-                positions: vec![],
-                string: "onLoadStart?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onLoadStart?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.13749999999999996,
-                positions: vec![],
-                string: "onDragStart?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onDragStart?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.13749999999999996,
-                positions: vec![],
-                string: "onDragLeave?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onDragLeave?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.13749999999999996,
-                positions: vec![],
-                string: "onDragEnter?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onDragEnter?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.13636363636363635,
-                positions: vec![],
-                string: "onAnimationEndCapture?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onAnimationEndCapture?"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.12692307692307692,
-                positions: vec![],
-                string: "onLoadedData?".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("12"),
-            sort_key: (3, "onLoadedData?"),
-        },
+    // exact match takes over sort_text
+    let completions = vec![
+        CompletionBuilder::function("into", None, "80000004"),
+        CompletionBuilder::function("try_into", None, "80000004"),
+        CompletionBuilder::function("clone_into", None, "80000004"),
+        CompletionBuilder::function("into_searcher", None, "80000000"),
+        CompletionBuilder::function("split_terminator", None, "7fffffff"),
+        CompletionBuilder::function("rsplit_terminator", None, "7fffffff"),
     ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches
-            .iter()
-            .take(12)
-            .map(|m| m.string_match.string.as_str())
-            .collect::<Vec<&str>>(),
-        vec![
-            "onAbort?",
-            "onAuxClick?",
-            "onAbortCapture?",
-            "onAnimationEnd?",
-            "onAnimationStart?",
-            "onAuxClickCapture?",
-            "onAnimationIteration?",
-            "onAnimationEndCapture?",
-            "onDrag?",
-            "onLoad?",
-            "onPlay?",
-            "onPaste?",
-        ]
-    );
+    let matches =
+        filter_and_sort_matches("into", &completions, SnippetSortOrder::default(), cx).await;
+    assert_eq!(matches[0].string, "into");
 }
 
 #[gpui::test]
-fn test_sort_matches_for_snippets(_cx: &mut TestAppContext) {
-    // Case 1: "prin"
-    let query: Option<&str> = Some("prin");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.2,
-                positions: vec![],
-                string: "println".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (2, "println"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.2,
-                positions: vec![],
-                string: "println!(…)".to_string(),
-            },
-            is_snippet: true,
-            sort_text: Some("80000000"),
-            sort_key: (2, "println!(…)"),
-        },
+async fn test_sort_positions(cx: &mut TestAppContext) {
+    // positions take precedence over fuzzy score and sort_text
+    let completions = vec![
+        CompletionBuilder::function("rounded-full", None, "15788"),
+        CompletionBuilder::variable("rounded-t-full", None, "15846"),
+        CompletionBuilder::variable("rounded-b-full", None, "15731"),
+        CompletionBuilder::function("rounded-tr-full", None, "15866"),
     ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::Top);
-    assert_eq!(
-        matches[0].string_match.string.as_str(),
-        "println!(…)",
-        "Match order not expected"
-    );
-}
 
-#[gpui::test]
-fn test_sort_matches_for_exact_match(_cx: &mut TestAppContext) {
-    // Case 1: "set_text"
-    let query: Option<&str> = Some("set_text");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 1.0,
-                positions: vec![],
-                string: "set_text".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "set_text"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.32000000000000006,
-                positions: vec![],
-                string: "set_placeholder_text".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "set_placeholder_text"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.32,
-                positions: vec![],
-                string: "set_text_style_refinement".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "set_text_style_refinement"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.16666666666666666,
-                positions: vec![],
-                string: "set_context_menu_options".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "set_context_menu_options"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.08695652173913043,
-                positions: vec![],
-                string: "select_to_next_word_end".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "select_to_next_word_end"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.07692307692307693,
-                positions: vec![],
-                string: "select_to_next_subword_end".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "select_to_next_subword_end"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.06956521739130435,
-                positions: vec![],
-                string: "set_custom_context_menu".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "set_custom_context_menu"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.06,
-                positions: vec![],
-                string: "select_to_end_of_excerpt".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "select_to_end_of_excerpt"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.055384615384615386,
-                positions: vec![],
-                string: "select_to_start_of_excerpt".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "select_to_start_of_excerpt"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.0464516129032258,
-                positions: vec![],
-                string: "select_to_start_of_next_excerpt".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "select_to_start_of_next_excerpt"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.04363636363636363,
-                positions: vec![],
-                string: "select_to_end_of_previous_excerpt".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "select_to_end_of_previous_excerpt"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::Top);
-    assert_eq!(
-        matches
-            .iter()
-            .map(|m| m.string_match.string.as_str())
-            .collect::<Vec<&str>>(),
-        vec![
-            "set_text",
-            "set_text_style_refinement",
-            "set_placeholder_text",
-            "set_context_menu_options",
-            "set_custom_context_menu",
-            "select_to_next_word_end",
-            "select_to_next_subword_end",
-            "select_to_end_of_excerpt",
-            "select_to_start_of_excerpt",
-            "select_to_start_of_next_excerpt",
-            "select_to_end_of_previous_excerpt",
-        ]
-    );
-}
+    let matches = filter_and_sort_matches(
+        "rounded-full",
+        &completions,
+        SnippetSortOrder::default(),
+        cx,
+    )
+    .await;
+    assert_eq!(matches[0].string, "rounded-full");
 
-#[gpui::test]
-fn test_sort_matches_for_prefix_matches(_cx: &mut TestAppContext) {
-    // Case 1: "set"
-    let query: Option<&str> = Some("set");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.12631578947368421,
-                positions: vec![],
-                string: "select_to_beginning".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "select_to_beginning"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.15000000000000002,
-                positions: vec![],
-                string: "set_collapse_matches".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "set_collapse_matches"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.21428571428571427,
-                positions: vec![],
-                string: "set_autoindent".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "set_autoindent"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.11538461538461539,
-                positions: vec![],
-                string: "set_all_diagnostics_active".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "set_all_diagnostics_active"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.1142857142857143,
-                positions: vec![],
-                string: "select_to_end_of_line".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "select_to_end_of_line"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.15000000000000002,
-                positions: vec![],
-                string: "select_all".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "select_all"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.13636363636363635,
-                positions: vec![],
-                string: "select_line".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "select_line"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.13636363636363635,
-                positions: vec![],
-                string: "select_left".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "select_left"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.13636363636363635,
-                positions: vec![],
-                string: "select_down".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "select_down"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::Top);
-    assert_eq!(
-        matches
-            .iter()
-            .map(|m| m.string_match.string.as_str())
-            .collect::<Vec<&str>>(),
-        vec![
-            "set_autoindent",
-            "set_collapse_matches",
-            "set_all_diagnostics_active",
-            "select_all",
-            "select_down",
-            "select_left",
-            "select_line",
-            "select_to_beginning",
-            "select_to_end_of_line",
-        ]
-    );
+    let matches =
+        filter_and_sort_matches("roundedfull", &completions, SnippetSortOrder::default(), cx).await;
+    assert_eq!(matches[0].string, "rounded-full");
 }
 
-#[gpui::test]
-fn test_sort_matches_for_await(_cx: &mut TestAppContext) {
-    // Case 1: "awa"
-    let query: Option<&str> = Some("awa");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.6000000000000001,
-                positions: vec![],
-                string: "await".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (0, "await"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 35,
-                score: 0.375,
-                positions: vec![],
-                string: "await.ne".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000010"),
-            sort_key: (3, "await.ne"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 34,
-                score: 0.375,
-                positions: vec![],
-                string: "await.eq".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000010"),
-            sort_key: (3, "await.eq"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 18,
-                score: 0.375,
-                positions: vec![],
-                string: "await.or".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7ffffff8"),
-            sort_key: (3, "await.or"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 21,
-                score: 0.3333333333333333,
-                positions: vec![],
-                string: "await.zip".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000006"),
-            sort_key: (3, "await.zip"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 20,
-                score: 0.3333333333333333,
-                positions: vec![],
-                string: "await.xor".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7ffffff8"),
-            sort_key: (3, "await.xor"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 15,
-                score: 0.3333333333333333,
-                positions: vec![],
-                string: "await.and".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000006"),
-            sort_key: (3, "await.and"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 9,
-                score: 0.3333333333333333,
-                positions: vec![],
-                string: "await.map".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000006"),
-            sort_key: (3, "await.map"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 47,
-                score: 0.30000000000000004,
-                positions: vec![],
-                string: "await.take".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7ffffff8"),
-            sort_key: (3, "await.take"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::Top);
-    assert_eq!(
-        matches
-            .iter()
-            .map(|m| m.string_match.string.as_str())
-            .collect::<Vec<&str>>(),
-        vec![
-            "await",
-            "await.or",
-            "await.xor",
-            "await.take",
-            "await.and",
-            "await.map",
-            "await.zip",
-            "await.eq",
-            "await.ne"
-        ]
-    );
-    // Case 2: "await"
-    let query: Option<&str> = Some("await");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 1.0,
-                positions: vec![],
-                string: "await".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (0, "await"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 35,
-                score: 0.625,
-                positions: vec![],
-                string: "await.ne".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000010"),
-            sort_key: (3, "await.ne"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 34,
-                score: 0.625,
-                positions: vec![],
-                string: "await.eq".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000010"),
-            sort_key: (3, "await.eq"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 18,
-                score: 0.625,
-                positions: vec![],
-                string: "await.or".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7ffffff8"),
-            sort_key: (3, "await.or"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 21,
-                score: 0.5555555555555556,
-                positions: vec![],
-                string: "await.zip".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000006"),
-            sort_key: (3, "await.zip"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 20,
-                score: 0.5555555555555556,
-                positions: vec![],
-                string: "await.xor".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7ffffff8"),
-            sort_key: (3, "await.xor"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 15,
-                score: 0.5555555555555556,
-                positions: vec![],
-                string: "await.and".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000006"),
-            sort_key: (3, "await.and"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 9,
-                score: 0.5555555555555556,
-                positions: vec![],
-                string: "await.map".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000006"),
-            sort_key: (3, "await.map"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 47,
-                score: 0.5,
-                positions: vec![],
-                string: "await.take".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7ffffff8"),
-            sort_key: (3, "await.take"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::Top);
-    assert_eq!(
-        matches
-            .iter()
-            .map(|m| m.string_match.string.as_str())
-            .collect::<Vec<&str>>(),
-        vec![
-            "await",
-            "await.or",
-            "await.xor",
-            "await.take",
-            "await.and",
-            "await.map",
-            "await.zip",
-            "await.eq",
-            "await.ne"
-        ]
-    );
+async fn test_for_each_prefix<F>(
+    target: &str,
+    completions: &Vec<Completion>,
+    cx: &mut TestAppContext,
+    mut test_fn: F,
+) where
+    F: FnMut(Vec<StringMatch>),
+{
+    for i in 1..=target.len() {
+        let prefix = &target[..i];
+        let matches =
+            filter_and_sort_matches(prefix, completions, SnippetSortOrder::default(), cx).await;
+        test_fn(matches);
+    }
 }
 
-#[gpui::test]
-fn test_sort_matches_for_python_init(_cx: &mut TestAppContext) {
-    // Case 1: "__in"
-    let query: Option<&str> = Some("__in");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 211,
-                score: 0.5,
-                positions: vec![],
-                string: "__init__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0003.__init__"),
-            sort_key: (3, "__init__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.5,
-                positions: vec![],
-                string: "__init__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0003"),
-            sort_key: (3, "__init__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 215,
-                score: 0.23529411764705882,
-                positions: vec![],
-                string: "__instancecheck__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0005.__instancecheck__"),
-            sort_key: (3, "__instancecheck__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 213,
-                score: 0.23529411764705882,
-                positions: vec![],
-                string: "__init_subclass__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0004.__init_subclass__"),
-            sort_key: (3, "__init_subclass__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 4,
-                score: 0.23529411764705882,
-                positions: vec![],
-                string: "__instancecheck__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0005"),
-            sort_key: (3, "__instancecheck__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 2,
-                score: 0.23529411764705882,
-                positions: vec![],
-                string: "__init_subclass__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0004"),
-            sort_key: (3, "__init_subclass__"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::Top);
-    assert_eq!(
-        matches
-            .iter()
-            .map(|m| m.string_match.string.as_str())
-            .collect::<Vec<&str>>(),
-        vec![
-            "__init__",
-            "__init__",
-            "__init_subclass__",
-            "__init_subclass__",
-            "__instancecheck__",
-            "__instancecheck__",
-        ]
-    );
-    // Case 2: "__ini"
-    let query: Option<&str> = Some("__ini");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 9,
-                score: 0.625,
-                positions: vec![],
-                string: "__init__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0004.__init__"),
-            sort_key: (3, "__init__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.625,
-                positions: vec![],
-                string: "__init__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0004"),
-            sort_key: (3, "__init__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 10,
-                score: 0.29411764705882354,
-                positions: vec![],
-                string: "__init_subclass__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0003.__init_subclass__"),
-            sort_key: (3, "__init_subclass__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 1,
-                score: 0.29411764705882354,
-                positions: vec![],
-                string: "__init_subclass__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0003"),
-            sort_key: (3, "__init_subclass__"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::Top);
-    assert_eq!(
-        matches
-            .iter()
-            .map(|m| m.string_match.string.as_str())
-            .collect::<Vec<&str>>(),
-        vec![
-            "__init__",
-            "__init__",
-            "__init_subclass__",
-            "__init_subclass__",
-        ]
-    );
-    // Case 3: "__init"
-    let query: Option<&str> = Some("__init");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 7,
-                score: 0.75,
-                positions: vec![],
-                string: "__init__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0000.__init__"),
-            sort_key: (3, "__init__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.75,
-                positions: vec![],
-                string: "__init__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0000"),
-            sort_key: (3, "__init__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 8,
-                score: 0.3529411764705882,
-                positions: vec![],
-                string: "__init_subclass__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0001.__init_subclass__"),
-            sort_key: (3, "__init_subclass__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 1,
-                score: 0.3529411764705882,
-                positions: vec![],
-                string: "__init_subclass__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0001"),
-            sort_key: (3, "__init_subclass__"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::Top);
-    assert_eq!(
-        matches
-            .iter()
-            .map(|m| m.string_match.string.as_str())
-            .collect::<Vec<&str>>(),
-        vec![
-            "__init__",
-            "__init__",
-            "__init_subclass__",
-            "__init_subclass__",
-        ]
-    );
-    // Case 4: "__init_"
-    let query: Option<&str> = Some("__init_");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 4,
-                score: 0.875,
-                positions: vec![],
-                string: "__init__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("11.9999.__init__"),
-            sort_key: (3, "__init__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.875,
-                positions: vec![],
-                string: "__init__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("11.9999"),
-            sort_key: (3, "__init__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 5,
-                score: 0.4117647058823529,
-                positions: vec![],
-                string: "__init_subclass__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0000.__init_subclass__"),
-            sort_key: (3, "__init_subclass__"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 1,
-                score: 0.4117647058823529,
-                positions: vec![],
-                string: "__init_subclass__".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("05.0000"),
-            sort_key: (3, "__init_subclass__"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::Top);
-    assert_eq!(
-        matches
-            .iter()
-            .map(|m| m.string_match.string.as_str())
-            .collect::<Vec<&str>>(),
-        vec![
-            "__init__",
-            "__init__",
-            "__init_subclass__",
-            "__init_subclass__",
-        ]
-    );
+struct CompletionBuilder;
+
+impl CompletionBuilder {
+    fn constant(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion {
+        Self::new(label, filter_text, sort_text, CompletionItemKind::CONSTANT)
+    }
+
+    fn function(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion {
+        Self::new(label, filter_text, sort_text, CompletionItemKind::FUNCTION)
+    }
+
+    fn method(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion {
+        Self::new(label, filter_text, sort_text, CompletionItemKind::METHOD)
+    }
+
+    fn variable(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion {
+        Self::new(label, filter_text, sort_text, CompletionItemKind::VARIABLE)
+    }
+
+    fn snippet(label: &str, filter_text: Option<&str>, sort_text: &str) -> Completion {
+        Self::new(label, filter_text, sort_text, CompletionItemKind::SNIPPET)
+    }
+
+    fn new(
+        label: &str,
+        filter_text: Option<&str>,
+        sort_text: &str,
+        kind: CompletionItemKind,
+    ) -> Completion {
+        Completion {
+            replace_range: Anchor::MIN..Anchor::MAX,
+            new_text: label.to_string(),
+            label: CodeLabel::plain(label.to_string(), filter_text),
+            documentation: None,
+            source: CompletionSource::Lsp {
+                insert_range: None,
+                server_id: LanguageServerId(0),
+                lsp_completion: Box::new(CompletionItem {
+                    label: label.to_string(),
+                    kind: Some(kind),
+                    sort_text: Some(sort_text.to_string()),
+                    filter_text: filter_text.map(|text| text.to_string()),
+                    ..Default::default()
+                }),
+                lsp_defaults: None,
+                resolved: false,
+            },
+            icon_path: None,
+            insert_text_mode: None,
+            confirm: None,
+        }
+    }
 }
 
-#[gpui::test]
-fn test_sort_matches_for_rust_into(_cx: &mut TestAppContext) {
-    // Case 1: "int"
-    let query: Option<&str> = Some("int");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 67,
-                score: 0.75,
-                positions: vec![],
-                string: "into".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000004"),
-            sort_key: (3, "into"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 68,
-                score: 0.30000000000000004,
-                positions: vec![],
-                string: "try_into".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000004"),
-            sort_key: (3, "try_into"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 108,
-                score: 0.2571428571428571,
-                positions: vec![],
-                string: "println".to_string(),
-            },
-            is_snippet: true,
-            sort_text: Some("80000004"),
-            sort_key: (3, "println"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 73,
-                score: 0.24,
-                positions: vec![],
-                string: "clone_into".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000004"),
-            sort_key: (3, "clone_into"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 1,
-                score: 0.23076923076923078,
-                positions: vec![],
-                string: "into_searcher".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (3, "into_searcher"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 109,
-                score: 0.22499999999999998,
-                positions: vec![],
-                string: "eprintln".to_string(),
-            },
-            is_snippet: true,
-            sort_text: Some("80000004"),
-            sort_key: (3, "eprintln"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.string.as_str(),
-        "into",
-        "Match order not expected"
-    );
-    // Case 2: "into"
-    let query: Option<&str> = Some("into");
-    let mut matches: Vec<SortableMatch<'_>> = vec![
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 65,
-                score: 1.0,
-                positions: vec![],
-                string: "into".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000004"),
-            sort_key: (3, "into"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 66,
-                score: 0.4,
-                positions: vec![],
-                string: "try_into".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000004"),
-            sort_key: (3, "try_into"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 71,
-                score: 0.32,
-                positions: vec![],
-                string: "clone_into".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000004"),
-            sort_key: (3, "clone_into"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 0,
-                score: 0.3076923076923077,
-                positions: vec![],
-                string: "into_searcher".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("80000000"),
-            sort_key: (3, "into_searcher"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 27,
-                score: 0.09,
-                positions: vec![],
-                string: "split_terminator".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "split_terminator"),
-        },
-        SortableMatch {
-            string_match: StringMatch {
-                candidate_id: 28,
-                score: 0.08470588235294117,
-                positions: vec![],
-                string: "rsplit_terminator".to_string(),
-            },
-            is_snippet: false,
-            sort_text: Some("7fffffff"),
-            sort_key: (3, "rsplit_terminator"),
-        },
-    ];
-    CompletionsMenu::sort_matches(&mut matches, query, SnippetSortOrder::default());
-    assert_eq!(
-        matches[0].string_match.string.as_str(),
-        "into",
-        "Match order not expected"
-    );
+async fn filter_and_sort_matches(
+    query: &str,
+    completions: &Vec<Completion>,
+    snippet_sort_order: SnippetSortOrder,
+    cx: &mut TestAppContext,
+) -> Vec<StringMatch> {
+    let candidates: Arc<[StringMatchCandidate]> = completions
+        .iter()
+        .enumerate()
+        .map(|(id, completion)| StringMatchCandidate::new(id, &completion.label.filter_text()))
+        .collect();
+    let cancel_flag = Arc::new(AtomicBool::new(false));
+    let background_executor = cx.executor();
+    let matches = fuzzy::match_strings(
+        &candidates,
+        query,
+        query.chars().any(|c| c.is_uppercase()),
+        false,
+        100,
+        &cancel_flag,
+        background_executor,
+    )
+    .await;
+    CompletionsMenu::sort_string_matches(matches, Some(query), snippet_sort_order, &completions)
 }

crates/editor/src/code_context_menus.rs 🔗

@@ -1,11 +1,11 @@
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    AnyElement, BackgroundExecutor, Entity, Focusable, FontWeight, ListSizingBehavior,
-    ScrollStrategy, SharedString, Size, StrikethroughStyle, StyledText, UniformListScrollHandle,
-    div, px, uniform_list,
+    AnyElement, Entity, Focusable, FontWeight, ListSizingBehavior, ScrollStrategy, SharedString,
+    Size, StrikethroughStyle, StyledText, Task, UniformListScrollHandle, div, px, uniform_list,
 };
-use language::Buffer;
+use itertools::Itertools;
 use language::CodeLabel;
+use language::{Buffer, LanguageName, LanguageRegistry};
 use markdown::{Markdown, MarkdownElement};
 use multi_buffer::{Anchor, ExcerptId};
 use ordered_float::OrderedFloat;
@@ -15,6 +15,9 @@ use project::{CodeAction, Completion, TaskSourceKind};
 use task::DebugScenario;
 use task::TaskContext;
 
+use std::collections::VecDeque;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
 use std::{
     cell::RefCell,
     cmp::{Reverse, min},
@@ -26,6 +29,7 @@ use task::ResolvedTask;
 use ui::{Color, IntoElement, ListItem, Pixels, Popover, Styled, prelude::*};
 use util::ResultExt;
 
+use crate::CodeActionSource;
 use crate::editor_settings::SnippetSortOrder;
 use crate::hover_popover::{hover_markdown_style, open_markdown_url};
 use crate::{
@@ -40,7 +44,20 @@ pub const MENU_ASIDE_X_PADDING: Pixels = px(16.);
 pub const MENU_ASIDE_MIN_WIDTH: Pixels = px(260.);
 pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.);
 
-#[allow(clippy::large_enum_variant)]
+// Constants for the markdown cache. The purpose of this cache is to reduce flickering due to
+// documentation not yet being parsed.
+//
+// The size of the cache is set to 16, which is roughly 3 times more than the number of items
+// fetched around the current selection. This way documentation is more often ready for render when
+// revisiting previous entries, such as when pressing backspace.
+const MARKDOWN_CACHE_MAX_SIZE: usize = 16;
+const MARKDOWN_CACHE_BEFORE_ITEMS: usize = 2;
+const MARKDOWN_CACHE_AFTER_ITEMS: usize = 2;
+
+// Number of items beyond the visible items to resolve documentation.
+const RESOLVE_BEFORE_ITEMS: usize = 4;
+const RESOLVE_AFTER_ITEMS: usize = 4;
+
 pub enum CodeContextMenu {
     Completions(CompletionsMenu),
     CodeActions(CodeActionsMenu),
@@ -50,11 +67,12 @@ impl CodeContextMenu {
     pub fn select_first(
         &mut self,
         provider: Option<&dyn CompletionProvider>,
+        window: &mut Window,
         cx: &mut Context<Editor>,
     ) -> bool {
         if self.visible() {
             match self {
-                CodeContextMenu::Completions(menu) => menu.select_first(provider, cx),
+                CodeContextMenu::Completions(menu) => menu.select_first(provider, window, cx),
                 CodeContextMenu::CodeActions(menu) => menu.select_first(cx),
             }
             true
@@ -66,11 +84,12 @@ impl CodeContextMenu {
     pub fn select_prev(
         &mut self,
         provider: Option<&dyn CompletionProvider>,
+        window: &mut Window,
         cx: &mut Context<Editor>,
     ) -> bool {
         if self.visible() {
             match self {
-                CodeContextMenu::Completions(menu) => menu.select_prev(provider, cx),
+                CodeContextMenu::Completions(menu) => menu.select_prev(provider, window, cx),
                 CodeContextMenu::CodeActions(menu) => menu.select_prev(cx),
             }
             true
@@ -82,11 +101,12 @@ impl CodeContextMenu {
     pub fn select_next(
         &mut self,
         provider: Option<&dyn CompletionProvider>,
+        window: &mut Window,
         cx: &mut Context<Editor>,
     ) -> bool {
         if self.visible() {
             match self {
-                CodeContextMenu::Completions(menu) => menu.select_next(provider, cx),
+                CodeContextMenu::Completions(menu) => menu.select_next(provider, window, cx),
                 CodeContextMenu::CodeActions(menu) => menu.select_next(cx),
             }
             true
@@ -98,11 +118,12 @@ impl CodeContextMenu {
     pub fn select_last(
         &mut self,
         provider: Option<&dyn CompletionProvider>,
+        window: &mut Window,
         cx: &mut Context<Editor>,
     ) -> bool {
         if self.visible() {
             match self {
-                CodeContextMenu::Completions(menu) => menu.select_last(provider, cx),
+                CodeContextMenu::Completions(menu) => menu.select_last(provider, window, cx),
                 CodeContextMenu::CodeActions(menu) => menu.select_last(cx),
             }
             true
@@ -144,13 +165,12 @@ impl CodeContextMenu {
 
     pub fn render_aside(
         &mut self,
-        editor: &Editor,
         max_size: Size<Pixels>,
         window: &mut Window,
         cx: &mut Context<Editor>,
     ) -> Option<AnyElement> {
         match self {
-            CodeContextMenu::Completions(menu) => menu.render_aside(editor, max_size, window, cx),
+            CodeContextMenu::Completions(menu) => menu.render_aside(max_size, window, cx),
             CodeContextMenu::CodeActions(_) => None,
         }
     }
@@ -158,7 +178,7 @@ impl CodeContextMenu {
     pub fn focused(&self, window: &mut Window, cx: &mut Context<Editor>) -> bool {
         match self {
             CodeContextMenu::Completions(completions_menu) => completions_menu
-                .markdown_element
+                .get_or_create_entry_markdown(completions_menu.selected_item, cx)
                 .as_ref()
                 .is_some_and(|markdown| markdown.focus_handle(cx).contains_focused(window, cx)),
             CodeContextMenu::CodeActions(_) => false,
@@ -169,61 +189,107 @@ impl CodeContextMenu {
 pub enum ContextMenuOrigin {
     Cursor,
     GutterIndicator(DisplayRow),
+    QuickActionBar,
 }
 
-#[derive(Clone, Debug)]
 pub struct CompletionsMenu {
     pub id: CompletionId,
+    pub source: CompletionsMenuSource,
     sort_completions: bool,
     pub initial_position: Anchor,
+    pub initial_query: Option<Arc<String>>,
+    pub is_incomplete: bool,
     pub buffer: Entity<Buffer>,
     pub completions: Rc<RefCell<Box<[Completion]>>>,
-    match_candidates: Rc<[StringMatchCandidate]>,
-    pub entries: Rc<RefCell<Vec<StringMatch>>>,
+    match_candidates: Arc<[StringMatchCandidate]>,
+    pub entries: Rc<RefCell<Box<[StringMatch]>>>,
     pub selected_item: usize,
+    filter_task: Task<()>,
+    cancel_filter: Arc<AtomicBool>,
     scroll_handle: UniformListScrollHandle,
     resolve_completions: bool,
     show_completion_documentation: bool,
-    pub(super) ignore_completion_provider: bool,
     last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
-    markdown_element: Option<Entity<Markdown>>,
+    markdown_cache: Rc<RefCell<VecDeque<(MarkdownCacheKey, Entity<Markdown>)>>>,
+    language_registry: Option<Arc<LanguageRegistry>>,
+    language: Option<LanguageName>,
     snippet_sort_order: SnippetSortOrder,
 }
 
+#[derive(Clone, Debug, PartialEq)]
+enum MarkdownCacheKey {
+    ForCandidate {
+        candidate_id: usize,
+    },
+    ForCompletionMatch {
+        new_text: String,
+        markdown_source: SharedString,
+    },
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum CompletionsMenuSource {
+    Normal,
+    SnippetChoices,
+    Words,
+}
+
+// TODO: There should really be a wrapper around fuzzy match tasks that does this.
+impl Drop for CompletionsMenu {
+    fn drop(&mut self) {
+        self.cancel_filter.store(true, Ordering::Relaxed);
+    }
+}
+
 impl CompletionsMenu {
     pub fn new(
         id: CompletionId,
+        source: CompletionsMenuSource,
         sort_completions: bool,
         show_completion_documentation: bool,
-        ignore_completion_provider: bool,
         initial_position: Anchor,
+        initial_query: Option<Arc<String>>,
+        is_incomplete: bool,
         buffer: Entity<Buffer>,
         completions: Box<[Completion]>,
         snippet_sort_order: SnippetSortOrder,
+        language_registry: Option<Arc<LanguageRegistry>>,
+        language: Option<LanguageName>,
+        cx: &mut Context<Editor>,
     ) -> Self {
         let match_candidates = completions
             .iter()
             .enumerate()
-            .map(|(id, completion)| StringMatchCandidate::new(id, &completion.label.filter_text()))
+            .map(|(id, completion)| StringMatchCandidate::new(id, completion.label.filter_text()))
             .collect();
 
-        Self {
+        let completions_menu = Self {
             id,
+            source,
             sort_completions,
             initial_position,
+            initial_query,
+            is_incomplete,
             buffer,
             show_completion_documentation,
-            ignore_completion_provider,
             completions: RefCell::new(completions).into(),
             match_candidates,
-            entries: RefCell::new(Vec::new()).into(),
+            entries: Rc::new(RefCell::new(Box::new([]))),
             selected_item: 0,
+            filter_task: Task::ready(()),
+            cancel_filter: Arc::new(AtomicBool::new(false)),
             scroll_handle: UniformListScrollHandle::new(),
             resolve_completions: true,
             last_rendered_range: RefCell::new(None).into(),
-            markdown_element: None,
+            markdown_cache: RefCell::new(VecDeque::new()).into(),
+            language_registry,
+            language,
             snippet_sort_order,
-        }
+        };
+
+        completions_menu.start_markdown_parse_for_nearby_entries(cx);
+
+        completions_menu
     }
 
     pub fn new_snippet_choices(
@@ -266,22 +332,28 @@ impl CompletionsMenu {
                 positions: vec![],
                 string: completion.clone(),
             })
-            .collect::<Vec<_>>();
+            .collect();
         Self {
             id,
+            source: CompletionsMenuSource::SnippetChoices,
             sort_completions,
             initial_position: selection.start,
+            initial_query: None,
+            is_incomplete: false,
             buffer,
             completions: RefCell::new(completions).into(),
             match_candidates,
             entries: RefCell::new(entries).into(),
             selected_item: 0,
+            filter_task: Task::ready(()),
+            cancel_filter: Arc::new(AtomicBool::new(false)),
             scroll_handle: UniformListScrollHandle::new(),
             resolve_completions: false,
             show_completion_documentation: false,
-            ignore_completion_provider: false,
             last_rendered_range: RefCell::new(None).into(),
-            markdown_element: None,
+            markdown_cache: RefCell::new(VecDeque::new()).into(),
+            language_registry: None,
+            language: None,
             snippet_sort_order,
         }
     }
@@ -289,6 +361,7 @@ impl CompletionsMenu {
     fn select_first(
         &mut self,
         provider: Option<&dyn CompletionProvider>,
+        window: &mut Window,
         cx: &mut Context<Editor>,
     ) {
         let index = if self.scroll_handle.y_flipped() {
@@ -296,48 +369,61 @@ impl CompletionsMenu {
         } else {
             0
         };
-        self.update_selection_index(index, provider, cx);
+        self.update_selection_index(index, provider, window, cx);
     }
 
-    fn select_last(&mut self, provider: Option<&dyn CompletionProvider>, cx: &mut Context<Editor>) {
+    fn select_last(
+        &mut self,
+        provider: Option<&dyn CompletionProvider>,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) {
         let index = if self.scroll_handle.y_flipped() {
             0
         } else {
             self.entries.borrow().len() - 1
         };
-        self.update_selection_index(index, provider, cx);
+        self.update_selection_index(index, provider, window, cx);
     }
 
-    fn select_prev(&mut self, provider: Option<&dyn CompletionProvider>, cx: &mut Context<Editor>) {
+    fn select_prev(
+        &mut self,
+        provider: Option<&dyn CompletionProvider>,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) {
         let index = if self.scroll_handle.y_flipped() {
             self.next_match_index()
         } else {
             self.prev_match_index()
         };
-        self.update_selection_index(index, provider, cx);
+        self.update_selection_index(index, provider, window, cx);
     }
 
-    fn select_next(&mut self, provider: Option<&dyn CompletionProvider>, cx: &mut Context<Editor>) {
+    fn select_next(
+        &mut self,
+        provider: Option<&dyn CompletionProvider>,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) {
         let index = if self.scroll_handle.y_flipped() {
             self.prev_match_index()
         } else {
             self.next_match_index()
         };
-        self.update_selection_index(index, provider, cx);
+        self.update_selection_index(index, provider, window, cx);
     }
 
     fn update_selection_index(
         &mut self,
         match_index: usize,
         provider: Option<&dyn CompletionProvider>,
+        window: &mut Window,
         cx: &mut Context<Editor>,
     ) {
         if self.selected_item != match_index {
             self.selected_item = match_index;
-            self.scroll_handle
-                .scroll_to_item(self.selected_item, ScrollStrategy::Top);
-            self.resolve_visible_completions(provider, cx);
-            cx.notify();
+            self.handle_selection_changed(provider, window, cx);
         }
     }
 
@@ -357,6 +443,28 @@ impl CompletionsMenu {
         }
     }
 
+    fn handle_selection_changed(
+        &mut self,
+        provider: Option<&dyn CompletionProvider>,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) {
+        self.scroll_handle
+            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
+        if let Some(provider) = provider {
+            let entries = self.entries.borrow();
+            let entry = if self.selected_item < entries.len() {
+                Some(&entries[self.selected_item])
+            } else {
+                None
+            };
+            provider.selection_changed(entry, window, cx);
+        }
+        self.resolve_visible_completions(provider, cx);
+        self.start_markdown_parse_for_nearby_entries(cx);
+        cx.notify();
+    }
+
     pub fn resolve_visible_completions(
         &mut self,
         provider: Option<&dyn CompletionProvider>,
@@ -369,6 +477,19 @@ impl CompletionsMenu {
             return;
         };
 
+        let entries = self.entries.borrow();
+        if entries.is_empty() {
+            return;
+        }
+        if self.selected_item >= entries.len() {
+            log::error!(
+                "bug: completion selected_item >= entries.len(): {} >= {}",
+                self.selected_item,
+                entries.len()
+            );
+            self.selected_item = entries.len() - 1;
+        }
+
         // Attempt to resolve completions for every item that will be displayed. This matters
         // because single line documentation may be displayed inline with the completion.
         //
@@ -380,7 +501,6 @@ impl CompletionsMenu {
         let visible_count = last_rendered_range
             .clone()
             .map_or(APPROXIMATE_VISIBLE_COUNT, |range| range.count());
-        let entries = self.entries.borrow();
         let entry_range = if self.selected_item == 0 {
             0..min(visible_count, entries.len())
         } else if self.selected_item == entries.len() - 1 {
@@ -393,11 +513,10 @@ impl CompletionsMenu {
 
         // Expand the range to resolve more completions than are predicted to be visible, to reduce
         // jank on navigation.
-        const EXTRA_TO_RESOLVE: usize = 4;
-        let entry_indices = util::iterate_expanded_and_wrapped_usize_range(
+        let entry_indices = util::expanded_and_wrapped_usize_range(
             entry_range.clone(),
-            EXTRA_TO_RESOLVE,
-            EXTRA_TO_RESOLVE,
+            RESOLVE_BEFORE_ITEMS,
+            RESOLVE_AFTER_ITEMS,
             entries.len(),
         );
 
@@ -427,14 +546,160 @@ impl CompletionsMenu {
             cx,
         );
 
+        let completion_id = self.id;
         cx.spawn(async move |editor, cx| {
             if let Some(true) = resolve_task.await.log_err() {
-                editor.update(cx, |_, cx| cx.notify()).ok();
+                editor
+                    .update(cx, |editor, cx| {
+                        // `resolve_completions` modified state affecting display.
+                        cx.notify();
+                        editor.with_completions_menu_matching_id(completion_id, |menu| {
+                            if let Some(menu) = menu {
+                                menu.start_markdown_parse_for_nearby_entries(cx)
+                            }
+                        });
+                    })
+                    .ok();
             }
         })
         .detach();
     }
 
+    fn start_markdown_parse_for_nearby_entries(&self, cx: &mut Context<Editor>) {
+        // Enqueue parse tasks of nearer items first.
+        //
+        // TODO: This means that the nearer items will actually be further back in the cache, which
+        // is not ideal. In practice this is fine because `get_or_create_markdown` moves the current
+        // selection to the front (when `is_render = true`).
+        let entry_indices = util::wrapped_usize_outward_from(
+            self.selected_item,
+            MARKDOWN_CACHE_BEFORE_ITEMS,
+            MARKDOWN_CACHE_AFTER_ITEMS,
+            self.entries.borrow().len(),
+        );
+
+        for index in entry_indices {
+            self.get_or_create_entry_markdown(index, cx);
+        }
+    }
+
+    fn get_or_create_entry_markdown(
+        &self,
+        index: usize,
+        cx: &mut Context<Editor>,
+    ) -> Option<Entity<Markdown>> {
+        let entries = self.entries.borrow();
+        if index >= entries.len() {
+            return None;
+        }
+        let candidate_id = entries[index].candidate_id;
+        let completions = self.completions.borrow();
+        match &completions[candidate_id].documentation {
+            Some(CompletionDocumentation::MultiLineMarkdown(source)) if !source.is_empty() => self
+                .get_or_create_markdown(candidate_id, Some(source), false, &completions, cx)
+                .map(|(_, markdown)| markdown),
+            Some(_) => None,
+            _ => None,
+        }
+    }
+
+    fn get_or_create_markdown(
+        &self,
+        candidate_id: usize,
+        source: Option<&SharedString>,
+        is_render: bool,
+        completions: &[Completion],
+        cx: &mut Context<Editor>,
+    ) -> Option<(bool, Entity<Markdown>)> {
+        let mut markdown_cache = self.markdown_cache.borrow_mut();
+
+        let mut has_completion_match_cache_entry = false;
+        let mut matching_entry = markdown_cache.iter().find_position(|(key, _)| match key {
+            MarkdownCacheKey::ForCandidate { candidate_id: id } => *id == candidate_id,
+            MarkdownCacheKey::ForCompletionMatch { .. } => {
+                has_completion_match_cache_entry = true;
+                false
+            }
+        });
+
+        if has_completion_match_cache_entry && matching_entry.is_none() {
+            if let Some(source) = source {
+                matching_entry = markdown_cache.iter().find_position(|(key, _)| {
+                    matches!(key, MarkdownCacheKey::ForCompletionMatch { markdown_source, .. }
+                                if markdown_source == source)
+                });
+            } else {
+                // Heuristic guess that documentation can be reused when new_text matches. This is
+                // to mitigate documentation flicker while typing. If this is wrong, then resolution
+                // should cause the correct documentation to be displayed soon.
+                let completion = &completions[candidate_id];
+                matching_entry = markdown_cache.iter().find_position(|(key, _)| {
+                    matches!(key, MarkdownCacheKey::ForCompletionMatch { new_text, .. }
+                                if new_text == &completion.new_text)
+                });
+            }
+        }
+
+        if let Some((cache_index, (key, markdown))) = matching_entry {
+            let markdown = markdown.clone();
+
+            // Since the markdown source matches, the key can now be ForCandidate.
+            if source.is_some() && matches!(key, MarkdownCacheKey::ForCompletionMatch { .. }) {
+                markdown_cache[cache_index].0 = MarkdownCacheKey::ForCandidate { candidate_id };
+            }
+
+            if is_render && cache_index != 0 {
+                // Move the current selection's cache entry to the front.
+                markdown_cache.rotate_right(1);
+                let cache_len = markdown_cache.len();
+                markdown_cache.swap(0, (cache_index + 1) % cache_len);
+            }
+
+            let is_parsing = markdown.update(cx, |markdown, cx| {
+                if let Some(source) = source {
+                    // `reset` is called as it's possible for documentation to change due to resolve
+                    // requests. It does nothing if `source` is unchanged.
+                    markdown.reset(source.clone(), cx);
+                }
+                markdown.is_parsing()
+            });
+            return Some((is_parsing, markdown));
+        }
+
+        let Some(source) = source else {
+            // Can't create markdown as there is no source.
+            return None;
+        };
+
+        if markdown_cache.len() < MARKDOWN_CACHE_MAX_SIZE {
+            let markdown = cx.new(|cx| {
+                Markdown::new(
+                    source.clone(),
+                    self.language_registry.clone(),
+                    self.language.clone(),
+                    cx,
+                )
+            });
+            // Handles redraw when the markdown is done parsing. The current render is for a
+            // deferred draw, and so without this did not redraw when `markdown` notified.
+            cx.observe(&markdown, |_, _, cx| cx.notify()).detach();
+            markdown_cache.push_front((
+                MarkdownCacheKey::ForCandidate { candidate_id },
+                markdown.clone(),
+            ));
+            Some((true, markdown))
+        } else {
+            debug_assert_eq!(markdown_cache.capacity(), MARKDOWN_CACHE_MAX_SIZE);
+            // Moves the last cache entry to the start. The ring buffer is full, so this does no
+            // copying and just shifts indexes.
+            markdown_cache.rotate_right(1);
+            markdown_cache[0].0 = MarkdownCacheKey::ForCandidate { candidate_id };
+            let markdown = &markdown_cache[0].1;
+            markdown.update(cx, |markdown, cx| markdown.reset(source.clone(), cx));
+            Some((true, markdown.clone()))
+        }
+    }
+
     pub fn visible(&self) -> bool {
         !self.entries.borrow().is_empty()
     }
@@ -450,39 +715,16 @@ impl CompletionsMenu {
         window: &mut Window,
         cx: &mut Context<Editor>,
     ) -> AnyElement {
-        let completions = self.completions.borrow_mut();
         let show_completion_documentation = self.show_completion_documentation;
-        let widest_completion_ix = self
-            .entries
-            .borrow()
-            .iter()
-            .enumerate()
-            .max_by_key(|(_, mat)| {
-                let completion = &completions[mat.candidate_id];
-                let documentation = &completion.documentation;
-
-                let mut len = completion.label.text.chars().count();
-                if let Some(CompletionDocumentation::SingleLine(text)) = documentation {
-                    if show_completion_documentation {
-                        len += text.chars().count();
-                    }
-                }
-
-                len
-            })
-            .map(|(ix, _)| ix);
-        drop(completions);
-
         let selected_item = self.selected_item;
         let completions = self.completions.clone();
         let entries = self.entries.clone();
         let last_rendered_range = self.last_rendered_range.clone();
         let style = style.clone();
         let list = uniform_list(
-            cx.entity().clone(),
             "completions",
             self.entries.borrow().len(),
-            move |_editor, range, _window, cx| {
+            cx.processor(move |_editor, range: Range<usize>, _window, cx| {
                 last_rendered_range.borrow_mut().replace(range.clone());
                 let start_ix = range.start;
                 let completions_guard = completions.borrow_mut();
@@ -532,22 +774,25 @@ impl CompletionsMenu {
 
                         let completion_label = StyledText::new(completion.label.text.clone())
                             .with_default_highlights(&style.text, highlights);
-                        let documentation_label = if let Some(
-                            CompletionDocumentation::SingleLine(text),
-                        ) = documentation
-                        {
-                            if text.trim().is_empty() {
-                                None
-                            } else {
-                                Some(
-                                    Label::new(text.clone())
-                                        .ml_4()
-                                        .size(LabelSize::Small)
-                                        .color(Color::Muted),
-                                )
+
+                        let documentation_label = match documentation {
+                            Some(CompletionDocumentation::SingleLine(text))
+                            | Some(CompletionDocumentation::SingleLineAndMultiLinePlainText {
+                                single_line: text,
+                                ..
+                            }) => {
+                                if text.trim().is_empty() {
+                                    None
+                                } else {
+                                    Some(
+                                        Label::new(text.clone())
+                                            .ml_4()
+                                            .size(LabelSize::Small)
+                                            .color(Color::Muted),
+                                    )
+                                }
                             }
-                        } else {
-                            None
+                            _ => None,
                         };
 
                         let start_slot = completion
@@ -591,20 +836,19 @@ impl CompletionsMenu {
                         )
                     })
                     .collect()
-            },
+            }),
         )
         .occlude()
         .max_h(max_height_in_lines as f32 * window.line_height())
         .track_scroll(self.scroll_handle.clone())
-        .with_width_from_item(widest_completion_ix)
-        .with_sizing_behavior(ListSizingBehavior::Infer);
+        .with_sizing_behavior(ListSizingBehavior::Infer)
+        .w(rems(34.));
 
         Popover::new().child(list).into_any_element()
     }
 
     fn render_aside(
         &mut self,
-        editor: &Editor,
         max_size: Size<Pixels>,
         window: &mut Window,
         cx: &mut Context<Editor>,
@@ -614,41 +858,48 @@ impl CompletionsMenu {
         }
 
         let mat = &self.entries.borrow()[self.selected_item];
-        let multiline_docs = match self.completions.borrow_mut()[mat.candidate_id]
-            .documentation
-            .as_ref()?
-        {
-            CompletionDocumentation::MultiLinePlainText(text) => div().child(text.clone()),
-            CompletionDocumentation::MultiLineMarkdown(parsed) if !parsed.is_empty() => {
-                let markdown = self.markdown_element.get_or_insert_with(|| {
-                    cx.new(|cx| {
-                        let languages = editor
-                            .workspace
-                            .as_ref()
-                            .and_then(|(workspace, _)| workspace.upgrade())
-                            .map(|workspace| workspace.read(cx).app_state().languages.clone());
-                        let language = editor
-                            .language_at(self.initial_position, cx)
-                            .map(|l| l.name().to_proto());
-                        Markdown::new(SharedString::default(), languages, language, cx)
-                    })
-                });
-                markdown.update(cx, |markdown, cx| {
-                    markdown.reset(parsed.clone(), cx);
-                });
-                div().child(
-                    MarkdownElement::new(markdown.clone(), hover_markdown_style(window, cx))
-                        .code_block_renderer(markdown::CodeBlockRenderer::Default {
-                            copy_button: false,
-                            copy_button_on_hover: false,
-                            border: false,
-                        })
-                        .on_url_click(open_markdown_url),
-                )
+        let completions = self.completions.borrow_mut();
+        let multiline_docs = match completions[mat.candidate_id].documentation.as_ref() {
+            Some(CompletionDocumentation::MultiLinePlainText(text)) => div().child(text.clone()),
+            Some(CompletionDocumentation::SingleLineAndMultiLinePlainText {
+                plain_text: Some(text),
+                ..
+            }) => div().child(text.clone()),
+            Some(CompletionDocumentation::MultiLineMarkdown(source)) if !source.is_empty() => {
+                let Some((false, markdown)) = self.get_or_create_markdown(
+                    mat.candidate_id,
+                    Some(source),
+                    true,
+                    &completions,
+                    cx,
+                ) else {
+                    return None;
+                };
+                Self::render_markdown(markdown, window, cx)
+            }
+            None => {
+                // Handle the case where documentation hasn't yet been resolved but there's a
+                // `new_text` match in the cache.
+                //
+                // TODO: It's inconsistent that documentation caching based on matching `new_text`
+                // only works for markdown. Consider generally caching the results of resolving
+                // completions.
+                let Some((false, markdown)) =
+                    self.get_or_create_markdown(mat.candidate_id, None, true, &completions, cx)
+                else {
+                    return None;
+                };
+                Self::render_markdown(markdown, window, cx)
+            }
+            Some(CompletionDocumentation::MultiLineMarkdown(_)) => return None,
+            Some(CompletionDocumentation::SingleLine(_)) => return None,
+            Some(CompletionDocumentation::Undocumented) => return None,
+            Some(CompletionDocumentation::SingleLineAndMultiLinePlainText {
+                plain_text: None,
+                ..
+            }) => {
+                return None;
             }
-            CompletionDocumentation::MultiLineMarkdown(_) => return None,
-            CompletionDocumentation::SingleLine(_) => return None,
-            CompletionDocumentation::Undocumented => return None,
         };
 
         Some(
@@ -666,48 +917,187 @@ impl CompletionsMenu {
         )
     }
 
-    pub fn sort_matches(
-        matches: &mut Vec<SortableMatch<'_>>,
+    fn render_markdown(
+        markdown: Entity<Markdown>,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) -> Div {
+        div().child(
+            MarkdownElement::new(markdown, hover_markdown_style(window, cx))
+                .code_block_renderer(markdown::CodeBlockRenderer::Default {
+                    copy_button: false,
+                    copy_button_on_hover: false,
+                    border: false,
+                })
+                .on_url_click(open_markdown_url),
+        )
+    }
+
+    pub fn filter(
+        &mut self,
+        query: Option<Arc<String>>,
+        provider: Option<Rc<dyn CompletionProvider>>,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) {
+        self.cancel_filter.store(true, Ordering::Relaxed);
+        if let Some(query) = query {
+            self.cancel_filter = Arc::new(AtomicBool::new(false));
+            let matches = self.do_async_filtering(query, cx);
+            let id = self.id;
+            self.filter_task = cx.spawn_in(window, async move |editor, cx| {
+                let matches = matches.await;
+                editor
+                    .update_in(cx, |editor, window, cx| {
+                        editor.with_completions_menu_matching_id(id, |this| {
+                            if let Some(this) = this {
+                                this.set_filter_results(matches, provider, window, cx);
+                            }
+                        });
+                    })
+                    .ok();
+            });
+        } else {
+            self.filter_task = Task::ready(());
+            let matches = self.unfiltered_matches();
+            self.set_filter_results(matches, provider, window, cx);
+        }
+    }
+
+    pub fn do_async_filtering(
+        &self,
+        query: Arc<String>,
+        cx: &Context<Editor>,
+    ) -> Task<Vec<StringMatch>> {
+        let matches_task = cx.background_spawn({
+            let query = query.clone();
+            let match_candidates = self.match_candidates.clone();
+            let cancel_filter = self.cancel_filter.clone();
+            let background_executor = cx.background_executor().clone();
+            async move {
+                fuzzy::match_strings(
+                    &match_candidates,
+                    &query,
+                    query.chars().any(|c| c.is_uppercase()),
+                    false,
+                    1000,
+                    &cancel_filter,
+                    background_executor,
+                )
+                .await
+            }
+        });
+
+        let completions = self.completions.clone();
+        let sort_completions = self.sort_completions;
+        let snippet_sort_order = self.snippet_sort_order;
+        cx.foreground_executor().spawn(async move {
+            let mut matches = matches_task.await;
+
+            if sort_completions {
+                matches = Self::sort_string_matches(
+                    matches,
+                    Some(&query),
+                    snippet_sort_order,
+                    completions.borrow().as_ref(),
+                );
+            }
+
+            matches
+        })
+    }
+
+    /// Like `do_async_filtering` but there is no filter query, so no need to spawn tasks.
+    pub fn unfiltered_matches(&self) -> Vec<StringMatch> {
+        let mut matches = self
+            .match_candidates
+            .iter()
+            .enumerate()
+            .map(|(candidate_id, candidate)| StringMatch {
+                candidate_id,
+                score: Default::default(),
+                positions: Default::default(),
+                string: candidate.string.clone(),
+            })
+            .collect();
+
+        if self.sort_completions {
+            matches = Self::sort_string_matches(
+                matches,
+                None,
+                self.snippet_sort_order,
+                self.completions.borrow().as_ref(),
+            );
+        }
+
+        matches
+    }
+
+    pub fn set_filter_results(
+        &mut self,
+        matches: Vec<StringMatch>,
+        provider: Option<Rc<dyn CompletionProvider>>,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) {
+        *self.entries.borrow_mut() = matches.into_boxed_slice();
+        self.selected_item = 0;
+        self.handle_selection_changed(provider.as_deref(), window, cx);
+    }
+
+    pub fn sort_string_matches(
+        matches: Vec<StringMatch>,
         query: Option<&str>,
         snippet_sort_order: SnippetSortOrder,
-    ) {
+        completions: &[Completion],
+    ) -> Vec<StringMatch> {
+        let mut matches = matches;
+
         #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
         enum MatchTier<'a> {
             WordStartMatch {
-                sort_prefix: Reverse<usize>,
-                sort_fuzzy_bracket: Reverse<usize>,
+                sort_exact: Reverse<i32>,
+                sort_positions: Vec<usize>,
                 sort_snippet: Reverse<i32>,
-                sort_text: Option<&'a str>,
                 sort_score: Reverse<OrderedFloat<f64>>,
-                sort_key: (usize, &'a str),
+                sort_text: Option<&'a str>,
+                sort_kind: usize,
+                sort_label: &'a str,
             },
             OtherMatch {
                 sort_score: Reverse<OrderedFloat<f64>>,
             },
         }
 
-        // Our goal here is to intelligently sort completion suggestions. We want to
-        // balance the raw fuzzy match score with hints from the language server
-
-        // In a fuzzy bracket, matches with a score of 1.0 are prioritized.
-        // The remaining matches are partitioned into two groups at 2/3 of the max_score.
-        let max_score = matches
-            .iter()
-            .map(|mat| mat.string_match.score)
-            .fold(0.0, f64::max);
-        let second_bracket_threshold = max_score * (2.0 / 3.0);
-
         let query_start_lower = query
+            .as_ref()
             .and_then(|q| q.chars().next())
             .and_then(|c| c.to_lowercase().next());
 
-        matches.sort_unstable_by_key(|mat| {
-            let score = mat.string_match.score;
+        matches.sort_unstable_by_key(|string_match| {
+            let completion = &completions[string_match.candidate_id];
+
+            let is_snippet = matches!(
+                &completion.source,
+                CompletionSource::Lsp { lsp_completion, .. }
+                if lsp_completion.kind == Some(CompletionItemKind::SNIPPET)
+            );
+
+            let sort_text = if let CompletionSource::Lsp { lsp_completion, .. } = &completion.source
+            {
+                lsp_completion.sort_text.as_deref()
+            } else {
+                None
+            };
+
+            let (sort_kind, sort_label) = completion.sort_key();
+
+            let score = string_match.score;
             let sort_score = Reverse(OrderedFloat(score));
 
             let query_start_doesnt_match_split_words = query_start_lower
                 .map(|query_char| {
-                    !split_words(&mat.string_match.string).any(|word| {
+                    !split_words(&string_match.string).any(|word| {
                         word.chars()
                             .next()
                             .and_then(|c| c.to_lowercase().next())

crates/editor/src/display_map.rs 🔗

@@ -76,11 +76,17 @@ pub enum FoldStatus {
     Foldable,
 }
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub enum HighlightKey {
+    Type(TypeId),
+    TypePlus(TypeId, usize),
+}
+
 pub trait ToDisplayPoint {
     fn to_display_point(&self, map: &DisplaySnapshot) -> DisplayPoint;
 }
 
-type TextHighlights = TreeMap<TypeId, Arc<(HighlightStyle, Vec<Range<Anchor>>)>>;
+type TextHighlights = TreeMap<HighlightKey, Arc<(HighlightStyle, Vec<Range<Anchor>>)>>;
 type InlayHighlights = TreeMap<TypeId, TreeMap<InlayId, (HighlightStyle, InlayHighlight)>>;
 
 /// Decides how text in a [`MultiBuffer`] should be displayed in a buffer, handling inlay hints,
@@ -473,12 +479,11 @@ impl DisplayMap {
 
     pub fn highlight_text(
         &mut self,
-        type_id: TypeId,
+        key: HighlightKey,
         ranges: Vec<Range<Anchor>>,
         style: HighlightStyle,
     ) {
-        self.text_highlights
-            .insert(type_id, Arc::new((style, ranges)));
+        self.text_highlights.insert(key, Arc::new((style, ranges)));
     }
 
     pub(crate) fn highlight_inlays(
@@ -501,11 +506,22 @@ impl DisplayMap {
     }
 
     pub fn text_highlights(&self, type_id: TypeId) -> Option<(HighlightStyle, &[Range<Anchor>])> {
-        let highlights = self.text_highlights.get(&type_id)?;
+        let highlights = self.text_highlights.get(&HighlightKey::Type(type_id))?;
         Some((highlights.0, &highlights.1))
     }
+
+    #[cfg(feature = "test-support")]
+    pub fn all_text_highlights(
+        &self,
+    ) -> impl Iterator<Item = &Arc<(HighlightStyle, Vec<Range<Anchor>>)>> {
+        self.text_highlights.values()
+    }
+
     pub fn clear_highlights(&mut self, type_id: TypeId) -> bool {
-        let mut cleared = self.text_highlights.remove(&type_id).is_some();
+        let mut cleared = self
+            .text_highlights
+            .remove(&HighlightKey::Type(type_id))
+            .is_some();
         cleared |= self.inlay_highlights.remove(&type_id).is_some();
         cleared
     }
@@ -639,6 +655,7 @@ pub struct HighlightedChunk<'a> {
     pub text: &'a str,
     pub style: Option<HighlightStyle>,
     pub is_tab: bool,
+    pub is_inlay: bool,
     pub replacement: Option<ChunkReplacement>,
 }
 
@@ -652,6 +669,7 @@ impl<'a> HighlightedChunk<'a> {
         let style = self.style;
         let is_tab = self.is_tab;
         let renderer = self.replacement;
+        let is_inlay = self.is_inlay;
         iter::from_fn(move || {
             let mut prefix_len = 0;
             while let Some(&ch) = chars.peek() {
@@ -667,6 +685,7 @@ impl<'a> HighlightedChunk<'a> {
                         text: prefix,
                         style,
                         is_tab,
+                        is_inlay,
                         replacement: renderer.clone(),
                     });
                 }
@@ -693,6 +712,7 @@ impl<'a> HighlightedChunk<'a> {
                         text: prefix,
                         style: Some(invisible_style),
                         is_tab: false,
+                        is_inlay,
                         replacement: Some(ChunkReplacement::Str(replacement.into())),
                     });
                 } else {
@@ -716,6 +736,7 @@ impl<'a> HighlightedChunk<'a> {
                         text: prefix,
                         style: Some(invisible_style),
                         is_tab: false,
+                        is_inlay,
                         replacement: renderer.clone(),
                     });
                 }
@@ -728,6 +749,7 @@ impl<'a> HighlightedChunk<'a> {
                     text: remainder,
                     style,
                     is_tab,
+                    is_inlay,
                     replacement: renderer.clone(),
                 })
             } else {
@@ -944,10 +966,22 @@ impl DisplaySnapshot {
                 .and_then(|id| id.style(&editor_style.syntax));
 
             if let Some(chunk_highlight) = chunk.highlight_style {
+                // For color inlays, blend the color with the editor background
+                let mut processed_highlight = chunk_highlight;
+                if chunk.is_inlay {
+                    if let Some(inlay_color) = chunk_highlight.color {
+                        // Only blend if the color has transparency (alpha < 1.0)
+                        if inlay_color.a < 1.0 {
+                            let blended_color = editor_style.background.blend(inlay_color);
+                            processed_highlight.color = Some(blended_color);
+                        }
+                    }
+                }
+
                 if let Some(highlight_style) = highlight_style.as_mut() {
-                    highlight_style.highlight(chunk_highlight);
+                    highlight_style.highlight(processed_highlight);
                 } else {
-                    highlight_style = Some(chunk_highlight);
+                    highlight_style = Some(processed_highlight);
                 }
             }
 
@@ -961,7 +995,10 @@ impl DisplaySnapshot {
                 if chunk.is_unnecessary {
                     diagnostic_highlight.fade_out = Some(editor_style.unnecessary_code_fade);
                 }
-                if editor_style.show_underlines {
+                if chunk.underline
+                    && editor_style.show_underlines
+                    && !(chunk.is_unnecessary && severity > lsp::DiagnosticSeverity::WARNING)
+                {
                     let diagnostic_color = super::diagnostic_style(severity, &editor_style.status);
                     diagnostic_highlight.underline = Some(UnderlineStyle {
                         color: Some(diagnostic_color),
@@ -981,6 +1018,7 @@ impl DisplaySnapshot {
                 text: chunk.text,
                 style: highlight_style,
                 is_tab: chunk.is_tab,
+                is_inlay: chunk.is_inlay,
                 replacement: chunk.renderer.map(ChunkReplacement::Renderer),
             }
             .highlight_invisibles(editor_style)
@@ -1026,9 +1064,7 @@ impl DisplaySnapshot {
         }
 
         let font_size = editor_style.text.font_size.to_pixels(*rem_size);
-        text_system
-            .layout_line(&line, font_size, &runs)
-            .expect("we expect the font to be loaded because it's rendered by the editor")
+        text_system.layout_line(&line, font_size, &runs)
     }
 
     pub fn x_for_display_point(
@@ -1325,7 +1361,9 @@ impl DisplaySnapshot {
         &self,
     ) -> Option<Arc<(HighlightStyle, Vec<Range<Anchor>>)>> {
         let type_id = TypeId::of::<Tag>();
-        self.text_highlights.get(&type_id).cloned()
+        self.text_highlights
+            .get(&HighlightKey::Type(type_id))
+            .cloned()
     }
 
     #[allow(unused)]
@@ -1999,11 +2037,11 @@ pub mod tests {
         map.update(cx, |map, cx| {
             map.splice_inlays(
                 &[],
-                vec![Inlay {
-                    id: InlayId::InlineCompletion(0),
-                    position: buffer_snapshot.anchor_after(0),
-                    text: "\n".into(),
-                }],
+                vec![Inlay::inline_completion(
+                    0,
+                    buffer_snapshot.anchor_after(0),
+                    "\n",
+                )],
                 cx,
             );
         });
@@ -2286,7 +2324,7 @@ pub mod tests {
         // Insert a block in the middle of a multi-line diagnostic.
         map.update(cx, |map, cx| {
             map.highlight_text(
-                TypeId::of::<usize>(),
+                HighlightKey::Type(TypeId::of::<usize>()),
                 vec![
                     buffer_snapshot.anchor_before(Point::new(3, 9))
                         ..buffer_snapshot.anchor_after(Point::new(3, 14)),
@@ -2514,7 +2552,9 @@ pub mod tests {
             cx.update(|cx| syntax_chunks(DisplayRow(0)..DisplayRow(5), &map, &theme, cx)),
             [
                 ("fn \n".to_string(), None),
-                ("oute\nr".to_string(), Some(Hsla::blue())),
+                ("oute".to_string(), Some(Hsla::blue())),
+                ("\n".to_string(), None),
+                ("r".to_string(), Some(Hsla::blue())),
                 ("() \n{}\n\n".to_string(), None),
             ]
         );
@@ -2537,8 +2577,11 @@ pub mod tests {
             [
                 ("out".to_string(), Some(Hsla::blue())),
                 ("⋯\n".to_string(), None),
-                ("  \nfn ".to_string(), Some(Hsla::red())),
-                ("i\n".to_string(), Some(Hsla::blue()))
+                ("  ".to_string(), Some(Hsla::red())),
+                ("\n".to_string(), None),
+                ("fn ".to_string(), Some(Hsla::red())),
+                ("i".to_string(), Some(Hsla::blue())),
+                ("\n".to_string(), None)
             ]
         );
     }
@@ -2603,7 +2646,7 @@ pub mod tests {
 
         map.update(cx, |map, _cx| {
             map.highlight_text(
-                TypeId::of::<MyType>(),
+                HighlightKey::Type(TypeId::of::<MyType>()),
                 highlighted_ranges
                     .into_iter()
                     .map(|range| {

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

@@ -282,7 +282,6 @@ struct Transform {
     block: Option<Block>,
 }
 
-#[allow(clippy::large_enum_variant)]
 #[derive(Clone)]
 pub enum Block {
     Custom(Arc<CustomBlock>),
@@ -465,7 +464,7 @@ impl BlockMap {
         map
     }
 
-    pub fn read(&self, wrap_snapshot: WrapSnapshot, edits: Patch<u32>) -> BlockMapReader {
+    pub fn read(&self, wrap_snapshot: WrapSnapshot, edits: Patch<u32>) -> BlockMapReader<'_> {
         self.sync(&wrap_snapshot, edits);
         *self.wrap_snapshot.borrow_mut() = wrap_snapshot.clone();
         BlockMapReader {
@@ -480,7 +479,7 @@ impl BlockMap {
         }
     }
 
-    pub fn write(&mut self, wrap_snapshot: WrapSnapshot, edits: Patch<u32>) -> BlockMapWriter {
+    pub fn write(&mut self, wrap_snapshot: WrapSnapshot, edits: Patch<u32>) -> BlockMapWriter<'_> {
         self.sync(&wrap_snapshot, edits);
         *self.wrap_snapshot.borrow_mut() = wrap_snapshot;
         BlockMapWriter(self)
@@ -1328,7 +1327,7 @@ impl BlockSnapshot {
         }
     }
 
-    pub(super) fn row_infos(&self, start_row: BlockRow) -> BlockRows {
+    pub(super) fn row_infos(&self, start_row: BlockRow) -> BlockRows<'_> {
         let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
         cursor.seek(&start_row, Bias::Right, &());
         let (output_start, input_start) = cursor.start();

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

@@ -1,16 +1,15 @@
 use collections::BTreeMap;
 use gpui::HighlightStyle;
 use language::Chunk;
-use multi_buffer::{Anchor, MultiBufferChunks, MultiBufferSnapshot, ToOffset as _};
+use multi_buffer::{MultiBufferChunks, MultiBufferSnapshot, ToOffset as _};
 use std::{
-    any::TypeId,
     cmp,
     iter::{self, Peekable},
     ops::Range,
-    sync::Arc,
     vec,
 };
-use sum_tree::TreeMap;
+
+use crate::display_map::{HighlightKey, TextHighlights};
 
 pub struct CustomHighlightsChunks<'a> {
     buffer_chunks: MultiBufferChunks<'a>,
@@ -19,15 +18,15 @@ pub struct CustomHighlightsChunks<'a> {
     multibuffer_snapshot: &'a MultiBufferSnapshot,
 
     highlight_endpoints: Peekable<vec::IntoIter<HighlightEndpoint>>,
-    active_highlights: BTreeMap<TypeId, HighlightStyle>,
-    text_highlights: Option<&'a TreeMap<TypeId, Arc<(HighlightStyle, Vec<Range<Anchor>>)>>>,
+    active_highlights: BTreeMap<HighlightKey, HighlightStyle>,
+    text_highlights: Option<&'a TextHighlights>,
 }
 
 #[derive(Debug, Copy, Clone, Eq, PartialEq)]
 struct HighlightEndpoint {
     offset: usize,
     is_start: bool,
-    tag: TypeId,
+    tag: HighlightKey,
     style: HighlightStyle,
 }
 
@@ -35,7 +34,7 @@ impl<'a> CustomHighlightsChunks<'a> {
     pub fn new(
         range: Range<usize>,
         language_aware: bool,
-        text_highlights: Option<&'a TreeMap<TypeId, Arc<(HighlightStyle, Vec<Range<Anchor>>)>>>,
+        text_highlights: Option<&'a TextHighlights>,
         multibuffer_snapshot: &'a MultiBufferSnapshot,
     ) -> Self {
         Self {
@@ -66,7 +65,7 @@ impl<'a> CustomHighlightsChunks<'a> {
 
 fn create_highlight_endpoints(
     range: &Range<usize>,
-    text_highlights: Option<&TreeMap<TypeId, Arc<(HighlightStyle, Vec<Range<Anchor>>)>>>,
+    text_highlights: Option<&TextHighlights>,
     buffer: &MultiBufferSnapshot,
 ) -> iter::Peekable<vec::IntoIter<HighlightEndpoint>> {
     let mut highlight_endpoints = Vec::new();

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

@@ -357,7 +357,7 @@ impl FoldMap {
         &mut self,
         inlay_snapshot: InlaySnapshot,
         edits: Vec<InlayEdit>,
-    ) -> (FoldMapWriter, FoldSnapshot, Vec<FoldEdit>) {
+    ) -> (FoldMapWriter<'_>, FoldSnapshot, Vec<FoldEdit>) {
         let (snapshot, edits) = self.read(inlay_snapshot, edits);
         (FoldMapWriter(self), snapshot, edits)
     }
@@ -730,7 +730,7 @@ impl FoldSnapshot {
         (line_end - line_start) as u32
     }
 
-    pub fn row_infos(&self, start_row: u32) -> FoldRows {
+    pub fn row_infos(&self, start_row: u32) -> FoldRows<'_> {
         if start_row > self.transforms.summary().output.lines.row {
             panic!("invalid display row {}", start_row);
         }
@@ -1255,8 +1255,12 @@ pub struct Chunk<'a> {
     pub diagnostic_severity: Option<lsp::DiagnosticSeverity>,
     /// Whether this chunk of text is marked as unnecessary.
     pub is_unnecessary: bool,
+    /// Whether this chunk of text should be underlined.
+    pub underline: bool,
     /// Whether this chunk of text was originally a tab character.
     pub is_tab: bool,
+    /// Whether this chunk of text was originally a tab character.
+    pub is_inlay: bool,
     /// An optional recipe for how the chunk should be presented.
     pub renderer: Option<ChunkRenderer>,
 }
@@ -1422,6 +1426,8 @@ impl<'a> Iterator for FoldChunks<'a> {
                 diagnostic_severity: chunk.diagnostic_severity,
                 is_unnecessary: chunk.is_unnecessary,
                 is_tab: chunk.is_tab,
+                is_inlay: chunk.is_inlay,
+                underline: chunk.underline,
                 renderer: None,
             });
         }

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

@@ -1,5 +1,6 @@
 use crate::{HighlightStyles, InlayId};
 use collections::BTreeSet;
+use gpui::{Hsla, Rgba};
 use language::{Chunk, Edit, Point, TextSummary};
 use multi_buffer::{
     Anchor, MultiBufferRow, MultiBufferRows, MultiBufferSnapshot, RowInfo, ToOffset,
@@ -39,6 +40,7 @@ pub struct Inlay {
     pub id: InlayId,
     pub position: Anchor,
     pub text: text::Rope,
+    color: Option<Hsla>,
 }
 
 impl Inlay {
@@ -54,6 +56,26 @@ impl Inlay {
             id: InlayId::Hint(id),
             position,
             text: text.into(),
+            color: None,
+        }
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn mock_hint(id: usize, position: Anchor, text: impl Into<Rope>) -> Self {
+        Self {
+            id: InlayId::Hint(id),
+            position,
+            text: text.into(),
+            color: None,
+        }
+    }
+
+    pub fn color(id: usize, position: Anchor, color: Rgba) -> Self {
+        Self {
+            id: InlayId::Color(id),
+            position,
+            text: Rope::from("◼"),
+            color: Some(Hsla::from(color)),
         }
     }
 
@@ -62,16 +84,23 @@ impl Inlay {
             id: InlayId::InlineCompletion(id),
             position,
             text: text.into(),
+            color: None,
         }
     }
 
-    pub fn debugger_hint<T: Into<Rope>>(id: usize, position: Anchor, text: T) -> Self {
+    pub fn debugger<T: Into<Rope>>(id: usize, position: Anchor, text: T) -> Self {
         Self {
             id: InlayId::DebuggerValue(id),
             position,
             text: text.into(),
+            color: None,
         }
     }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn get_color(&self) -> Option<Hsla> {
+        self.color
+    }
 }
 
 impl sum_tree::Item for Transform {
@@ -296,6 +325,14 @@ impl<'a> Iterator for InlayChunks<'a> {
                     }
                     InlayId::Hint(_) => self.highlight_styles.inlay_hint,
                     InlayId::DebuggerValue(_) => self.highlight_styles.inlay_hint,
+                    InlayId::Color(_) => match inlay.color {
+                        Some(color) => {
+                            let mut style = self.highlight_styles.inlay_hint.unwrap_or_default();
+                            style.color = Some(color);
+                            Some(style)
+                        }
+                        None => self.highlight_styles.inlay_hint,
+                    },
                 };
                 let next_inlay_highlight_endpoint;
                 let offset_in_inlay = self.output_offset - self.transforms.start().0;
@@ -336,6 +373,7 @@ impl<'a> Iterator for InlayChunks<'a> {
                 Chunk {
                     text: chunk,
                     highlight_style,
+                    is_inlay: true,
                     ..Default::default()
                 }
             }
@@ -633,24 +671,24 @@ impl InlayMap {
                     .take(len)
                     .collect::<String>();
 
-                let inlay_id = if i % 2 == 0 {
-                    InlayId::Hint(post_inc(next_inlay_id))
+                let next_inlay = if i % 2 == 0 {
+                    Inlay::mock_hint(
+                        post_inc(next_inlay_id),
+                        snapshot.buffer.anchor_at(position, bias),
+                        text.clone(),
+                    )
                 } else {
-                    InlayId::InlineCompletion(post_inc(next_inlay_id))
+                    Inlay::inline_completion(
+                        post_inc(next_inlay_id),
+                        snapshot.buffer.anchor_at(position, bias),
+                        text.clone(),
+                    )
                 };
+                let inlay_id = next_inlay.id;
                 log::info!(
-                    "creating inlay {:?} at buffer offset {} with bias {:?} and text {:?}",
-                    inlay_id,
-                    position,
-                    bias,
-                    text
+                    "creating inlay {inlay_id:?} at buffer offset {position} with bias {bias:?} and text {text:?}"
                 );
-
-                to_insert.push(Inlay {
-                    id: inlay_id,
-                    position: snapshot.buffer.anchor_at(position, bias),
-                    text: text.into(),
-                });
+                to_insert.push(next_inlay);
             } else {
                 to_remove.push(
                     self.inlays
@@ -1077,7 +1115,7 @@ mod tests {
     use super::*;
     use crate::{
         InlayId, MultiBuffer,
-        display_map::{InlayHighlights, TextHighlights},
+        display_map::{HighlightKey, InlayHighlights, TextHighlights},
         hover_links::InlayHighlight,
     };
     use gpui::{App, HighlightStyle};
@@ -1182,11 +1220,11 @@ mod tests {
 
         let (inlay_snapshot, _) = inlay_map.splice(
             &[],
-            vec![Inlay {
-                id: InlayId::Hint(post_inc(&mut next_inlay_id)),
-                position: buffer.read(cx).snapshot(cx).anchor_after(3),
-                text: "|123|".into(),
-            }],
+            vec![Inlay::mock_hint(
+                post_inc(&mut next_inlay_id),
+                buffer.read(cx).snapshot(cx).anchor_after(3),
+                "|123|",
+            )],
         );
         assert_eq!(inlay_snapshot.text(), "abc|123|defghi");
         assert_eq!(
@@ -1259,16 +1297,16 @@ mod tests {
         let (inlay_snapshot, _) = inlay_map.splice(
             &[],
             vec![
-                Inlay {
-                    id: InlayId::Hint(post_inc(&mut next_inlay_id)),
-                    position: buffer.read(cx).snapshot(cx).anchor_before(3),
-                    text: "|123|".into(),
-                },
-                Inlay {
-                    id: InlayId::InlineCompletion(post_inc(&mut next_inlay_id)),
-                    position: buffer.read(cx).snapshot(cx).anchor_after(3),
-                    text: "|456|".into(),
-                },
+                Inlay::mock_hint(
+                    post_inc(&mut next_inlay_id),
+                    buffer.read(cx).snapshot(cx).anchor_before(3),
+                    "|123|",
+                ),
+                Inlay::inline_completion(
+                    post_inc(&mut next_inlay_id),
+                    buffer.read(cx).snapshot(cx).anchor_after(3),
+                    "|456|",
+                ),
             ],
         );
         assert_eq!(inlay_snapshot.text(), "abx|123||456|yDzefghi");
@@ -1474,21 +1512,21 @@ mod tests {
         let (inlay_snapshot, _) = inlay_map.splice(
             &[],
             vec![
-                Inlay {
-                    id: InlayId::Hint(post_inc(&mut next_inlay_id)),
-                    position: buffer.read(cx).snapshot(cx).anchor_before(0),
-                    text: "|123|\n".into(),
-                },
-                Inlay {
-                    id: InlayId::Hint(post_inc(&mut next_inlay_id)),
-                    position: buffer.read(cx).snapshot(cx).anchor_before(4),
-                    text: "|456|".into(),
-                },
-                Inlay {
-                    id: InlayId::InlineCompletion(post_inc(&mut next_inlay_id)),
-                    position: buffer.read(cx).snapshot(cx).anchor_before(7),
-                    text: "\n|567|\n".into(),
-                },
+                Inlay::mock_hint(
+                    post_inc(&mut next_inlay_id),
+                    buffer.read(cx).snapshot(cx).anchor_before(0),
+                    "|123|\n",
+                ),
+                Inlay::mock_hint(
+                    post_inc(&mut next_inlay_id),
+                    buffer.read(cx).snapshot(cx).anchor_before(4),
+                    "|456|",
+                ),
+                Inlay::inline_completion(
+                    post_inc(&mut next_inlay_id),
+                    buffer.read(cx).snapshot(cx).anchor_before(7),
+                    "\n|567|\n",
+                ),
             ],
         );
         assert_eq!(inlay_snapshot.text(), "|123|\nabc\n|456|def\n|567|\n\nghi");
@@ -1591,7 +1629,7 @@ mod tests {
             text_highlight_ranges.sort_by_key(|range| (range.start, Reverse(range.end)));
             log::info!("highlighting text ranges {text_highlight_ranges:?}");
             text_highlights.insert(
-                TypeId::of::<()>(),
+                HighlightKey::Type(TypeId::of::<()>()),
                 Arc::new((
                     HighlightStyle::default(),
                     text_highlight_ranges

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

@@ -411,7 +411,7 @@ impl WrapSnapshot {
         }
 
         let mut tab_edits_iter = tab_edits.iter().peekable();
-        let mut row_edits = Vec::new();
+        let mut row_edits = Vec::with_capacity(tab_edits.len());
         while let Some(edit) = tab_edits_iter.next() {
             let mut row_edit = RowEdit {
                 old_rows: edit.old.start.row()..edit.old.end.row() + 1,
@@ -561,7 +561,7 @@ impl WrapSnapshot {
     }
 
     fn compute_edits(&self, tab_edits: &[TabEdit], new_snapshot: &WrapSnapshot) -> Patch<u32> {
-        let mut wrap_edits = Vec::new();
+        let mut wrap_edits = Vec::with_capacity(tab_edits.len());
         let mut old_cursor = self.transforms.cursor::<TransformSummary>(&());
         let mut new_cursor = new_snapshot.transforms.cursor::<TransformSummary>(&());
         for mut tab_edit in tab_edits.iter().cloned() {
@@ -726,7 +726,7 @@ impl WrapSnapshot {
         self.transforms.summary().output.longest_row
     }
 
-    pub fn row_infos(&self, start_row: u32) -> WrapRows {
+    pub fn row_infos(&self, start_row: u32) -> WrapRows<'_> {
         let mut transforms = self.transforms.cursor::<(WrapPoint, TabPoint)>(&());
         transforms.seek(&WrapPoint::new(start_row, 0), Bias::Left, &());
         let mut input_row = transforms.start().1.row();
@@ -933,7 +933,7 @@ impl<'a> Iterator for WrapChunks<'a> {
             self.transforms.next(&());
             return Some(Chunk {
                 text: &display_text[start_ix..end_ix],
-                ..self.input_chunk.clone()
+                ..Default::default()
             });
         }
 

crates/editor/src/editor.rs 🔗

@@ -15,7 +15,7 @@
 pub mod actions;
 mod blink_manager;
 mod clangd_ext;
-mod code_context_menus;
+pub mod code_context_menus;
 pub mod display_map;
 mod editor_settings;
 mod editor_settings_controls;
@@ -30,6 +30,7 @@ mod inlay_hint_cache;
 pub mod items;
 mod jsx_tag_auto_close;
 mod linked_editing_ranges;
+mod lsp_colors;
 mod lsp_ext;
 mod mouse_context_menu;
 pub mod movement;
@@ -60,11 +61,12 @@ use client::{Collaborator, ParticipantIndex};
 use clock::{AGENT_REPLICA_ID, ReplicaId};
 use collections::{BTreeMap, HashMap, HashSet, VecDeque};
 use convert_case::{Case, Casing};
+use dap::TelemetrySpawnLocation;
 use display_map::*;
 pub use display_map::{ChunkRenderer, ChunkRendererContext, DisplayPoint, FoldPlaceholder};
 pub use editor_settings::{
-    CurrentLineHighlight, EditorSettings, HideMouseMode, ScrollBeyondLastLine, SearchSettings,
-    ShowScrollbar,
+    CurrentLineHighlight, DocumentColorsRenderMode, EditorSettings, HideMouseMode,
+    ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowScrollbar,
 };
 use editor_settings::{GoToDefinitionFallback, Minimap as MinimapSettings};
 pub use editor_settings_controls::*;
@@ -72,12 +74,13 @@ use element::{AcceptEditPredictionBinding, LineWithInvisibles, PositionMap, layo
 pub use element::{
     CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition,
 };
-use feature_flags::{DebuggerFeatureFlag, FeatureFlagAppExt};
 use futures::{
-    FutureExt,
+    FutureExt, StreamExt as _,
     future::{self, Shared, join},
+    stream::FuturesUnordered,
 };
-use fuzzy::StringMatchCandidate;
+use fuzzy::{StringMatch, StringMatchCandidate};
+use lsp_colors::LspColorData;
 
 use ::git::blame::BlameEntry;
 use ::git::{Restore, blame::ParsedCommitMessage};
@@ -94,7 +97,7 @@ use gpui::{
     MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, ScrollHandle,
     SharedString, Size, Stateful, Styled, Subscription, Task, TextStyle, TextStyleRefinement,
     UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window,
-    div, impl_actions, point, prelude::*, pulsating_between, px, relative, size,
+    div, point, prelude::*, pulsating_between, px, relative, size,
 };
 use highlight_matching_bracket::refresh_matching_bracket_highlights;
 use hover_links::{HoverLink, HoveredLinkState, InlayHighlight, find_file};
@@ -108,9 +111,9 @@ pub use items::MAX_TAB_TITLE_LEN;
 use itertools::Itertools;
 use language::{
     AutoindentMode, BracketMatch, BracketPair, Buffer, Capability, CharKind, CodeLabel,
-    CursorShape, DiagnosticEntry, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText,
-    IndentKind, IndentSize, Language, OffsetRangeExt, Point, Selection, SelectionGoal, TextObject,
-    TransactionId, TreeSitterOptions, WordsQuery,
+    CursorShape, DiagnosticEntry, DiffOptions, DocumentationConfig, EditPredictionsMode,
+    EditPreview, HighlightedText, IndentKind, IndentSize, Language, OffsetRangeExt, Point,
+    Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery,
     language_settings::{
         self, InlayHintSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode,
         all_language_settings, language_settings,
@@ -123,13 +126,15 @@ use markdown::Markdown;
 use mouse_context_menu::MouseContextMenu;
 use persistence::DB;
 use project::{
-    ProjectPath,
+    BreakpointWithPosition, CompletionResponse, ProjectPath,
     debugger::{
         breakpoint_store::{
-            BreakpointEditAction, BreakpointState, BreakpointStore, BreakpointStoreEvent,
+            BreakpointEditAction, BreakpointSessionState, BreakpointState, BreakpointStore,
+            BreakpointStoreEvent,
         },
         session::{Session, SessionEvent},
     },
+    git_store::{GitStoreEvent, RepositoryEvent},
     project_settings::DiagnosticSeverity,
 };
 
@@ -137,7 +142,6 @@ pub use git::blame::BlameRenderer;
 pub use proposed_changes_editor::{
     ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
 };
-use smallvec::smallvec;
 use std::{cell::OnceCell, iter::Peekable, ops::Not};
 use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
 
@@ -175,7 +179,7 @@ use selections_collection::{
 };
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsLocation, SettingsStore, update_settings_file};
-use smallvec::SmallVec;
+use smallvec::{SmallVec, smallvec};
 use snippet::Snippet;
 use std::sync::Arc;
 use std::{
@@ -194,25 +198,31 @@ pub use sum_tree::Bias;
 use sum_tree::TreeMap;
 use text::{BufferId, FromAnchor, OffsetUtf16, Rope};
 use theme::{
-    ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, ThemeColors, ThemeSettings,
+    ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, ThemeSettings,
     observe_buffer_font_size_adjustment,
 };
 use ui::{
-    ButtonSize, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName, IconSize, Key,
-    Tooltip, h_flex, prelude::*,
+    ButtonSize, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName, IconSize,
+    Indicator, Key, Tooltip, h_flex, prelude::*,
 };
 use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc};
 use workspace::{
     CollaboratorId, Item as WorkspaceItem, ItemId, ItemNavHistory, OpenInTerminal, OpenTerminal,
     RestoreOnStartupBehavior, SERIALIZATION_THROTTLE_TIME, SplitDirection, TabBarSettings, Toast,
     ViewId, Workspace, WorkspaceId, WorkspaceSettings,
-    item::{ItemHandle, PreviewTabsSettings},
+    item::{ItemHandle, PreviewTabsSettings, SaveOptions},
     notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt},
     searchable::SearchEvent,
 };
 
-use crate::hover_links::{find_url, find_url_from_range};
-use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState};
+use crate::{
+    code_context_menus::CompletionsMenuSource,
+    hover_links::{find_url, find_url_from_range},
+};
+use crate::{
+    editor_settings::MultiCursorModifier,
+    signature_help::{SignatureHelpHiddenBy, SignatureHelpState},
+};
 
 pub const FILE_HEADER_HEIGHT: u32 = 2;
 pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u32 = 1;
@@ -232,7 +242,6 @@ pub(crate) const SCROLL_CENTER_TOP_BOTTOM_DEBOUNCE_TIMEOUT: Duration = Duration:
 
 pub(crate) const EDIT_PREDICTION_KEY_CONTEXT: &str = "edit_prediction";
 pub(crate) const EDIT_PREDICTION_CONFLICT_KEY_CONTEXT: &str = "edit_prediction_conflict";
-pub(crate) const MIN_LINE_NUMBER_DIGITS: u32 = 4;
 pub(crate) const MINIMAP_FONT_SIZE: AbsoluteLength = AbsoluteLength::Pixels(px(2.));
 
 pub type RenderDiffHunkControlsFn = Arc<
@@ -248,14 +257,6 @@ pub type RenderDiffHunkControlsFn = Arc<
     ) -> AnyElement,
 >;
 
-const COLUMNAR_SELECTION_MODIFIERS: Modifiers = Modifiers {
-    alt: true,
-    shift: true,
-    control: false,
-    platform: false,
-    function: false,
-};
-
 struct InlineValueCache {
     enabled: bool,
     inlays: Vec<InlayId>,
@@ -275,24 +276,29 @@ impl InlineValueCache {
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub enum InlayId {
     InlineCompletion(usize),
-    Hint(usize),
     DebuggerValue(usize),
+    // LSP
+    Hint(usize),
+    Color(usize),
 }
 
 impl InlayId {
     fn id(&self) -> usize {
         match self {
             Self::InlineCompletion(id) => *id,
-            Self::Hint(id) => *id,
             Self::DebuggerValue(id) => *id,
+            Self::Hint(id) => *id,
+            Self::Color(id) => *id,
         }
     }
 }
 
 pub enum ActiveDebugLine {}
+pub enum DebugStackFrameLine {}
 enum DocumentHighlightRead {}
 enum DocumentHighlightWrite {}
 enum InputComposition {}
+pub enum PendingInput {}
 enum SelectedTextHighlight {}
 
 pub enum ConflictsOuter {}
@@ -446,6 +452,7 @@ pub enum SelectPhase {
     BeginColumnar {
         position: DisplayPoint,
         reset: bool,
+        mode: ColumnarMode,
         goal_column: u32,
     },
     Extend {
@@ -460,6 +467,12 @@ pub enum SelectPhase {
     End,
 }
 
+#[derive(Clone, Debug, PartialEq)]
+pub enum ColumnarMode {
+    FromMouse,
+    FromSelection,
+}
+
 #[derive(Clone, Debug)]
 pub enum SelectMode {
     Character,
@@ -474,7 +487,8 @@ pub enum EditorMode {
         auto_width: bool,
     },
     AutoHeight {
-        max_lines: usize,
+        min_lines: usize,
+        max_lines: Option<usize>,
     },
     Full {
         /// When set to `true`, the editor will scale its UI elements with the buffer font size.
@@ -498,10 +512,17 @@ impl EditorMode {
         }
     }
 
+    #[inline]
     pub fn is_full(&self) -> bool {
         matches!(self, Self::Full { .. })
     }
 
+    #[inline]
+    pub fn is_single_line(&self) -> bool {
+        matches!(self, Self::SingleLine { .. })
+    }
+
+    #[inline]
     fn is_minimap(&self) -> bool {
         matches!(self, Self::Minimap { .. })
     }
@@ -695,8 +716,8 @@ impl EditorActionId {
 // type GetFieldEditorTheme = dyn Fn(&theme::Theme) -> theme::FieldEditor;
 // type OverrideTextStyle = dyn Fn(&EditorStyle) -> Option<HighlightStyle>;
 
-type BackgroundHighlight = (fn(&ThemeColors) -> Hsla, Arc<[Range<Anchor>]>);
-type GutterHighlight = (fn(&App) -> Hsla, Arc<[Range<Anchor>]>);
+type BackgroundHighlight = (fn(&Theme) -> Hsla, Arc<[Range<Anchor>]>);
+type GutterHighlight = (fn(&App) -> Hsla, Vec<Range<Anchor>>);
 
 #[derive(Default)]
 struct ScrollbarMarkerState {
@@ -715,18 +736,39 @@ impl ScrollbarMarkerState {
 #[derive(Clone, Copy, PartialEq, Eq)]
 pub enum MinimapVisibility {
     Disabled,
-    Enabled(bool),
+    Enabled {
+        /// The configuration currently present in the users settings.
+        setting_configuration: bool,
+        /// Whether to override the currently set visibility from the users setting.
+        toggle_override: bool,
+    },
 }
 
 impl MinimapVisibility {
     fn for_mode(mode: &EditorMode, cx: &App) -> Self {
         if mode.is_full() {
-            Self::Enabled(EditorSettings::get_global(cx).minimap.minimap_enabled())
+            Self::Enabled {
+                setting_configuration: EditorSettings::get_global(cx).minimap.minimap_enabled(),
+                toggle_override: false,
+            }
         } else {
             Self::Disabled
         }
     }
 
+    fn hidden(&self) -> Self {
+        match *self {
+            Self::Enabled {
+                setting_configuration,
+                ..
+            } => Self::Enabled {
+                setting_configuration,
+                toggle_override: setting_configuration,
+            },
+            Self::Disabled => Self::Disabled,
+        }
+    }
+
     fn disabled(&self) -> bool {
         match *self {
             Self::Disabled => true,
@@ -734,16 +776,35 @@ impl MinimapVisibility {
         }
     }
 
+    fn settings_visibility(&self) -> bool {
+        match *self {
+            Self::Enabled {
+                setting_configuration,
+                ..
+            } => setting_configuration,
+            _ => false,
+        }
+    }
+
     fn visible(&self) -> bool {
         match *self {
-            Self::Enabled(visible) => visible,
+            Self::Enabled {
+                setting_configuration,
+                toggle_override,
+            } => setting_configuration ^ toggle_override,
             _ => false,
         }
     }
 
     fn toggle_visibility(&self) -> Self {
         match *self {
-            Self::Enabled(visible) => Self::Enabled(!visible),
+            Self::Enabled {
+                toggle_override,
+                setting_configuration,
+            } => Self::Enabled {
+                setting_configuration,
+                toggle_override: !toggle_override,
+            },
             Self::Disabled => Self::Disabled,
         }
     }
@@ -775,7 +836,7 @@ impl RunnableTasks {
 }
 
 #[derive(Clone)]
-struct ResolvedTasks {
+pub struct ResolvedTasks {
     templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>,
     position: Anchor,
 }
@@ -858,15 +919,41 @@ struct InlineBlamePopoverState {
 
 struct InlineBlamePopover {
     position: gpui::Point<Pixels>,
-    show_task: Option<Task<()>>,
     hide_task: Option<Task<()>>,
     popover_bounds: Option<Bounds<Pixels>>,
     popover_state: InlineBlamePopoverState,
 }
 
+enum SelectionDragState {
+    /// State when no drag related activity is detected.
+    None,
+    /// State when the mouse is down on a selection that is about to be dragged.
+    ReadyToDrag {
+        selection: Selection<Anchor>,
+        click_position: gpui::Point<Pixels>,
+        mouse_down_time: Instant,
+    },
+    /// State when the mouse is dragging the selection in the editor.
+    Dragging {
+        selection: Selection<Anchor>,
+        drop_cursor: Selection<Anchor>,
+        hide_drop_cursor: bool,
+    },
+}
+
+enum ColumnarSelectionState {
+    FromMouse {
+        selection_tail: Anchor,
+        display_point: Option<DisplayPoint>,
+    },
+    FromSelection {
+        selection_tail: Anchor,
+    },
+}
+
 /// Represents a breakpoint indicator that shows up when hovering over lines in the gutter that don't have
 /// a breakpoint on them.
-#[derive(Clone, Copy, Debug)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 struct PhantomBreakpointIndicator {
     display_row: DisplayRow,
     /// There's a small debounce between hovering over the line and showing the indicator.
@@ -874,6 +961,7 @@ struct PhantomBreakpointIndicator {
     is_active: bool,
     collides_with_existing_breakpoint: bool,
 }
+
 /// Zed's primary implementation of text input, allowing users to edit a [`MultiBuffer`].
 ///
 /// See the [module level documentation](self) for more information.
@@ -890,11 +978,13 @@ pub struct Editor {
     /// When inline assist editors are linked, they all render cursors because
     /// typing enters text into each of them, even the ones that aren't focused.
     pub(crate) show_cursor_when_unfocused: bool,
-    columnar_selection_tail: Option<Anchor>,
+    columnar_selection_state: Option<ColumnarSelectionState>,
     add_selections_state: Option<AddSelectionsState>,
     select_next_state: Option<SelectNextState>,
     select_prev_state: Option<SelectNextState>,
     selection_history: SelectionHistory,
+    defer_selection_effects: bool,
+    deferred_selection_effects_state: Option<DeferredSelectionEffectsState>,
     autoclose_regions: Vec<AutocloseRegion>,
     snippet_stack: InvalidationStack<SnippetState>,
     select_syntax_node_history: SelectSyntaxNodeHistory,
@@ -904,6 +994,7 @@ pub struct Editor {
     show_inline_diagnostics: bool,
     inline_diagnostics_update: Task<()>,
     inline_diagnostics_enabled: bool,
+    diagnostics_enabled: bool,
     inline_diagnostics: Vec<(Anchor, InlineDiagnostic)>,
     soft_wrap_mode_override: Option<language_settings::SoftWrap>,
     hard_wrap: Option<usize>,
@@ -911,7 +1002,7 @@ pub struct Editor {
     // TODO: make this a access method
     pub project: Option<Entity<Project>>,
     semantics_provider: Option<Rc<dyn SemanticsProvider>>,
-    completion_provider: Option<Box<dyn CompletionProvider>>,
+    completion_provider: Option<Rc<dyn CompletionProvider>>,
     collaboration_hub: Option<Box<dyn CollaborationHub>>,
     blink_manager: Entity<BlinkManager>,
     show_cursor_names: bool,
@@ -920,8 +1011,9 @@ pub struct Editor {
     mode: EditorMode,
     show_breadcrumbs: bool,
     show_gutter: bool,
-    show_scrollbars: bool,
+    show_scrollbars: ScrollbarAxes,
     minimap_visibility: MinimapVisibility,
+    offset_content: bool,
     disable_expand_excerpt_buttons: bool,
     show_line_numbers: Option<bool>,
     use_relative_line_numbers: Option<bool>,
@@ -934,7 +1026,7 @@ pub struct Editor {
     placeholder_text: Option<Arc<str>>,
     highlight_order: usize,
     highlighted_rows: HashMap<TypeId, Vec<RowHighlight>>,
-    background_highlights: TreeMap<TypeId, BackgroundHighlight>,
+    background_highlights: TreeMap<HighlightKey, BackgroundHighlight>,
     gutter_highlights: TreeMap<TypeId, GutterHighlight>,
     scrollbar_marker_state: ScrollbarMarkerState,
     active_indent_guides_state: ActiveIndentGuidesState,
@@ -942,8 +1034,9 @@ pub struct Editor {
     context_menu: RefCell<Option<CodeContextMenu>>,
     context_menu_options: Option<ContextMenuOptions>,
     mouse_context_menu: Option<MouseContextMenu>,
-    completion_tasks: Vec<(CompletionId, Task<Option<()>>)>,
+    completion_tasks: Vec<(CompletionId, Task<()>)>,
     inline_blame_popover: Option<InlineBlamePopover>,
+    inline_blame_popover_show_task: Option<Task<()>>,
     signature_help_state: SignatureHelpState,
     auto_signature_help: Option<bool>,
     find_all_references_task_sources: Vec<Anchor>,
@@ -991,8 +1084,9 @@ pub struct Editor {
     style: Option<EditorStyle>,
     text_style_refinement: Option<TextStyleRefinement>,
     next_editor_action_id: EditorActionId,
-    editor_actions:
-        Rc<RefCell<BTreeMap<EditorActionId, Box<dyn Fn(&mut Window, &mut Context<Self>)>>>>,
+    editor_actions: Rc<
+        RefCell<BTreeMap<EditorActionId, Box<dyn Fn(&Editor, &mut Window, &mut Context<Self>)>>>,
+    >,
     use_autoclose: bool,
     use_auto_surround: bool,
     auto_replace_emoji_shortcode: bool,
@@ -1024,6 +1118,8 @@ pub struct Editor {
     tasks_update_task: Option<Task<()>>,
     breakpoint_store: Option<Entity<BreakpointStore>>,
     gutter_breakpoint_indicator: (Option<PhantomBreakpointIndicator>, Option<Task<()>>),
+    hovered_diff_hunk_row: Option<DisplayRow>,
+    pull_diagnostics_task: Task<()>,
     in_project_search: bool,
     previous_search_ranges: Option<Arc<[Range<Anchor>]>>,
     breadcrumb_header: Option<String>,
@@ -1044,6 +1140,11 @@ pub struct Editor {
     hide_mouse_mode: HideMouseMode,
     pub change_list: ChangeList,
     inline_value_cache: InlineValueCache,
+    selection_drag_state: SelectionDragState,
+    drag_and_drop_selection_enabled: bool,
+    next_color_inlay_id: usize,
+    colors: Option<LspColorData>,
+    folding_newlines: Task<()>,
 }
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
@@ -1070,6 +1171,7 @@ pub struct EditorSnapshot {
     show_gutter: bool,
     show_line_numbers: Option<bool>,
     show_git_diff_gutter: Option<bool>,
+    show_code_actions: Option<bool>,
     show_runnables: Option<bool>,
     show_breakpoints: Option<bool>,
     git_blame_gutter_max_author_length: Option<usize>,
@@ -1115,6 +1217,12 @@ impl GutterDimensions {
     }
 }
 
+struct CharacterDimensions {
+    em_width: Pixels,
+    em_advance: Pixels,
+    line_height: Pixels,
+}
+
 #[derive(Debug)]
 pub struct RemoteSelection {
     pub replica_id: ReplicaId,
@@ -1134,10 +1242,12 @@ struct SelectionHistoryEntry {
     add_selections_state: Option<AddSelectionsState>,
 }
 
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
 enum SelectionHistoryMode {
     Normal,
     Undoing,
     Redoing,
+    Skipping,
 }
 
 #[derive(Clone, PartialEq, Eq, Hash)]
@@ -1152,6 +1262,72 @@ impl Default for SelectionHistoryMode {
     }
 }
 
+#[derive(Debug)]
+/// SelectionEffects controls the side-effects of updating the selection.
+///
+/// The default behaviour does "what you mostly want":
+/// - it pushes to the nav history if the cursor moved by >10 lines
+/// - it re-triggers completion requests
+/// - it scrolls to fit
+///
+/// You might want to modify these behaviours. For example when doing a "jump"
+/// like go to definition, we always want to add to nav history; but when scrolling
+/// in vim mode we never do.
+///
+/// Similarly, you might want to disable scrolling if you don't want the viewport to
+/// move.
+pub struct SelectionEffects {
+    nav_history: Option<bool>,
+    completions: bool,
+    scroll: Option<Autoscroll>,
+}
+
+impl Default for SelectionEffects {
+    fn default() -> Self {
+        Self {
+            nav_history: None,
+            completions: true,
+            scroll: Some(Autoscroll::fit()),
+        }
+    }
+}
+impl SelectionEffects {
+    pub fn scroll(scroll: Autoscroll) -> Self {
+        Self {
+            scroll: Some(scroll),
+            ..Default::default()
+        }
+    }
+
+    pub fn no_scroll() -> Self {
+        Self {
+            scroll: None,
+            ..Default::default()
+        }
+    }
+
+    pub fn completions(self, completions: bool) -> Self {
+        Self {
+            completions,
+            ..self
+        }
+    }
+
+    pub fn nav_history(self, nav_history: bool) -> Self {
+        Self {
+            nav_history: Some(nav_history),
+            ..self
+        }
+    }
+}
+
+struct DeferredSelectionEffectsState {
+    changed: bool,
+    effects: SelectionEffects,
+    old_cursor_position: Anchor,
+    history_entry: SelectionHistoryEntry,
+}
+
 #[derive(Default)]
 struct SelectionHistory {
     #[allow(clippy::type_complexity)]
@@ -1163,11 +1339,19 @@ struct SelectionHistory {
 }
 
 impl SelectionHistory {
+    #[track_caller]
     fn insert_transaction(
         &mut self,
         transaction_id: TransactionId,
         selections: Arc<[Selection<Anchor>]>,
     ) {
+        if selections.is_empty() {
+            log::error!(
+                "SelectionHistory::insert_transaction called with empty selections. Caller: {}",
+                std::panic::Location::caller()
+            );
+            return;
+        }
         self.selections_by_transaction
             .insert(transaction_id, (selections, None));
     }
@@ -1197,6 +1381,7 @@ impl SelectionHistory {
                 }
                 SelectionHistoryMode::Undoing => self.push_redo(entry),
                 SelectionHistoryMode::Redoing => self.push_undo(entry),
+                SelectionHistoryMode::Skipping => {}
             }
         }
     }
@@ -1253,6 +1438,11 @@ struct RowHighlight {
 
 #[derive(Clone, Debug)]
 struct AddSelectionsState {
+    groups: Vec<AddSelectionsGroup>,
+}
+
+#[derive(Clone, Debug)]
+struct AddSelectionsGroup {
     above: bool,
     stack: Vec<usize>,
 }
@@ -1311,7 +1501,7 @@ pub struct ActiveDiagnosticGroup {
 }
 
 #[derive(Debug, PartialEq, Eq)]
-#[allow(clippy::large_enum_variant)]
+
 pub(crate) enum ActiveDiagnostic {
     None,
     All,
@@ -1406,7 +1596,7 @@ impl InlayHintRefreshReason {
 }
 
 pub enum FormatTarget {
-    Buffers,
+    Buffers(HashSet<Entity<Buffer>>),
     Ranges(Vec<Range<MultiBufferPoint>>),
 }
 
@@ -1471,11 +1661,40 @@ impl Editor {
         )
     }
 
-    pub fn auto_height(max_lines: usize, window: &mut Window, cx: &mut Context<Self>) -> Self {
+    pub fn auto_height(
+        min_lines: usize,
+        max_lines: usize,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let buffer = cx.new(|cx| Buffer::local("", cx));
+        let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+        Self::new(
+            EditorMode::AutoHeight {
+                min_lines,
+                max_lines: Some(max_lines),
+            },
+            buffer,
+            None,
+            window,
+            cx,
+        )
+    }
+
+    /// Creates a new auto-height editor with a minimum number of lines but no maximum.
+    /// The editor grows as tall as needed to fit its content.
+    pub fn auto_height_unbounded(
+        min_lines: usize,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
         let buffer = cx.new(|cx| Buffer::local("", cx));
         let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
         Self::new(
-            EditorMode::AutoHeight { max_lines },
+            EditorMode::AutoHeight {
+                min_lines,
+                max_lines: None,
+            },
             buffer,
             None,
             window,
@@ -1627,6 +1846,14 @@ impl Editor {
                             editor
                                 .refresh_inlay_hints(InlayHintRefreshReason::RefreshRequested, cx);
                         }
+                        project::Event::LanguageServerAdded(..)
+                        | project::Event::LanguageServerRemoved(..) => {
+                            if editor.tasks_update_task.is_none() {
+                                editor.tasks_update_task =
+                                    Some(editor.refresh_runnables(window, cx));
+                            }
+                            editor.update_lsp_data(true, None, window, cx);
+                        }
                         project::Event::SnippetEdit(id, snippet_edits) => {
                             if let Some(buffer) = editor.buffer.read(cx).buffer(*id) {
                                 let focus_handle = editor.focus_handle(cx);
@@ -1684,6 +1911,31 @@ impl Editor {
                         _ => {}
                     },
                 ));
+                let git_store = project.read(cx).git_store().clone();
+                let project = project.clone();
+                project_subscriptions.push(cx.subscribe(&git_store, move |this, _, event, cx| {
+                    match event {
+                        GitStoreEvent::RepositoryUpdated(
+                            _,
+                            RepositoryEvent::Updated {
+                                new_instance: true, ..
+                            },
+                            _,
+                        ) => {
+                            this.load_diff_task = Some(
+                                update_uncommitted_diff_for_buffer(
+                                    cx.entity(),
+                                    &project,
+                                    this.buffer.read(cx).all_buffers(),
+                                    this.buffer.clone(),
+                                    cx,
+                                )
+                                .shared(),
+                            );
+                        }
+                        _ => {}
+                    }
+                }));
             }
         }
 
@@ -1700,6 +1952,8 @@ impl Editor {
             .detach();
         cx.on_blur(&focus_handle, window, Self::handle_blur)
             .detach();
+        cx.observe_pending_input(window, Self::observe_pending_input)
+            .detach();
 
         let show_indent_guides = if matches!(mode, EditorMode::SingleLine { .. }) {
             Some(false)
@@ -1728,7 +1982,7 @@ impl Editor {
             code_action_providers.push(Rc::new(project) as Rc<_>);
         }
 
-        let mut this = Self {
+        let mut editor = Self {
             focus_handle,
             show_cursor_when_unfocused: false,
             last_focused_descendant: None,
@@ -1736,11 +1990,13 @@ impl Editor {
             display_map: display_map.clone(),
             selections,
             scroll_manager: ScrollManager::new(cx),
-            columnar_selection_tail: None,
+            columnar_selection_state: None,
             add_selections_state: None,
             select_next_state: None,
             select_prev_state: None,
             selection_history: SelectionHistory::default(),
+            defer_selection_effects: false,
+            deferred_selection_effects_state: None,
             autoclose_regions: Vec::new(),
             snippet_stack: InvalidationStack::default(),
             select_syntax_node_history: SelectSyntaxNodeHistory::default(),
@@ -1752,14 +2008,18 @@ impl Editor {
             soft_wrap_mode_override,
             diagnostics_max_severity,
             hard_wrap: None,
-            completion_provider: project.clone().map(|project| Box::new(project) as _),
+            completion_provider: project.clone().map(|project| Rc::new(project) as _),
             semantics_provider: project.clone().map(|project| Rc::new(project) as _),
             collaboration_hub: project.clone().map(|project| Box::new(project) as _),
             project,
             blink_manager: blink_manager.clone(),
             show_local_selections: true,
-            show_scrollbars: full_mode,
+            show_scrollbars: ScrollbarAxes {
+                horizontal: full_mode,
+                vertical: full_mode,
+            },
             minimap_visibility: MinimapVisibility::for_mode(&mode, cx),
+            offset_content: !matches!(mode, EditorMode::SingleLine { .. }),
             show_breadcrumbs: EditorSettings::get_global(cx).toolbar.breadcrumbs,
             show_gutter: mode.is_full(),
             show_line_numbers: None,
@@ -1784,6 +2044,7 @@ impl Editor {
             mouse_context_menu: None,
             completion_tasks: Vec::new(),
             inline_blame_popover: None,
+            inline_blame_popover_show_task: None,
             signature_help_state: SignatureHelpState::default(),
             auto_signature_help: None,
             find_all_references_task_sources: Vec::new(),
@@ -1824,6 +2085,7 @@ impl Editor {
                 released_too_fast: false,
             },
             inline_diagnostics_enabled: mode.is_full(),
+            diagnostics_enabled: mode.is_full(),
             inline_value_cache: InlineValueCache::new(inlay_hint_settings.show_value_hints),
             inlay_hint_cache: InlayHintCache::new(inlay_hint_settings),
 
@@ -1861,6 +2123,7 @@ impl Editor {
 
             breakpoint_store,
             gutter_breakpoint_indicator: (None, None),
+            hovered_diff_hunk_row: None,
             _subscriptions: vec![
                 cx.observe(&buffer, Self::on_buffer_changed),
                 cx.subscribe_in(&buffer, window, Self::on_buffer_event),
@@ -1877,9 +2140,15 @@ impl Editor {
                             blink_manager.disable(cx);
                         }
                     });
+                    if active {
+                        editor.show_mouse_cursor(cx);
+                    }
                 }),
             ],
             tasks_update_task: None,
+            pull_diagnostics_task: Task::ready(()),
+            colors: None,
+            next_color_inlay_id: 0,
             linked_edit_ranges: Default::default(),
             in_project_search: false,
             previous_search_ranges: None,
@@ -1903,17 +2172,21 @@ impl Editor {
                 .unwrap_or_default(),
             change_list: ChangeList::new(),
             mode,
+            selection_drag_state: SelectionDragState::None,
+            drag_and_drop_selection_enabled: EditorSettings::get_global(cx).drag_and_drop_selection,
+            folding_newlines: Task::ready(()),
         };
-        if let Some(breakpoints) = this.breakpoint_store.as_ref() {
-            this._subscriptions
+        if let Some(breakpoints) = editor.breakpoint_store.as_ref() {
+            editor
+                ._subscriptions
                 .push(cx.observe(breakpoints, |_, _, cx| {
                     cx.notify();
                 }));
         }
-        this.tasks_update_task = Some(this.refresh_runnables(window, cx));
-        this._subscriptions.extend(project_subscriptions);
+        editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
+        editor._subscriptions.extend(project_subscriptions);
 
-        this._subscriptions.push(cx.subscribe_in(
+        editor._subscriptions.push(cx.subscribe_in(
             &cx.entity(),
             window,
             |editor, _, e: &EditorEvent, window, cx| match e {
@@ -1958,14 +2231,15 @@ impl Editor {
             },
         ));
 
-        if let Some(dap_store) = this
+        if let Some(dap_store) = editor
             .project
             .as_ref()
             .map(|project| project.read(cx).dap_store())
         {
             let weak_editor = cx.weak_entity();
 
-            this._subscriptions
+            editor
+                ._subscriptions
                 .push(
                     cx.observe_new::<project::debugger::session::Session>(move |_, _, cx| {
                         let session_entity = cx.entity();
@@ -1980,40 +2254,49 @@ impl Editor {
                 );
 
             for session in dap_store.read(cx).sessions().cloned().collect::<Vec<_>>() {
-                this._subscriptions
+                editor
+                    ._subscriptions
                     .push(cx.subscribe(&session, Self::on_debug_session_event));
             }
         }
 
-        this.end_selection(window, cx);
-        this.scroll_manager.show_scrollbars(window, cx);
-        jsx_tag_auto_close::refresh_enabled_in_any_buffer(&mut this, &buffer, cx);
+        // skip adding the initial selection to selection history
+        editor.selection_history.mode = SelectionHistoryMode::Skipping;
+        editor.end_selection(window, cx);
+        editor.selection_history.mode = SelectionHistoryMode::Normal;
+
+        editor.scroll_manager.show_scrollbars(window, cx);
+        jsx_tag_auto_close::refresh_enabled_in_any_buffer(&mut editor, &buffer, cx);
 
         if full_mode {
             let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars();
             cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars));
 
-            if this.git_blame_inline_enabled {
-                this.start_git_blame_inline(false, window, cx);
+            if editor.git_blame_inline_enabled {
+                editor.start_git_blame_inline(false, window, cx);
             }
 
-            this.go_to_active_debug_line(window, cx);
+            editor.go_to_active_debug_line(window, cx);
 
             if let Some(buffer) = buffer.read(cx).as_singleton() {
-                if let Some(project) = this.project.as_ref() {
+                if let Some(project) = editor.project.as_ref() {
                     let handle = project.update(cx, |project, cx| {
                         project.register_buffer_with_language_servers(&buffer, cx)
                     });
-                    this.registered_buffers
+                    editor
+                        .registered_buffers
                         .insert(buffer.read(cx).remote_id(), handle);
                 }
             }
 
-            this.minimap = this.create_minimap(EditorSettings::get_global(cx).minimap, window, cx);
+            editor.minimap =
+                editor.create_minimap(EditorSettings::get_global(cx).minimap, window, cx);
+            editor.colors = Some(LspColorData::new(cx));
+            editor.update_lsp_data(false, None, window, cx);
         }
 
-        this.report_editor_event("Editor Opened", None, cx);
-        this
+        editor.report_editor_event("Editor Opened", None, cx);
+        editor
     }
 
     pub fn deploy_mouse_context_menu(
@@ -2115,8 +2398,15 @@ impl Editor {
         key_context
     }
 
-    pub fn hide_mouse_cursor(&mut self, origin: &HideMouseCursorOrigin) {
-        self.mouse_cursor_hidden = match origin {
+    fn show_mouse_cursor(&mut self, cx: &mut Context<Self>) {
+        if self.mouse_cursor_hidden {
+            self.mouse_cursor_hidden = false;
+            cx.notify();
+        }
+    }
+
+    pub fn hide_mouse_cursor(&mut self, origin: HideMouseCursorOrigin, cx: &mut Context<Self>) {
+        let hide_mouse_cursor = match origin {
             HideMouseCursorOrigin::TypingAction => {
                 matches!(
                     self.hide_mouse_mode,
@@ -2127,6 +2417,10 @@ impl Editor {
                 matches!(self.hide_mouse_mode, HideMouseMode::OnTypingAndMovement)
             }
         };
+        if self.mouse_cursor_hidden != hide_mouse_cursor {
+            self.mouse_cursor_hidden = hide_mouse_cursor;
+            cx.notify();
+        }
     }
 
     pub fn edit_prediction_in_conflict(&self) -> bool {
@@ -2151,31 +2445,28 @@ impl Editor {
 
     pub fn accept_edit_prediction_keybind(
         &self,
+        accept_partial: bool,
         window: &Window,
         cx: &App,
     ) -> AcceptEditPredictionBinding {
         let key_context = self.key_context_internal(true, window, cx);
         let in_conflict = self.edit_prediction_in_conflict();
 
-        AcceptEditPredictionBinding(
-            window
-                .bindings_for_action_in_context(&AcceptEditPrediction, key_context)
-                .into_iter()
-                .filter(|binding| {
-                    !in_conflict
-                        || binding
-                            .keystrokes()
-                            .first()
-                            .map_or(false, |keystroke| keystroke.modifiers.modified())
-                })
-                .rev()
-                .min_by_key(|binding| {
-                    binding
-                        .keystrokes()
-                        .first()
-                        .map_or(u8::MAX, |k| k.modifiers.number_of_modifiers())
-                }),
-        )
+        let bindings = if accept_partial {
+            window.bindings_for_action_in_context(&AcceptPartialEditPrediction, key_context)
+        } else {
+            window.bindings_for_action_in_context(&AcceptEditPrediction, key_context)
+        };
+
+        // TODO: if the binding contains multiple keystrokes, display all of them, not
+        // just the first one.
+        AcceptEditPredictionBinding(bindings.into_iter().rev().find(|binding| {
+            !in_conflict
+                || binding
+                    .keystrokes()
+                    .first()
+                    .map_or(false, |keystroke| keystroke.modifiers.modified())
+        }))
     }
 
     pub fn new_file(

crates/editor/src/editor_settings.rs 🔗

@@ -1,10 +1,16 @@
+use core::num;
+use std::num::NonZeroU32;
+
 use gpui::App;
 use language::CursorShape;
 use project::project_settings::DiagnosticSeverity;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsSources, VsCodeSettings};
+use util::serde::default_true;
 
+/// Imports from the VSCode settings at
+/// https://code.visualstudio.com/docs/reference/default-settings
 #[derive(Deserialize, Clone)]
 pub struct EditorSettings {
     pub cursor_blink: bool,
@@ -23,6 +29,7 @@ pub struct EditorSettings {
     pub autoscroll_on_clicks: bool,
     pub horizontal_scroll_margin: f32,
     pub scroll_sensitivity: f32,
+    pub fast_scroll_sensitivity: f32,
     pub relative_line_numbers: bool,
     pub seed_search_query_from_cursor: SeedQuerySetting,
     pub use_smartcase_search: bool,
@@ -44,6 +51,24 @@ pub struct EditorSettings {
     pub snippet_sort_order: SnippetSortOrder,
     #[serde(default)]
     pub diagnostics_max_severity: Option<DiagnosticSeverity>,
+    pub inline_code_actions: bool,
+    pub drag_and_drop_selection: bool,
+    pub lsp_document_colors: DocumentColorsRenderMode,
+}
+
+/// How to render LSP `textDocument/documentColor` colors in the editor.
+#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum DocumentColorsRenderMode {
+    /// Do not query and render document colors.
+    None,
+    /// Render document colors as inlay hints near the color text.
+    #[default]
+    Inlay,
+    /// Draw a border around the color text.
+    Border,
+    /// Draw a background behind the color text.
+    Background,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -106,6 +131,7 @@ pub struct Toolbar {
     pub quick_actions: bool,
     pub selections_menu: bool,
     pub agent_review: bool,
+    pub code_actions: bool,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -123,9 +149,11 @@ pub struct Scrollbar {
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
 pub struct Minimap {
     pub show: ShowMinimap,
+    pub display_in: DisplayIn,
     pub thumb: MinimapThumb,
     pub thumb_border: MinimapThumbBorder,
     pub current_line_highlight: Option<CurrentLineHighlight>,
+    pub max_width_columns: num::NonZeroU32,
 }
 
 impl Minimap {
@@ -133,6 +161,11 @@ impl Minimap {
         self.show != ShowMinimap::Never
     }
 
+    #[inline]
+    pub fn on_active_editor(&self) -> bool {
+        self.display_in == DisplayIn::ActiveEditor
+    }
+
     pub fn with_show_override(self) -> Self {
         Self {
             show: ShowMinimap::Always,
@@ -143,6 +176,7 @@ impl Minimap {
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
 pub struct Gutter {
+    pub min_line_number_digits: usize,
     pub line_numbers: bool,
     pub runnables: bool,
     pub breakpoints: bool,
@@ -181,6 +215,19 @@ pub enum ShowMinimap {
     Never,
 }
 
+/// Where to show the minimap in the editor.
+///
+/// Default: all_editors
+#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum DisplayIn {
+    /// Show on all open editors.
+    AllEditors,
+    /// Show the minimap on the active editor only.
+    #[default]
+    ActiveEditor,
+}
+
 /// When to show the minimap thumb.
 ///
 /// Default: always
@@ -276,6 +323,9 @@ pub enum ScrollBeyondLastLine {
 /// Default options for buffer and project search items.
 #[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
 pub struct SearchSettings {
+    /// Whether to show the project search button in the status bar.
+    #[serde(default = "default_true")]
+    pub button: bool,
     #[serde(default)]
     pub whole_word: bool,
     #[serde(default)]
@@ -328,6 +378,7 @@ pub enum SnippetSortOrder {
 }
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
+#[schemars(deny_unknown_fields)]
 pub struct EditorSettingsContent {
     /// Whether the cursor blinks in the editor.
     ///
@@ -364,9 +415,9 @@ pub struct EditorSettingsContent {
     ///
     /// Default: true
     pub hover_popover_enabled: Option<bool>,
-    /// Time to wait before showing the informational hover box
+    /// Time to wait in milliseconds before showing the informational hover box.
     ///
-    /// Default: 350
+    /// Default: 300
     pub hover_popover_delay: Option<u64>,
     /// Toolbar related settings
     pub toolbar: Option<ToolbarContent>,
@@ -397,6 +448,12 @@ pub struct EditorSettingsContent {
     ///
     /// Default: 1.0
     pub scroll_sensitivity: Option<f32>,
+    /// Scroll sensitivity multiplier for fast scrolling. This multiplier is applied
+    /// to both the horizontal and vertical delta values while scrolling. Fast scrolling
+    /// happens when a user holds the alt or option key while scrolling.
+    ///
+    /// Default: 4.0
+    pub fast_scroll_sensitivity: Option<f32>,
     /// Whether the line numbers on editors gutter are relative or not.
     ///
     /// Default: false
@@ -406,7 +463,7 @@ pub struct EditorSettingsContent {
     /// Default: always
     pub seed_search_query_from_cursor: Option<SeedQuerySetting>,
     pub use_smartcase_search: Option<bool>,
-    /// The key to use for adding multiple cursors
+    /// Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier.
     ///
     /// Default: alt
     pub multi_cursor_modifier: Option<MultiCursorModifier>,
@@ -474,6 +531,21 @@ pub struct EditorSettingsContent {
     /// Default: warning
     #[serde(default)]
     pub diagnostics_max_severity: Option<DiagnosticSeverity>,
+
+    /// Whether to show code action button at start of buffer line.
+    ///
+    /// Default: true
+    pub inline_code_actions: Option<bool>,
+
+    /// Whether to allow drag and drop text selection in buffer.
+    ///
+    /// Default: true
+    pub drag_and_drop_selection: Option<bool>,
+
+    /// How to render LSP `textDocument/documentColor` colors in the editor.
+    ///
+    /// Default: [`DocumentColorsRenderMode::Inlay`]
+    pub lsp_document_colors: Option<DocumentColorsRenderMode>,
 }
 
 // Toolbar related settings
@@ -496,6 +568,10 @@ pub struct ToolbarContent {
     ///
     /// Default: true
     pub agent_review: Option<bool>,
+    /// Whether to display code action buttons in the editor toolbar.
+    ///
+    /// Default: false
+    pub code_actions: Option<bool>,
 }
 
 /// Scrollbar related settings
@@ -534,13 +610,18 @@ pub struct ScrollbarContent {
 }
 
 /// Minimap related settings
-#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
+#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
 pub struct MinimapContent {
     /// When to show the minimap in the editor.
     ///
     /// Default: never
     pub show: Option<ShowMinimap>,
 
+    /// Where to show the minimap in the editor.
+    ///
+    /// Default: [`DisplayIn::ActiveEditor`]
+    pub display_in: Option<DisplayIn>,
+
     /// When to show the minimap thumb.
     ///
     /// Default: always
@@ -555,6 +636,11 @@ pub struct MinimapContent {
     ///
     /// Default: inherits editor line highlights setting
     pub current_line_highlight: Option<Option<CurrentLineHighlight>>,
+
+    /// Maximum number of columns to display in the minimap.
+    ///
+    /// Default: 80
+    pub max_width_columns: Option<num::NonZeroU32>,
 }
 
 /// Forcefully enable or disable the scrollbar for each axis
@@ -578,6 +664,10 @@ pub struct GutterContent {
     ///
     /// Default: true
     pub line_numbers: Option<bool>,
+    /// Minimum number of characters to reserve space for in the gutter.
+    ///
+    /// Default: 4
+    pub min_line_number_digits: Option<usize>,
     /// Whether to show runnable buttons in the gutter.
     ///
     /// Default: true
@@ -727,6 +817,10 @@ impl Settings for EditorSettings {
             "editor.mouseWheelScrollSensitivity",
             &mut current.scroll_sensitivity,
         );
+        vscode.f32_setting(
+            "editor.fastScrollSensitivity",
+            &mut current.fast_scroll_sensitivity,
+        );
         if Some("relative") == vscode.read_string("editor.lineNumbers") {
             current.relative_line_numbers = Some(true);
         }
@@ -765,5 +859,37 @@ impl Settings for EditorSettings {
             let search = current.search.get_or_insert_default();
             search.include_ignored = use_ignored;
         }
+
+        let mut minimap = MinimapContent::default();
+        let minimap_enabled = vscode.read_bool("editor.minimap.enabled").unwrap_or(true);
+        let autohide = vscode.read_bool("editor.minimap.autohide");
+        let mut max_width_columns: Option<u32> = None;
+        vscode.u32_setting("editor.minimap.maxColumn", &mut max_width_columns);
+        if minimap_enabled {
+            if let Some(false) = autohide {
+                minimap.show = Some(ShowMinimap::Always);
+            } else {
+                minimap.show = Some(ShowMinimap::Auto);
+            }
+        } else {
+            minimap.show = Some(ShowMinimap::Never);
+        }
+        if let Some(max_width_columns) = max_width_columns {
+            minimap.max_width_columns = NonZeroU32::new(max_width_columns);
+        }
+
+        vscode.enum_setting(
+            "editor.minimap.showSlider",
+            &mut minimap.thumb,
+            |s| match s {
+                "always" => Some(MinimapThumb::Always),
+                "mouseover" => Some(MinimapThumb::Hover),
+                _ => None,
+            },
+        );
+
+        if minimap != MinimapContent::default() {
+            current.minimap = Some(minimap)
+        }
     }
 }

crates/editor/src/editor_tests.rs 🔗

@@ -1,6 +1,8 @@
 use super::*;
 use crate::{
     JoinLines,
+    code_context_menus::CodeContextMenu,
+    inline_completion_tests::FakeInlineCompletionProvider,
     linked_editing_ranges::LinkedEditingRanges,
     scroll::scroll_amount::ScrollAmount,
     test::{
@@ -13,19 +15,20 @@ use crate::{
 use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind};
 use futures::StreamExt;
 use gpui::{
-    BackgroundExecutor, DismissEvent, SemanticVersion, TestAppContext, UpdateGlobal,
+    BackgroundExecutor, DismissEvent, Rgba, SemanticVersion, TestAppContext, UpdateGlobal,
     VisualTestContext, WindowBounds, WindowOptions, div,
 };
 use indoc::indoc;
 use language::{
     BracketPairConfig,
     Capability::ReadWrite,
-    FakeLspAdapter, LanguageConfig, LanguageConfigOverride, LanguageMatcher, LanguageName,
-    Override, Point,
+    DiagnosticSourceKind, FakeLspAdapter, LanguageConfig, LanguageConfigOverride, LanguageMatcher,
+    LanguageName, Override, Point,
     language_settings::{
         AllLanguageSettings, AllLanguageSettingsContent, CompletionSettings,
         LanguageSettingsContent, LspInsertMode, PrettierSettings,
     },
+    tree_sitter_python,
 };
 use language_settings::{Formatter, FormatterList, IndentGuideSettings};
 use lsp::CompletionParams;
@@ -52,8 +55,9 @@ use util::{
     uri,
 };
 use workspace::{
-    CloseAllItems, CloseInactiveItems, NavigationEntry, ViewId,
-    item::{FollowEvent, FollowableItem, Item, ItemHandle},
+    CloseActiveItem, CloseAllItems, CloseInactiveItems, MoveItemToPaneInDirection, NavigationEntry,
+    OpenOptions, ViewId,
+    item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions},
 };
 
 #[gpui::test]
@@ -176,7 +180,9 @@ fn test_edit_events(cx: &mut TestAppContext) {
 
     // No event is emitted when the mutation is a no-op.
     _ = editor2.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| s.select_ranges([0..0]));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([0..0])
+        });
 
         editor.backspace(&Backspace, window, cx);
     });
@@ -199,7 +205,9 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) {
 
     _ = editor.update(cx, |editor, window, cx| {
         editor.start_transaction_at(now, window, cx);
-        editor.change_selections(None, window, cx, |s| s.select_ranges([2..4]));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([2..4])
+        });
 
         editor.insert("cd", window, cx);
         editor.end_transaction_at(now, cx);
@@ -207,14 +215,18 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) {
         assert_eq!(editor.selections.ranges(cx), vec![4..4]);
 
         editor.start_transaction_at(now, window, cx);
-        editor.change_selections(None, window, cx, |s| s.select_ranges([4..5]));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([4..5])
+        });
         editor.insert("e", window, cx);
         editor.end_transaction_at(now, cx);
         assert_eq!(editor.text(cx), "12cde6");
         assert_eq!(editor.selections.ranges(cx), vec![5..5]);
 
         now += group_interval + Duration::from_millis(1);
-        editor.change_selections(None, window, cx, |s| s.select_ranges([2..2]));
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+            s.select_ranges([2..2])
+        });
 
         // Simulate an edit in another editor
         buffer.update(cx, |buffer, cx| {
@@ -322,7 +334,7 @@ fn test_ime_composition(cx: &mut TestAppContext) {
         assert_eq!(editor.marked_text_ranges(cx), None);
 
         // Start a new IME composition with multiple cursors.
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_ranges([
                 OffsetUtf16(1)..OffsetUtf16(1),
                 OffsetUtf16(3)..OffsetUtf16(3),
@@ -620,7 +632,7 @@ fn test_clone(cx: &mut TestAppContext) {
     });
 
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_ranges(selection_ranges.clone())
         });
         editor.fold_creases(
@@ -706,12 +718,12 @@ async fn test_navigation_history(cx: &mut TestAppContext) {
 
             // Move the cursor a small distance.
             // Nothing is added to the navigation history.
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_display_ranges([
                     DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)
                 ])
             });
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_display_ranges([
                     DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 0)
                 ])
@@ -720,7 +732,7 @@ async fn test_navigation_history(cx: &mut TestAppContext) {
 
             // Move the cursor a large distance.
             // The history can jump back to the previous position.
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_display_ranges([
                     DisplayPoint::new(DisplayRow(13), 0)..DisplayPoint::new(DisplayRow(13), 3)
                 ])
@@ -890,7 +902,7 @@ fn test_fold_action(cx: &mut TestAppContext) {
     });
 
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(7), 0)..DisplayPoint::new(DisplayRow(12), 0)
             ]);
@@ -981,7 +993,7 @@ fn test_fold_action_whitespace_sensitive_language(cx: &mut TestAppContext) {
     });
 
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(6), 0)..DisplayPoint::new(DisplayRow(10), 0)
             ]);
@@ -1066,7 +1078,7 @@ fn test_fold_action_multiple_line_breaks(cx: &mut TestAppContext) {
     });
 
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(6), 0)..DisplayPoint::new(DisplayRow(11), 0)
             ]);
@@ -1298,7 +1310,7 @@ fn test_move_cursor(cx: &mut TestAppContext) {
             &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)]
         );
 
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 2)
             ]);
@@ -1443,7 +1455,7 @@ fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) {
         build_editor(buffer.clone(), window, cx)
     });
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([empty_range(0, "ⓐⓑⓒⓓⓔ".len())]);
         });
 
@@ -1533,7 +1545,7 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) {
         build_editor(buffer, window, cx)
     });
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1),
                 DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 4),
@@ -1728,7 +1740,7 @@ fn test_beginning_end_of_line_ignore_soft_wrap(cx: &mut TestAppContext) {
 
         // First, let's assert behavior on the first line, that was not soft-wrapped.
         // Start the cursor at the `k` on the first line
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(0), 7)..DisplayPoint::new(DisplayRow(0), 7)
             ]);
@@ -1750,7 +1762,7 @@ fn test_beginning_end_of_line_ignore_soft_wrap(cx: &mut TestAppContext) {
 
         // Now, let's assert behavior on the second line, that ended up being soft-wrapped.
         // Start the cursor at the last line (`y` that was wrapped to a new line)
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 0)
             ]);
@@ -1816,7 +1828,7 @@ fn test_beginning_of_line_stop_at_indent(cx: &mut TestAppContext) {
     });
 
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1),
                 DisplayPoint::new(DisplayRow(1), 4)..DisplayPoint::new(DisplayRow(1), 4),
@@ -1898,55 +1910,54 @@ fn test_prev_next_word_boundary(cx: &mut TestAppContext) {
         build_editor(buffer, window, cx)
     });
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(0), 11)..DisplayPoint::new(DisplayRow(0), 11),
                 DisplayPoint::new(DisplayRow(2), 4)..DisplayPoint::new(DisplayRow(2), 4),
             ])
         });
-
         editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
         assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n  {ˇbaz.qux()}", editor, cx);
 
         editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
-        assert_selection_ranges("use stdˇ::str::{foo, bar}\n\n  ˇ{baz.qux()}", editor, cx);
+        assert_selection_ranges("use stdˇ::str::{foo, bar}\n\nˇ  {baz.qux()}", editor, cx);
 
         editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
-        assert_selection_ranges("use ˇstd::str::{foo, bar}\n\nˇ  {baz.qux()}", editor, cx);
+        assert_selection_ranges("use ˇstd::str::{foo, bar}\nˇ\n  {baz.qux()}", editor, cx);
 
         editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
-        assert_selection_ranges("ˇuse std::str::{foo, bar}\nˇ\n  {baz.qux()}", editor, cx);
+        assert_selection_ranges("ˇuse std::str::{foo, barˇ}\n\n  {baz.qux()}", editor, cx);
 
         editor.move_to_previous_word_start(&MoveToPreviousWordStart, window, cx);
-        assert_selection_ranges("ˇuse std::str::{foo, barˇ}\n\n  {baz.qux()}", editor, cx);
+        assert_selection_ranges("ˇuse std::str::{foo, ˇbar}\n\n  {baz.qux()}", editor, cx);
 
         editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx);
-        assert_selection_ranges("useˇ std::str::{foo, bar}ˇ\n\n  {baz.qux()}", editor, cx);
+        assert_selection_ranges("useˇ std::str::{foo, barˇ}\n\n  {baz.qux()}", editor, cx);
 
         editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx);
-        assert_selection_ranges("use stdˇ::str::{foo, bar}\nˇ\n  {baz.qux()}", editor, cx);
+        assert_selection_ranges("use stdˇ::str::{foo, bar}ˇ\n\n  {baz.qux()}", editor, cx);
 
         editor.move_to_next_word_end(&MoveToNextWordEnd, window, cx);
-        assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n  {ˇbaz.qux()}", editor, cx);
+        assert_selection_ranges("use std::ˇstr::{foo, bar}\nˇ\n  {baz.qux()}", editor, cx);
 
         editor.move_right(&MoveRight, window, cx);
         editor.select_to_previous_word_start(&SelectToPreviousWordStart, window, cx);
         assert_selection_ranges(
-            "use std::«ˇs»tr::{foo, bar}\n\n  {«ˇb»az.qux()}",
+            "use std::«ˇs»tr::{foo, bar}\n«ˇ\n»  {baz.qux()}",
             editor,
             cx,
         );
 
         editor.select_to_previous_word_start(&SelectToPreviousWordStart, window, cx);
         assert_selection_ranges(
-            "use std«ˇ::s»tr::{foo, bar}\n\n  «ˇ{b»az.qux()}",
+            "use std«ˇ::s»tr::{foo, bar«ˇ}\n\n»  {baz.qux()}",
             editor,
             cx,
         );
 
         editor.select_to_next_word_end(&SelectToNextWordEnd, window, cx);
         assert_selection_ranges(
-            "use std::«ˇs»tr::{foo, bar}\n\n  {«ˇb»az.qux()}",
+            "use std::«ˇs»tr::{foo, bar}«ˇ\n\n»  {baz.qux()}",
             editor,
             cx,
         );
@@ -1969,7 +1980,7 @@ fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) {
             "use one::{\n    two::three::\n    four::five\n};"
         );
 
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(1), 7)..DisplayPoint::new(DisplayRow(1), 7)
             ]);
@@ -2232,7 +2243,7 @@ async fn test_autoscroll(cx: &mut TestAppContext) {
     // on screen, the editor autoscrolls to reveal the newest cursor, and
     // allows the vertical scroll margin below that cursor.
     cx.update_editor(|editor, window, cx| {
-        editor.change_selections(Some(Autoscroll::fit()), window, cx, |selections| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
             selections.select_ranges([
                 Point::new(0, 0)..Point::new(0, 0),
                 Point::new(6, 0)..Point::new(6, 0),
@@ -2260,7 +2271,7 @@ async fn test_autoscroll(cx: &mut TestAppContext) {
     // Add a cursor above the visible area. Since both cursors fit on screen,
     // the editor scrolls to show both.
     cx.update_editor(|editor, window, cx| {
-        editor.change_selections(Some(Autoscroll::fit()), window, cx, |selections| {
+        editor.change_selections(Default::default(), window, cx, |selections| {
             selections.select_ranges([
                 Point::new(1, 0)..Point::new(1, 0),
                 Point::new(6, 0)..Point::new(6, 0),
@@ -2427,7 +2438,7 @@ fn test_delete_to_word_boundary(cx: &mut TestAppContext) {
     });
 
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 // an empty selection - the preceding word fragment is deleted
                 DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2),
@@ -2446,7 +2457,7 @@ fn test_delete_to_word_boundary(cx: &mut TestAppContext) {
     });
 
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 // an empty selection - the following word fragment is deleted
                 DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3),
@@ -2481,7 +2492,7 @@ fn test_delete_to_previous_word_start_or_newline(cx: &mut TestAppContext) {
     };
 
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1)
             ])
@@ -2517,7 +2528,7 @@ fn test_delete_to_next_word_end_or_newline(cx: &mut TestAppContext) {
     };
 
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)
             ])
@@ -2556,7 +2567,7 @@ fn test_newline(cx: &mut TestAppContext) {
     });
 
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(0), 2)..DisplayPoint::new(DisplayRow(0), 2),
                 DisplayPoint::new(DisplayRow(1), 2)..DisplayPoint::new(DisplayRow(1), 2),
@@ -2589,7 +2600,7 @@ fn test_newline_with_old_selections(cx: &mut TestAppContext) {
             cx,
         );
         let mut editor = build_editor(buffer.clone(), window, cx);
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_ranges([
                 Point::new(2, 4)..Point::new(2, 5),
                 Point::new(5, 4)..Point::new(5, 5),
@@ -2755,7 +2766,7 @@ async fn test_newline_comments(cx: &mut TestAppContext) {
 
     let language = Arc::new(Language::new(
         LanguageConfig {
-            line_comments: vec!["//".into()],
+            line_comments: vec!["// ".into()],
             ..LanguageConfig::default()
         },
         None,
@@ -2770,7 +2781,29 @@ async fn test_newline_comments(cx: &mut TestAppContext) {
         cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
         cx.assert_editor_state(indoc! {"
         // Foo
+        // ˇ
+    "});
+        // Ensure that we add comment prefix when existing line contains space
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(
+            indoc! {"
+        // Foo
+        //s
+        // ˇ
+    "}
+            .replace("s", " ") // s is used as space placeholder to prevent format on save
+            .as_str(),
+        );
+        // Ensure that we add comment prefix when existing line does not contain space
+        cx.set_state(indoc! {"
+        // Foo
         //ˇ
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        // Foo
+        //
+        // ˇ
     "});
         // Ensure that if cursor is before the comment start, we do not actually insert a comment prefix.
         cx.set_state(indoc! {"
@@ -2797,6 +2830,256 @@ async fn test_newline_comments(cx: &mut TestAppContext) {
     "});
 }
 
+#[gpui::test]
+async fn test_newline_comments_with_multiple_delimiters(cx: &mut TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = NonZeroU32::new(4)
+    });
+
+    let language = Arc::new(Language::new(
+        LanguageConfig {
+            line_comments: vec!["// ".into(), "/// ".into()],
+            ..LanguageConfig::default()
+        },
+        None,
+    ));
+    {
+        let mut cx = EditorTestContext::new(cx).await;
+        cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+        cx.set_state(indoc! {"
+        //ˇ
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        //
+        // ˇ
+    "});
+
+        cx.set_state(indoc! {"
+        ///ˇ
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        ///
+        /// ˇ
+    "});
+    }
+}
+
+#[gpui::test]
+async fn test_newline_documentation_comments(cx: &mut TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = NonZeroU32::new(4)
+    });
+
+    let language = Arc::new(
+        Language::new(
+            LanguageConfig {
+                documentation: Some(language::DocumentationConfig {
+                    start: "/**".into(),
+                    end: "*/".into(),
+                    prefix: "* ".into(),
+                    tab_size: NonZeroU32::new(1).unwrap(),
+                }),
+
+                ..LanguageConfig::default()
+            },
+            Some(tree_sitter_rust::LANGUAGE.into()),
+        )
+        .with_override_query("[(line_comment)(block_comment)] @comment.inclusive")
+        .unwrap(),
+    );
+
+    {
+        let mut cx = EditorTestContext::new(cx).await;
+        cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+        cx.set_state(indoc! {"
+        /**ˇ
+    "});
+
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /**
+         * ˇ
+    "});
+        // Ensure that if cursor is before the comment start,
+        // we do not actually insert a comment prefix.
+        cx.set_state(indoc! {"
+        ˇ/**
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+
+        ˇ/**
+    "});
+        // Ensure that if cursor is between it doesn't add comment prefix.
+        cx.set_state(indoc! {"
+        /*ˇ*
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /*
+        ˇ*
+    "});
+        // Ensure that if suffix exists on same line after cursor it adds new line.
+        cx.set_state(indoc! {"
+        /**ˇ*/
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /**
+         * ˇ
+         */
+    "});
+        // Ensure that if suffix exists on same line after cursor with space it adds new line.
+        cx.set_state(indoc! {"
+        /**ˇ */
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /**
+         * ˇ
+         */
+    "});
+        // Ensure that if suffix exists on same line after cursor with space it adds new line.
+        cx.set_state(indoc! {"
+        /** ˇ*/
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(
+            indoc! {"
+        /**s
+         * ˇ
+         */
+    "}
+            .replace("s", " ") // s is used as space placeholder to prevent format on save
+            .as_str(),
+        );
+        // Ensure that delimiter space is preserved when newline on already
+        // spaced delimiter.
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(
+            indoc! {"
+        /**s
+         *s
+         * ˇ
+         */
+    "}
+            .replace("s", " ") // s is used as space placeholder to prevent format on save
+            .as_str(),
+        );
+        // Ensure that delimiter space is preserved when space is not
+        // on existing delimiter.
+        cx.set_state(indoc! {"
+        /**
+         *ˇ
+         */
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /**
+         *
+         * ˇ
+         */
+    "});
+        // Ensure that if suffix exists on same line after cursor it
+        // doesn't add extra new line if prefix is not on same line.
+        cx.set_state(indoc! {"
+        /**
+        ˇ*/
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /**
+
+        ˇ*/
+    "});
+        // Ensure that it detects suffix after existing prefix.
+        cx.set_state(indoc! {"
+        /**ˇ/
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /**
+        ˇ/
+    "});
+        // Ensure that if suffix exists on same line before
+        // cursor it does not add comment prefix.
+        cx.set_state(indoc! {"
+        /** */ˇ
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /** */
+        ˇ
+    "});
+        // Ensure that if suffix exists on same line before
+        // cursor it does not add comment prefix.
+        cx.set_state(indoc! {"
+        /**
+         *
+         */ˇ
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /**
+         *
+         */
+         ˇ
+    "});
+
+        // Ensure that inline comment followed by code
+        // doesn't add comment prefix on newline
+        cx.set_state(indoc! {"
+        /** */ textˇ
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /** */ text
+        ˇ
+    "});
+
+        // Ensure that text after comment end tag
+        // doesn't add comment prefix on newline
+        cx.set_state(indoc! {"
+        /**
+         *
+         */ˇtext
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /**
+         *
+         */
+         ˇtext
+    "});
+
+        // Ensure if not comment block it doesn't
+        // add comment prefix on newline
+        cx.set_state(indoc! {"
+        * textˇ
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        * text
+        ˇ
+    "});
+    }
+    // Ensure that comment continuations can be disabled.
+    update_test_language_settings(cx, |settings| {
+        settings.defaults.extend_comment_on_newline = Some(false);
+    });
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.set_state(indoc! {"
+        /**ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.assert_editor_state(indoc! {"
+        /**
+        ˇ
+    "});
+}
+
 #[gpui::test]
 fn test_insert_with_old_selections(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -2804,7 +3087,7 @@ fn test_insert_with_old_selections(cx: &mut TestAppContext) {
     let editor = cx.add_window(|window, cx| {
         let buffer = MultiBuffer::build_simple("a( X ), b( Y ), c( Z )", cx);
         let mut editor = build_editor(buffer.clone(), window, cx);
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_ranges([3..4, 11..12, 19..20])
         });
         editor
@@ -3453,7 +3736,7 @@ fn test_delete_line(cx: &mut TestAppContext) {
         build_editor(buffer, window, cx)
     });
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(0), 1)..DisplayPoint::new(DisplayRow(0), 1),
                 DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 1),
@@ -3476,7 +3759,7 @@ fn test_delete_line(cx: &mut TestAppContext) {
         build_editor(buffer, window, cx)
     });
     _ = editor.update(cx, |editor, window, cx| {
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_display_ranges([
                 DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(0), 1)
             ])
@@ -3513,7 +3796,7 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) {
         );
 
         // When multiple lines are selected, remove newlines that are spanned by the selection
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_ranges([Point::new(0, 5)..Point::new(2, 2)])
         });
         editor.join_lines(&JoinLines, window, cx);
@@ -3532,7 +3815,7 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) {
         );
 
         // When joining an empty line don't insert a space
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_ranges([Point::new(2, 1)..Point::new(2, 2)])
         });
         editor.join_lines(&JoinLines, window, cx);
@@ -3572,7 +3855,7 @@ fn test_join_lines_with_single_selection(cx: &mut TestAppContext) {
 
         // We remove any leading spaces
         assert_eq!(buffer.read(cx).text(), "aaa bbb\n  c\n  \n\td");
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_ranges([Point::new(0, 1)..Point::new(0, 1)])
         });
         editor.join_lines(&JoinLines, window, cx);
@@ -3599,7 +3882,7 @@ fn test_join_lines_with_multi_selection(cx: &mut TestAppContext) {
         let mut editor = build_editor(buffer.clone(), window, cx);
         let buffer = buffer.read(cx).as_singleton().unwrap();
 
-        editor.change_selections(None, window, cx, |s| {
+        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_ranges([
                 Point::new(0, 2)..Point::new(1, 1),
                 Point::new(1, 2)..Point::new(1, 2),
@@ -3702,7 +3985,7 @@ async fn test_custom_newlines_cause_no_false_positive_diffs(
 }
 
 #[gpui::test]
-async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) {
+async fn test_manipulate_immutable_lines_with_single_selection(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
 
     let mut cx = EditorTestContext::new(cx).await;
@@ -3747,8 +4030,8 @@ async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) {
 
     // Skip testing shuffle_line()
 
-    // From here on out, test more complex cases of manipulate_lines() with a single driver method: sort_lines_case_sensitive()
-    // Since all methods calling manipulate_lines() are doing the exact same general thing (reordering lines)
+    // From here on out, test more complex cases of manipulate_immutable_lines() with a single driver method: sort_lines_case_sensitive()
+    // Since all methods calling manipulate_immutable_lines() are doing the exact same general thing (reordering lines)
 
     // Don't manipulate when cursor is on single line, but expand the selection
     cx.set_state(indoc! {"
@@ -3815,7 +4098,7 @@ async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) {
         bbˇ»b
     "});
     cx.update_editor(|e, window, cx| {
-        e.manipulate_lines(window, cx, |lines| lines.push("added_line"))
+        e.manipulate_immutable_lines(window, cx, |lines| lines.push("added_line"))
     });
     cx.assert_editor_state(indoc! {"
         «aaa
@@ -3829,7 +4112,7 @@ async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) {
         bbbˇ»
     "});
     cx.update_editor(|e, window, cx| {
-        e.manipulate_lines(window, cx, |lines| {
+        e.manipulate_immutable_lines(window, cx, |lines| {
             lines.pop();
         })
     });
@@ -3843,7 +4126,7 @@ async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) {
         bbbˇ»
     "});
     cx.update_editor(|e, window, cx| {
-        e.manipulate_lines(window, cx, |lines| {
+        e.manipulate_immutable_lines(window, cx, |lines| {
             lines.drain(..);
         })
     });
@@ -3943,7 +4226,7 @@ async fn test_unique_lines_single_selection(cx: &mut TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_manipulate_lines_with_multi_selection(cx: &mut TestAppContext) {
+async fn test_manipulate_immutable_lines_with_multi_selection(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
 
     let mut cx = EditorTestContext::new(cx).await;
@@ -4003,7 +4286,7 @@ async fn test_manipulate_lines_with_multi_selection(cx: &mut TestAppContext) {
         aaaˇ»aa
     "});
     cx.update_editor(|e, window, cx| {
-        e.manipulate_lines(window, cx, |lines| lines.push("added line"))
+        e.manipulate_immutable_lines(window, cx, |lines| lines.push("added line"))
     });
     cx.assert_editor_state(indoc! {"
         «2
@@ -4024,7 +4307,7 @@ async fn test_manipulate_lines_with_multi_selection(cx: &mut TestAppContext) {
         aaaˇ»aa
     "});
     cx.update_editor(|e, window, cx| {
-        e.manipulate_lines(window, cx, |lines| {
+        e.manipulate_immutable_lines(window, cx, |lines| {
             lines.pop();
         })
     });
@@ -4035,6 +4318,246 @@ async fn test_manipulate_lines_with_multi_selection(cx: &mut TestAppContext) {
     "});
 }
 
+#[gpui::test]
+async fn test_convert_indentation_to_spaces(cx: &mut TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = NonZeroU32::new(3)
+    });
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    // MULTI SELECTION
+    // Ln.1 "«" tests empty lines
+    // Ln.9 tests just leading whitespace
+    cx.set_state(indoc! {"
+        «
+        abc                 // No indentationˇ»
+        «\tabc              // 1 tabˇ»
+        \t\tabc «      ˇ»   // 2 tabs
+        \t ab«c             // Tab followed by space
+         \tabc              // Space followed by tab (3 spaces should be the result)
+        \t \t  \t   \tabc   // Mixed indentation (tab conversion depends on the column)
+           abˇ»ˇc   ˇ    ˇ  // Already space indented«
+        \t
+        \tabc\tdef          // Only the leading tab is manipulatedˇ»
+    "});
+    cx.update_editor(|e, window, cx| {
+        e.convert_indentation_to_spaces(&ConvertIndentationToSpaces, window, cx);
+    });
+    cx.assert_editor_state(
+        indoc! {"
+            «
+            abc                 // No indentation
+               abc              // 1 tab
+                  abc          // 2 tabs
+                abc             // Tab followed by space
+               abc              // Space followed by tab (3 spaces should be the result)
+                           abc   // Mixed indentation (tab conversion depends on the column)
+               abc         // Already space indented
+               ·
+               abc\tdef          // Only the leading tab is manipulatedˇ»
+        "}
+        .replace("·", "")
+        .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace
+    );
+
+    // Test on just a few lines, the others should remain unchanged
+    // Only lines (3, 5, 10, 11) should change
+    cx.set_state(
+        indoc! {"
+            ·
+            abc                 // No indentation
+            \tabcˇ               // 1 tab
+            \t\tabc             // 2 tabs
+            \t abcˇ              // Tab followed by space
+             \tabc              // Space followed by tab (3 spaces should be the result)
+            \t \t  \t   \tabc   // Mixed indentation (tab conversion depends on the column)
+               abc              // Already space indented
+            «\t
+            \tabc\tdef          // Only the leading tab is manipulatedˇ»
+        "}
+        .replace("·", "")
+        .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace
+    );
+    cx.update_editor(|e, window, cx| {
+        e.convert_indentation_to_spaces(&ConvertIndentationToSpaces, window, cx);
+    });
+    cx.assert_editor_state(
+        indoc! {"
+            ·
+            abc                 // No indentation
+            «   abc               // 1 tabˇ»
+            \t\tabc             // 2 tabs
+            «    abc              // Tab followed by spaceˇ»
+             \tabc              // Space followed by tab (3 spaces should be the result)
+            \t \t  \t   \tabc   // Mixed indentation (tab conversion depends on the column)
+               abc              // Already space indented
+            «   ·
+               abc\tdef          // Only the leading tab is manipulatedˇ»
+        "}
+        .replace("·", "")
+        .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace
+    );
+
+    // SINGLE SELECTION
+    // Ln.1 "«" tests empty lines
+    // Ln.9 tests just leading whitespace
+    cx.set_state(indoc! {"
+        «
+        abc                 // No indentation
+        \tabc               // 1 tab
+        \t\tabc             // 2 tabs
+        \t abc              // Tab followed by space
+         \tabc              // Space followed by tab (3 spaces should be the result)
+        \t \t  \t   \tabc   // Mixed indentation (tab conversion depends on the column)
+           abc              // Already space indented
+        \t
+        \tabc\tdef          // Only the leading tab is manipulatedˇ»
+    "});
+    cx.update_editor(|e, window, cx| {
+        e.convert_indentation_to_spaces(&ConvertIndentationToSpaces, window, cx);
+    });
+    cx.assert_editor_state(
+        indoc! {"
+            «
+            abc                 // No indentation
+               abc               // 1 tab
+                  abc             // 2 tabs
+                abc              // Tab followed by space
+               abc              // Space followed by tab (3 spaces should be the result)
+                           abc   // Mixed indentation (tab conversion depends on the column)
+               abc              // Already space indented
+               ·
+               abc\tdef          // Only the leading tab is manipulatedˇ»
+        "}
+        .replace("·", "")
+        .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace
+    );
+}
+
+#[gpui::test]
+async fn test_convert_indentation_to_tabs(cx: &mut TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = NonZeroU32::new(3)
+    });
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    // MULTI SELECTION
+    // Ln.1 "«" tests empty lines
+    // Ln.11 tests just leading whitespace
+    cx.set_state(indoc! {"
+        «
+        abˇ»ˇc                 // No indentation
+         abc    ˇ        ˇ    // 1 space (< 3 so dont convert)
+          abc  «             // 2 spaces (< 3 so dont convert)
+           abc              // 3 spaces (convert)
+             abc ˇ»           // 5 spaces (1 tab + 2 spaces)
+        «\tˇ»\t«\tˇ»abc           // Already tab indented
+        «\t abc              // Tab followed by space
+         \tabc              // Space followed by tab (should be consumed due to tab)
+        \t \t  \t   \tabc   // Mixed indentation (first 3 spaces are consumed, the others are converted)
+           \tˇ»  «\t
+           abcˇ»   \t ˇˇˇ        // Only the leading spaces should be converted
+    "});
+    cx.update_editor(|e, window, cx| {
+        e.convert_indentation_to_tabs(&ConvertIndentationToTabs, window, cx);
+    });
+    cx.assert_editor_state(indoc! {"
+        «
+        abc                 // No indentation
+         abc                // 1 space (< 3 so dont convert)
+          abc               // 2 spaces (< 3 so dont convert)
+        \tabc              // 3 spaces (convert)
+        \t  abc            // 5 spaces (1 tab + 2 spaces)
+        \t\t\tabc           // Already tab indented
+        \t abc              // Tab followed by space
+        \tabc              // Space followed by tab (should be consumed due to tab)
+        \t\t\t\t\tabc   // Mixed indentation (first 3 spaces are consumed, the others are converted)
+        \t\t\t
+        \tabc   \t         // Only the leading spaces should be convertedˇ»
+    "});
+
+    // Test on just a few lines, the other should remain unchanged
+    // Only lines (4, 8, 11, 12) should change
+    cx.set_state(
+        indoc! {"
+            ·
+            abc                 // No indentation
+             abc                // 1 space (< 3 so dont convert)
+              abc               // 2 spaces (< 3 so dont convert)
+            «   abc              // 3 spaces (convert)ˇ»
+                 abc            // 5 spaces (1 tab + 2 spaces)
+            \t\t\tabc           // Already tab indented
+            \t abc              // Tab followed by space
+             \tabc      ˇ        // Space followed by tab (should be consumed due to tab)
+               \t\t  \tabc      // Mixed indentation
+            \t \t  \t   \tabc   // Mixed indentation
+               \t  \tˇ
+            «   abc   \t         // Only the leading spaces should be convertedˇ»
+        "}
+        .replace("·", "")
+        .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace
+    );
+    cx.update_editor(|e, window, cx| {
+        e.convert_indentation_to_tabs(&ConvertIndentationToTabs, window, cx);
+    });
+    cx.assert_editor_state(
+        indoc! {"
+            ·
+            abc                 // No indentation
+             abc                // 1 space (< 3 so dont convert)
+              abc               // 2 spaces (< 3 so dont convert)
+            «\tabc              // 3 spaces (convert)ˇ»
+                 abc            // 5 spaces (1 tab + 2 spaces)
+            \t\t\tabc           // Already tab indented
+            \t abc              // Tab followed by space
+            «\tabc              // Space followed by tab (should be consumed due to tab)ˇ»
+               \t\t  \tabc      // Mixed indentation
+            \t \t  \t   \tabc   // Mixed indentation
+            «\t\t\t
+            \tabc   \t         // Only the leading spaces should be convertedˇ»
+        "}
+        .replace("·", "")
+        .as_str(), // · used as placeholder to prevent format-on-save from removing whitespace
+    );
+
+    // SINGLE SELECTION
+    // Ln.1 "«" tests empty lines
+    // Ln.11 tests just leading whitespace
+    cx.set_state(indoc! {"
+        «
+        abc                 // No indentation
+         abc                // 1 space (< 3 so dont convert)
+          abc               // 2 spaces (< 3 so dont convert)
+           abc              // 3 spaces (convert)
+             abc            // 5 spaces (1 tab + 2 spaces)
+        \t\t\tabc           // Already tab indented
+        \t abc              // Tab followed by space
+         \tabc              // Space followed by tab (should be consumed due to tab)
+        \t \t  \t   \tabc   // Mixed indentation (first 3 spaces are consumed, the others are converted)
+           \t  \t
+           abc   \t         // Only the leading spaces should be convertedˇ»
+    "});
+    cx.update_editor(|e, window, cx| {
+        e.convert_indentation_to_tabs(&ConvertIndentationToTabs, window, cx);
+    });
+    cx.assert_editor_state(indoc! {"
+        «
+        abc                 // No indentation
+         abc                // 1 space (< 3 so dont convert)
+          abc               // 2 spaces (< 3 so dont convert)
+        \tabc              // 3 spaces (convert)
+        \t  abc            // 5 spaces (1 tab + 2 spaces)
+        \t\t\tabc           // Already tab indented
+        \t abc              // Tab followed by space
+        \tabc              // Space followed by tab (should be consumed due to tab)
+        \t\t\t\t\tabc   // Mixed indentation (first 3 spaces are consumed, the others are converted)
+        \t\t\t
+        \tabc   \t         // Only the leading spaces should be convertedˇ»
+    "});
+}
+
 #[gpui::test]
 async fn test_toggle_case(cx: &mut TestAppContext) {
     init_test(cx, |_| {});

crates/editor/src/element.rs 🔗

@@ -1,24 +1,24 @@
 use crate::{
-    ActiveDiagnostic, BlockId, COLUMNAR_SELECTION_MODIFIERS, CURSORS_VISIBLE_FOR,
-    ChunkRendererContext, ChunkReplacement, ConflictsOurs, ConflictsOursMarker, ConflictsOuter,
+    ActiveDiagnostic, BlockId, CURSORS_VISIBLE_FOR, ChunkRendererContext, ChunkReplacement,
+    CodeActionSource, ColumnarMode, ConflictsOurs, ConflictsOursMarker, ConflictsOuter,
     ConflictsTheirs, ConflictsTheirsMarker, ContextMenuPlacement, CursorShape, CustomBlockId,
     DisplayDiffHunk, DisplayPoint, DisplayRow, DocumentHighlightRead, DocumentHighlightWrite,
     EditDisplayMode, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle,
     FILE_HEADER_HEIGHT, FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput,
     HoveredCursor, InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineHighlight,
-    LineUp, MAX_LINE_LEN, MIN_LINE_NUMBER_DIGITS, MINIMAP_FONT_SIZE,
-    MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown, PageUp, PhantomBreakpointIndicator,
-    Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight, Selection, SoftWrap,
-    StickyHeaderExcerpt, ToPoint, ToggleFold,
+    LineUp, MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts,
+    PageDown, PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt, SelectPhase,
+    SelectedTextHighlight, Selection, SelectionDragState, SoftWrap, StickyHeaderExcerpt, ToPoint,
+    ToggleFold,
     code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP},
     display_map::{
-        Block, BlockContext, BlockStyle, DisplaySnapshot, EditorMargins, FoldId, HighlightedChunk,
-        ToDisplayPoint,
+        Block, BlockContext, BlockStyle, DisplaySnapshot, EditorMargins, FoldId, HighlightKey,
+        HighlightedChunk, ToDisplayPoint,
     },
     editor_settings::{
-        CurrentLineHighlight, DoubleClickInMultibuffer, MinimapThumb, MinimapThumbBorder,
-        MultiCursorModifier, ScrollBeyondLastLine, ScrollbarAxes, ScrollbarDiagnostics,
-        ShowMinimap, ShowScrollbar,
+        CurrentLineHighlight, DocumentColorsRenderMode, DoubleClickInMultibuffer, Minimap,
+        MinimapThumb, MinimapThumbBorder, ScrollBeyondLastLine, ScrollbarAxes,
+        ScrollbarDiagnostics, ShowMinimap, ShowScrollbar,
     },
     git::blame::{BlameRenderer, GitBlame, GlobalBlameRenderer},
     gutter::breakpoint_indicator::breakpoint_indicator_path,
@@ -34,7 +34,6 @@ use crate::{
 
 use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
 use collections::{BTreeMap, HashMap};
-use feature_flags::{DebuggerFeatureFlag, FeatureFlagAppExt};
 use file_icons::FileIcons;
 use git::{
     Oid,
@@ -44,13 +43,13 @@ use git::{
 use gpui::{
     Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
     Bounds, ClickEvent, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges,
-    Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, Hsla,
-    InteractiveElement, IntoElement, IsZero, Keystroke, Length, Modifiers, ModifiersChangedEvent,
-    MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels,
-    ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size,
-    StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, WeakEntity, Window,
-    anchored, canvas, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px,
-    quad, relative, size, solid_background, transparent_black,
+    Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox,
+    HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, Keystroke, Length, Modifiers,
+    ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
+    ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
+    Size, StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, WeakEntity,
+    Window, anchored, canvas, deferred, div, fill, linear_color_stop, linear_gradient, outline,
+    point, px, quad, relative, size, solid_background, transparent_black,
 };
 use itertools::Itertools;
 use language::language_settings::{
@@ -64,7 +63,7 @@ use multi_buffer::{
 
 use project::{
     ProjectPath,
-    debugger::breakpoint_store::{Breakpoint, BreakpointEditAction},
+    debugger::breakpoint_store::{Breakpoint, BreakpointEditAction, BreakpointSessionState},
     project_settings::{GitGutterSetting, GitHunkStyleSetting, ProjectSettings},
 };
 use settings::Settings;
@@ -78,17 +77,19 @@ use std::{
     ops::{Deref, Range},
     rc::Rc,
     sync::Arc,
-    time::Duration,
+    time::{Duration, Instant},
 };
 use sum_tree::Bias;
-use text::BufferId;
+use text::{BufferId, SelectionGoal};
 use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor};
 use ui::{ButtonLike, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*};
 use unicode_segmentation::UnicodeSegmentation;
+use util::post_inc;
 use util::{RangeExt, ResultExt, debug_panic};
 use workspace::{CollaboratorId, Workspace, item::Item, notifications::NotifyTaskExt};
 
 const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.;
+const SELECTION_DRAG_DELAY: Duration = Duration::from_millis(300);
 
 /// Determines what kinds of highlights should be applied to a lines background.
 #[derive(Clone, Copy, Default)]
@@ -109,6 +110,12 @@ struct SelectionLayout {
     user_name: Option<SharedString>,
 }
 
+struct InlineBlameLayout {
+    element: AnyElement,
+    bounds: Bounds<Pixels>,
+    entry: BlameEntry,
+}
+
 impl SelectionLayout {
     fn new<T: ToPoint + ToDisplayPoint + Clone>(
         selection: Selection<T>,
@@ -189,7 +196,7 @@ impl EditorElement {
         let editor = &self.editor;
         editor.update(cx, |editor, cx| {
             for action in editor.editor_actions.borrow().values() {
-                (action)(window, cx)
+                (action)(editor, window, cx)
             }
         });
 
@@ -225,6 +232,8 @@ impl EditorElement {
         register_action(editor, window, Editor::reverse_lines);
         register_action(editor, window, Editor::shuffle_lines);
         register_action(editor, window, Editor::toggle_case);
+        register_action(editor, window, Editor::convert_indentation_to_spaces);
+        register_action(editor, window, Editor::convert_indentation_to_tabs);
         register_action(editor, window, Editor::convert_to_upper_case);
         register_action(editor, window, Editor::convert_to_lower_case);
         register_action(editor, window, Editor::convert_to_title_case);
@@ -428,8 +437,15 @@ impl EditorElement {
         register_action(editor, window, Editor::toggle_indent_guides);
         register_action(editor, window, Editor::toggle_inlay_hints);
         register_action(editor, window, Editor::toggle_edit_predictions);
-        register_action(editor, window, Editor::toggle_inline_diagnostics);
-        register_action(editor, window, Editor::toggle_minimap);
+        if editor.read(cx).diagnostics_enabled() {
+            register_action(editor, window, Editor::toggle_diagnostics);
+        }
+        if editor.read(cx).inline_diagnostics_enabled() {
+            register_action(editor, window, Editor::toggle_inline_diagnostics);
+        }
+        if editor.read(cx).supports_minimap(cx) {
+            register_action(editor, window, Editor::toggle_minimap);
+        }
         register_action(editor, window, hover_popover::hover);
         register_action(editor, window, Editor::reveal_in_finder);
         register_action(editor, window, Editor::copy_path);
@@ -554,12 +570,10 @@ impl EditorElement {
         register_action(editor, window, Editor::insert_uuid_v4);
         register_action(editor, window, Editor::insert_uuid_v7);
         register_action(editor, window, Editor::open_selections_in_multibuffer);
-        if cx.has_flag::<DebuggerFeatureFlag>() {
-            register_action(editor, window, Editor::toggle_breakpoint);
-            register_action(editor, window, Editor::edit_log_breakpoint);
-            register_action(editor, window, Editor::enable_breakpoint);
-            register_action(editor, window, Editor::disable_breakpoint);
-        }
+        register_action(editor, window, Editor::toggle_breakpoint);
+        register_action(editor, window, Editor::edit_log_breakpoint);
+        register_action(editor, window, Editor::enable_breakpoint);
+        register_action(editor, window, Editor::disable_breakpoint);
     }
 
     fn register_key_listeners(&self, window: &mut Window, _: &mut App, layout: &EditorLayout) {
@@ -615,6 +629,7 @@ impl EditorElement {
 
         let text_hitbox = &position_map.text_hitbox;
         let gutter_hitbox = &position_map.gutter_hitbox;
+        let point_for_position = position_map.point_for_position(event.position);
         let mut click_count = event.click_count;
         let mut modifiers = event.modifiers;
 
@@ -628,6 +643,21 @@ impl EditorElement {
             return;
         }
 
+        if editor.drag_and_drop_selection_enabled && click_count == 1 {
+            let newest_anchor = editor.selections.newest_anchor();
+            let snapshot = editor.snapshot(window, cx);
+            let selection = newest_anchor.map(|anchor| anchor.to_display_point(&snapshot));
+            if point_for_position.intersects_selection(&selection) {
+                editor.selection_drag_state = SelectionDragState::ReadyToDrag {
+                    selection: newest_anchor.clone(),
+                    click_position: event.position,
+                    mouse_down_time: Instant::now(),
+                };
+                cx.stop_propagation();
+                return;
+            }
+        }
+
         let is_singleton = editor.buffer().read(cx).is_singleton();
 
         if click_count == 2 && !is_singleton {
@@ -671,13 +701,16 @@ impl EditorElement {
             }
         }
 
-        let point_for_position = position_map.point_for_position(event.position);
         let position = point_for_position.previous_valid;
-        if modifiers == COLUMNAR_SELECTION_MODIFIERS {
+        if let Some(mode) = Editor::columnar_selection_mode(&modifiers, cx) {
             editor.select(
                 SelectPhase::BeginColumnar {
                     position,
-                    reset: false,
+                    reset: match mode {
+                        ColumnarMode::FromMouse => true,
+                        ColumnarMode::FromSelection => false,
+                    },
+                    mode: mode,
                     goal_column: point_for_position.exact_unclipped.column(),
                 },
                 window,
@@ -694,15 +727,10 @@ impl EditorElement {
                 cx,
             );
         } else {
-            let multi_cursor_setting = EditorSettings::get_global(cx).multi_cursor_modifier;
-            let multi_cursor_modifier = match multi_cursor_setting {
-                MultiCursorModifier::Alt => modifiers.alt,
-                MultiCursorModifier::CmdOrCtrl => modifiers.secondary(),
-            };
             editor.select(
                 SelectPhase::Begin {
                     position,
-                    add: multi_cursor_modifier,
+                    add: Editor::multi_cursor_modifier(true, &modifiers, cx),
                     click_count,
                 },
                 window,
@@ -799,6 +827,7 @@ impl EditorElement {
             SelectPhase::BeginColumnar {
                 position,
                 reset: true,
+                mode: ColumnarMode::FromMouse,
                 goal_column: point_for_position.exact_unclipped.column(),
             },
             window,
@@ -816,6 +845,54 @@ impl EditorElement {
         let text_hitbox = &position_map.text_hitbox;
         let end_selection = editor.has_pending_selection();
         let pending_nonempty_selections = editor.has_pending_nonempty_selection();
+        let point_for_position = position_map.point_for_position(event.position);
+
+        match editor.selection_drag_state {
+            SelectionDragState::ReadyToDrag {
+                selection: _,
+                ref click_position,
+                mouse_down_time: _,
+            } => {
+                if event.position == *click_position {
+                    editor.select(
+                        SelectPhase::Begin {
+                            position: point_for_position.previous_valid,
+                            add: false,
+                            click_count: 1, // ready to drag state only occurs on click count 1
+                        },
+                        window,
+                        cx,
+                    );
+                    editor.selection_drag_state = SelectionDragState::None;
+                    cx.stop_propagation();
+                    return;
+                } else {
+                    debug_panic!("drag state can never be in ready state after drag")
+                }
+            }
+            SelectionDragState::Dragging { ref selection, .. } => {
+                let snapshot = editor.snapshot(window, cx);
+                let selection_display = selection.map(|anchor| anchor.to_display_point(&snapshot));
+                if !point_for_position.intersects_selection(&selection_display)
+                    && text_hitbox.is_hovered(window)
+                {
+                    let is_cut = !(cfg!(target_os = "macos") && event.modifiers.alt
+                        || cfg!(not(target_os = "macos")) && event.modifiers.control);
+                    editor.move_selection_on_drop(
+                        &selection.clone(),
+                        point_for_position.previous_valid,
+                        is_cut,
+                        window,
+                        cx,
+                    );
+                }
+                editor.selection_drag_state = SelectionDragState::None;
+                cx.stop_propagation();
+                cx.notify();
+                return;
+            }
+            _ => {}
+        }
 
         if end_selection {
             editor.select(SelectPhase::End, window, cx);
@@ -862,13 +939,9 @@ impl EditorElement {
         let text_hitbox = &position_map.text_hitbox;
         let pending_nonempty_selections = editor.has_pending_nonempty_selection();
 
-        let multi_cursor_setting = EditorSettings::get_global(cx).multi_cursor_modifier;
-        let multi_cursor_modifier = match multi_cursor_setting {
-            MultiCursorModifier::Alt => event.modifiers().secondary(),
-            MultiCursorModifier::CmdOrCtrl => event.modifiers().alt,
-        };
+        let hovered_link_modifier = Editor::multi_cursor_modifier(false, &event.modifiers(), cx);
 
-        if !pending_nonempty_selections && multi_cursor_modifier && text_hitbox.is_hovered(window) {
+        if !pending_nonempty_selections && hovered_link_modifier && text_hitbox.is_hovered(window) {
             let point = position_map.point_for_position(event.up.position);
             editor.handle_click_hovered_link(point, event.modifiers(), window, cx);
 
@@ -883,52 +956,122 @@ impl EditorElement {
         window: &mut Window,
         cx: &mut Context<Editor>,
     ) {
-        if !editor.has_pending_selection() {
+        if !editor.has_pending_selection()
+            && matches!(editor.selection_drag_state, SelectionDragState::None)
+        {
             return;
         }
 
-        let text_bounds = position_map.text_hitbox.bounds;
         let point_for_position = position_map.point_for_position(event.position);
-        let mut scroll_delta = gpui::Point::<f32>::default();
-        let vertical_margin = position_map.line_height.min(text_bounds.size.height / 3.0);
-        let top = text_bounds.origin.y + vertical_margin;
-        let bottom = text_bounds.bottom_left().y - vertical_margin;
-        if event.position.y < top {
-            scroll_delta.y = -scale_vertical_mouse_autoscroll_delta(top - event.position.y);
-        }
-        if event.position.y > bottom {
-            scroll_delta.y = scale_vertical_mouse_autoscroll_delta(event.position.y - bottom);
-        }
+        let text_hitbox = &position_map.text_hitbox;
 
-        // We need horizontal width of text
-        let style = editor.style.clone().unwrap_or_default();
-        let font_id = window.text_system().resolve_font(&style.text.font());
-        let font_size = style.text.font_size.to_pixels(window.rem_size());
-        let em_width = window.text_system().em_width(font_id, font_size).unwrap();
+        let scroll_delta = {
+            let text_bounds = text_hitbox.bounds;
+            let mut scroll_delta = gpui::Point::<f32>::default();
+            let vertical_margin = position_map.line_height.min(text_bounds.size.height / 3.0);
+            let top = text_bounds.origin.y + vertical_margin;
+            let bottom = text_bounds.bottom_left().y - vertical_margin;
+            if event.position.y < top {
+                scroll_delta.y = -scale_vertical_mouse_autoscroll_delta(top - event.position.y);
+            }
+            if event.position.y > bottom {
+                scroll_delta.y = scale_vertical_mouse_autoscroll_delta(event.position.y - bottom);
+            }
 
-        let scroll_margin_x = EditorSettings::get_global(cx).horizontal_scroll_margin;
+            // We need horizontal width of text
+            let style = editor.style.clone().unwrap_or_default();
+            let font_id = window.text_system().resolve_font(&style.text.font());
+            let font_size = style.text.font_size.to_pixels(window.rem_size());
+            let em_width = window.text_system().em_width(font_id, font_size).unwrap();
 
-        let scroll_space: Pixels = scroll_margin_x * em_width;
+            let scroll_margin_x = EditorSettings::get_global(cx).horizontal_scroll_margin;
 
-        let left = text_bounds.origin.x + scroll_space;
-        let right = text_bounds.top_right().x - scroll_space;
+            let scroll_space: Pixels = scroll_margin_x * em_width;
 
-        if event.position.x < left {
-            scroll_delta.x = -scale_horizontal_mouse_autoscroll_delta(left - event.position.x);
-        }
-        if event.position.x > right {
-            scroll_delta.x = scale_horizontal_mouse_autoscroll_delta(event.position.x - right);
-        }
+            let left = text_bounds.origin.x + scroll_space;
+            let right = text_bounds.top_right().x - scroll_space;
 
-        editor.select(
-            SelectPhase::Update {
-                position: point_for_position.previous_valid,
-                goal_column: point_for_position.exact_unclipped.column(),
-                scroll_delta,
-            },
-            window,
-            cx,
-        );
+            if event.position.x < left {
+                scroll_delta.x = -scale_horizontal_mouse_autoscroll_delta(left - event.position.x);
+            }
+            if event.position.x > right {
+                scroll_delta.x = scale_horizontal_mouse_autoscroll_delta(event.position.x - right);
+            }
+            scroll_delta
+        };
+
+        if !editor.has_pending_selection() {
+            let drop_anchor = position_map
+                .snapshot
+                .display_point_to_anchor(point_for_position.previous_valid, Bias::Left);
+            match editor.selection_drag_state {
+                SelectionDragState::Dragging {
+                    ref mut drop_cursor,
+                    ref mut hide_drop_cursor,
+                    ..
+                } => {
+                    drop_cursor.start = drop_anchor;
+                    drop_cursor.end = drop_anchor;
+                    *hide_drop_cursor = !text_hitbox.is_hovered(window);
+                    editor.apply_scroll_delta(scroll_delta, window, cx);
+                    cx.notify();
+                }
+                SelectionDragState::ReadyToDrag {
+                    ref selection,
+                    ref click_position,
+                    ref mouse_down_time,
+                } => {
+                    if mouse_down_time.elapsed() >= SELECTION_DRAG_DELAY {
+                        let drop_cursor = Selection {
+                            id: post_inc(&mut editor.selections.next_selection_id),
+                            start: drop_anchor,
+                            end: drop_anchor,
+                            reversed: false,
+                            goal: SelectionGoal::None,
+                        };
+                        editor.selection_drag_state = SelectionDragState::Dragging {
+                            selection: selection.clone(),
+                            drop_cursor,
+                            hide_drop_cursor: false,
+                        };
+                        editor.apply_scroll_delta(scroll_delta, window, cx);
+                        cx.notify();
+                    } else {
+                        let click_point = position_map.point_for_position(*click_position);
+                        editor.selection_drag_state = SelectionDragState::None;
+                        editor.select(
+                            SelectPhase::Begin {
+                                position: click_point.previous_valid,
+                                add: false,
+                                click_count: 1,
+                            },
+                            window,
+                            cx,
+                        );
+                        editor.select(
+                            SelectPhase::Update {
+                                position: point_for_position.previous_valid,
+                                goal_column: point_for_position.exact_unclipped.column(),
+                                scroll_delta,
+                            },
+                            window,
+                            cx,
+                        );
+                    }
+                }
+                _ => {}
+            }
+        } else {
+            editor.select(
+                SelectPhase::Update {
+                    position: point_for_position.previous_valid,
+                    goal_column: point_for_position.exact_unclipped.column(),
+                    scroll_delta,
+                },
+                window,
+                cx,
+            );
+        }
     }
 
     fn mouse_moved(
@@ -941,17 +1084,70 @@ impl EditorElement {
         let text_hitbox = &position_map.text_hitbox;
         let gutter_hitbox = &position_map.gutter_hitbox;
         let modifiers = event.modifiers;
+        let text_hovered = text_hitbox.is_hovered(window);
         let gutter_hovered = gutter_hitbox.is_hovered(window);
         editor.set_gutter_hovered(gutter_hovered, cx);
-        editor.mouse_cursor_hidden = false;
+        editor.show_mouse_cursor(cx);
+
+        let point_for_position = position_map.point_for_position(event.position);
+        let valid_point = point_for_position.previous_valid;
 
-        if gutter_hovered {
-            let new_point = position_map
-                .point_for_position(event.position)
-                .previous_valid;
+        let hovered_diff_control = position_map
+            .diff_hunk_control_bounds
+            .iter()
+            .find(|(_, bounds)| bounds.contains(&event.position))
+            .map(|(row, _)| *row);
+
+        let hovered_diff_hunk_row = if let Some(control_row) = hovered_diff_control {
+            Some(control_row)
+        } else {
+            if text_hovered {
+                let current_row = valid_point.row();
+                position_map.display_hunks.iter().find_map(|(hunk, _)| {
+                    if let DisplayDiffHunk::Unfolded {
+                        display_row_range, ..
+                    } = hunk
+                    {
+                        if display_row_range.contains(&current_row) {
+                            Some(display_row_range.start)
+                        } else {
+                            None
+                        }
+                    } else {
+                        None
+                    }
+                })
+            } else {
+                None
+            }
+        };
+
+        if hovered_diff_hunk_row != editor.hovered_diff_hunk_row {
+            editor.hovered_diff_hunk_row = hovered_diff_hunk_row;
+            cx.notify();
+        }
+
+        if let Some((bounds, blame_entry)) = &position_map.inline_blame_bounds {
+            let mouse_over_inline_blame = bounds.contains(&event.position);
+            let mouse_over_popover = editor
+                .inline_blame_popover
+                .as_ref()
+                .and_then(|state| state.popover_bounds)
+                .map_or(false, |bounds| bounds.contains(&event.position));
+
+            if mouse_over_inline_blame || mouse_over_popover {
+                editor.show_blame_popover(&blame_entry, event.position, cx);
+            } else {
+                editor.hide_blame_popover(cx);
+            }
+        } else {
+            editor.hide_blame_popover(cx);
+        }
+
+        let breakpoint_indicator = if gutter_hovered {
             let buffer_anchor = position_map
                 .snapshot
-                .display_point_to_anchor(new_point, Bias::Left);
+                .display_point_to_anchor(valid_point, Bias::Left);
 
             if let Some((buffer_snapshot, file)) = position_map
                 .snapshot
@@ -959,7 +1155,6 @@ impl EditorElement {
                 .buffer_for_excerpt(buffer_anchor.excerpt_id)
                 .and_then(|buffer| buffer.file().map(|file| (buffer, file)))
             {
-                let was_hovered = editor.gutter_breakpoint_indicator.0.is_some();
                 let as_point = text::ToPoint::to_point(&buffer_anchor.text_anchor, buffer_snapshot);
 
                 let is_visible = editor
@@ -987,43 +1182,46 @@ impl EditorElement {
                             .is_some()
                     });
 
-                editor.gutter_breakpoint_indicator.0 = Some(PhantomBreakpointIndicator {
-                    display_row: new_point.row(),
-                    is_active: is_visible,
-                    collides_with_existing_breakpoint: has_existing_breakpoint,
-                });
-
-                editor.gutter_breakpoint_indicator.1.get_or_insert_with(|| {
-                    cx.spawn(async move |this, cx| {
-                        if !was_hovered {
+                if !is_visible {
+                    editor.gutter_breakpoint_indicator.1.get_or_insert_with(|| {
+                        cx.spawn(async move |this, cx| {
                             cx.background_executor()
                                 .timer(Duration::from_millis(200))
                                 .await;
-                        }
-
-                        this.update(cx, |this, cx| {
-                            if let Some(indicator) = this.gutter_breakpoint_indicator.0.as_mut() {
-                                indicator.is_active = true;
-                            }
 
-                            cx.notify();
+                            this.update(cx, |this, cx| {
+                                if let Some(indicator) = this.gutter_breakpoint_indicator.0.as_mut()
+                                {
+                                    indicator.is_active = true;
+                                    cx.notify();
+                                }
+                            })
+                            .ok();
                         })
-                        .ok();
-                    })
-                });
+                    });
+                }
+
+                Some(PhantomBreakpointIndicator {
+                    display_row: valid_point.row(),
+                    is_active: is_visible,
+                    collides_with_existing_breakpoint: has_existing_breakpoint,
+                })
             } else {
-                editor.gutter_breakpoint_indicator = (None, None);
+                editor.gutter_breakpoint_indicator.1 = None;
+                None
             }
         } else {
-            editor.gutter_breakpoint_indicator = (None, None);
-        }
+            editor.gutter_breakpoint_indicator.1 = None;
+            None
+        };
 
-        cx.notify();
+        if &breakpoint_indicator != &editor.gutter_breakpoint_indicator.0 {
+            editor.gutter_breakpoint_indicator.0 = breakpoint_indicator;
+            cx.notify();
+        }
 
         // Don't trigger hover popover if mouse is hovering over context menu
-        if text_hitbox.is_hovered(window) {
-            let point_for_position = position_map.point_for_position(event.position);
-
+        if text_hovered {
             editor.update_hovered_link(
                 point_for_position,
                 &position_map.snapshot,
@@ -1157,6 +1355,36 @@ impl EditorElement {
 
                 let player = editor.current_user_player_color(cx);
                 selections.push((player, layouts));
+
+                if let SelectionDragState::Dragging {
+                    ref selection,
+                    ref drop_cursor,
+                    ref hide_drop_cursor,
+                } = editor.selection_drag_state
+                {
+                    if !hide_drop_cursor
+                        && (drop_cursor
+                            .start
+                            .cmp(&selection.start, &snapshot.buffer_snapshot)
+                            .eq(&Ordering::Less)
+                            || drop_cursor
+                                .end
+                                .cmp(&selection.end, &snapshot.buffer_snapshot)
+                                .eq(&Ordering::Greater))
+                    {
+                        let drag_cursor_layout = SelectionLayout::new(
+                            drop_cursor.clone(),
+                            false,
+                            CursorShape::Bar,
+                            &snapshot.display_snapshot,
+                            false,
+                            false,
+                            None,
+                        );
+                        let absent_color = cx.theme().players().absent();
+                        selections.push((absent_color, vec![drag_cursor_layout]));
+                    }
+                }
             }
 
             if let Some(collaboration_hub) = &editor.collaboration_hub {
@@ -1337,7 +1565,7 @@ impl EditorElement {
                         snapshot
                             .grapheme_at(cursor_position)
                             .or_else(|| {
-                                if cursor_column == 0 {
+                                if snapshot.is_empty() {
                                     snapshot.placeholder_text().and_then(|s| {
                                         s.graphemes(true).next().map(|s| s.to_string().into())
                                     })
@@ -1345,7 +1573,7 @@ impl EditorElement {
                                     None
                                 }
                             })
-                            .and_then(|text| {
+                            .map(|text| {
                                 let len = text.len();
 
                                 let font = cursor_row_layout
@@ -1371,21 +1599,18 @@ impl EditorElement {
                                     cx.theme().colors().editor_background
                                 };
 
-                                window
-                                    .text_system()
-                                    .shape_line(
-                                        text,
-                                        cursor_row_layout.font_size,
-                                        &[TextRun {
-                                            len,
-                                            font,
-                                            color,
-                                            background_color: None,
-                                            strikethrough: None,
-                                            underline: None,
-                                        }],
-                                    )
-                                    .log_err()
+                                window.text_system().shape_line(
+                                    text,
+                                    cursor_row_layout.font_size,
+                                    &[TextRun {
+                                        len,
+                                        font,
+                                        color,
+                                        background_color: None,
+                                        strikethrough: None,
+                                        underline: None,
+                                    }],
+                                )
                             })
                     } else {
                         None
@@ -1463,7 +1688,10 @@ impl EditorElement {
         window: &mut Window,
         cx: &mut App,
     ) -> Option<EditorScrollbars> {
-        if !self.editor.read(cx).show_scrollbars || self.style.scrollbar_width.is_zero() {
+        let show_scrollbars = self.editor.read(cx).show_scrollbars;
+        if (!show_scrollbars.horizontal && !show_scrollbars.vertical)
+            || self.style.scrollbar_width.is_zero()
+        {
             return None;
         }
 
@@ -1507,8 +1735,24 @@ impl EditorElement {
             ShowScrollbar::Never => return None,
         };
 
+        // The horizontal scrollbar is usually slightly offset to align nicely with
+        // indent guides. However, this offset is not needed if indent guides are
+        // disabled for the current editor.
+        let content_offset = self
+            .editor
+            .read(cx)
+            .show_indent_guides
+            .is_none_or(|should_show| should_show)
+            .then_some(content_offset)
+            .unwrap_or_default();
+
         Some(EditorScrollbars::from_scrollbar_axes(
-            scrollbar_settings.axes,
+            ScrollbarAxes {
+                horizontal: scrollbar_settings.axes.horizontal
+                    && self.editor.read(cx).show_scrollbars.horizontal,
+                vertical: scrollbar_settings.axes.vertical
+                    && self.editor.read(cx).show_scrollbars.vertical,
+            },
             scrollbar_layout_information,
             content_offset,
             scroll_position,
@@ -1531,12 +1775,23 @@ impl EditorElement {
         window: &mut Window,
         cx: &mut App,
     ) -> Option<MinimapLayout> {
-        let minimap_editor = self
-            .editor
-            .read_with(cx, |editor, _| editor.minimap().cloned())?;
+        let minimap_editor = self.editor.read(cx).minimap().cloned()?;
 
         let minimap_settings = EditorSettings::get_global(cx).minimap;
 
+        if minimap_settings.on_active_editor() {
+            let active_editor = self.editor.read(cx).workspace().and_then(|ws| {
+                ws.read(cx)
+                    .active_pane()
+                    .read(cx)
+                    .active_item()
+                    .and_then(|i| i.act_as::<Editor>(cx))
+            });
+            if active_editor.is_some_and(|e| e != self.editor) {
+                return None;
+            }
+        }
+
         if !snapshot.mode.is_full()
             || minimap_width.is_zero()
             || matches!(
@@ -1563,11 +1818,13 @@ impl EditorElement {
             .map(|vertical_scrollbar| vertical_scrollbar.hitbox.origin)
             .unwrap_or_else(|| editor_bounds.top_right());
 
+        let thumb_state = self
+            .editor
+            .read_with(cx, |editor, _| editor.scroll_manager.minimap_thumb_state());
+
         let show_thumb = match minimap_settings.thumb {
             MinimapThumb::Always => true,
-            MinimapThumb::Hover => self.editor.update(cx, |editor, _| {
-                editor.scroll_manager.minimap_thumb_visible()
-            }),
+            MinimapThumb::Hover => thumb_state.is_some(),
         };
 
         let minimap_bounds = Bounds::from_corner_and_size(
@@ -1577,12 +1834,10 @@ impl EditorElement {
         );
         let minimap_line_height = self.get_minimap_line_height(
             minimap_editor
-                .read_with(cx, |editor, _| {
-                    editor
-                        .text_style_refinement
-                        .as_ref()
-                        .and_then(|refinement| refinement.font_size)
-                })
+                .read(cx)
+                .text_style_refinement
+                .as_ref()
+                .and_then(|refinement| refinement.font_size)
                 .unwrap_or(MINIMAP_FONT_SIZE),
             window,
             cx,
@@ -1601,14 +1856,15 @@ impl EditorElement {
         );
 
         let layout = ScrollbarLayout::for_minimap(
-            window.insert_hitbox(minimap_bounds, false),
+            window.insert_hitbox(minimap_bounds, HitboxBehavior::Normal),
             visible_editor_lines,
             total_editor_lines,
             minimap_line_height,
             scroll_position,
             minimap_scroll_top,
             show_thumb,
-        );
+        )
+        .with_thumb_state(thumb_state);
 
         minimap_editor.update(cx, |editor, cx| {
             editor.set_scroll_position(point(0., minimap_scroll_top), window, cx)
@@ -1656,6 +1912,40 @@ impl EditorElement {
         text_style.line_height_in_pixels(rem_size)
     }
 
+    fn get_minimap_width(
+        &self,
+        minimap_settings: &Minimap,
+        scrollbars_shown: bool,
+        text_width: Pixels,
+        em_width: Pixels,
+        font_size: Pixels,
+        rem_size: Pixels,
+        cx: &App,
+    ) -> Option<Pixels> {
+        if minimap_settings.show == ShowMinimap::Auto && !scrollbars_shown {
+            return None;
+        }
+
+        let minimap_font_size = self.editor.read_with(cx, |editor, cx| {
+            editor.minimap().map(|minimap_editor| {
+                minimap_editor
+                    .read(cx)
+                    .text_style_refinement
+                    .as_ref()
+                    .and_then(|refinement| refinement.font_size)
+                    .unwrap_or(MINIMAP_FONT_SIZE)
+            })
+        })?;
+
+        let minimap_em_width = em_width * (minimap_font_size.to_pixels(rem_size) / font_size);
+
+        let minimap_width = (text_width * MinimapLayout::MINIMAP_WIDTH_PCT)
+            .min(minimap_em_width * minimap_settings.max_width_columns.get() as f32);
+
+        (minimap_width >= minimap_em_width * MinimapLayout::MINIMAP_MIN_WIDTH_COLUMNS)
+            .then_some(minimap_width)
+    }
+
     fn prepaint_crease_toggles(
         &self,
         crease_toggles: &mut [Option<AnyElement>],
@@ -1771,7 +2061,7 @@ impl EditorElement {
                 if matches!(hunk, DisplayDiffHunk::Unfolded { .. }) {
                     let hunk_bounds =
                         Self::diff_hunk_bounds(snapshot, line_height, gutter_hitbox.bounds, hunk);
-                    *hitbox = Some(window.insert_hitbox(hunk_bounds, true));
+                    *hitbox = Some(window.insert_hitbox(hunk_bounds, HitboxBehavior::BlockMouse));
                 }
             }
         }
@@ -1935,6 +2225,159 @@ impl EditorElement {
         elements
     }
 
+    fn layout_inline_code_actions(
+        &self,
+        display_point: DisplayPoint,
+        content_origin: gpui::Point<Pixels>,
+        scroll_pixel_position: gpui::Point<Pixels>,
+        line_height: Pixels,
+        snapshot: &EditorSnapshot,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Option<AnyElement> {
+        if !snapshot
+            .show_code_actions
+            .unwrap_or(EditorSettings::get_global(cx).inline_code_actions)
+        {
+            return None;
+        }
+
+        let icon_size = ui::IconSize::XSmall;
+        let mut button = self.editor.update(cx, |editor, cx| {
+            editor.available_code_actions.as_ref()?;
+            let active = editor
+                .context_menu
+                .borrow()
+                .as_ref()
+                .and_then(|menu| {
+                    if let crate::CodeContextMenu::CodeActions(CodeActionsMenu {
+                        deployed_from,
+                        ..
+                    }) = menu
+                    {
+                        deployed_from.as_ref()
+                    } else {
+                        None
+                    }
+                })
+                .map_or(false, |source| {
+                    matches!(source, CodeActionSource::Indicator(..))
+                });
+            Some(editor.render_inline_code_actions(icon_size, display_point.row(), active, cx))
+        })?;
+
+        let buffer_point = display_point.to_point(&snapshot.display_snapshot);
+
+        // do not show code action for folded line
+        if snapshot.is_line_folded(MultiBufferRow(buffer_point.row)) {
+            return None;
+        }
+
+        // do not show code action for blank line with cursor
+        let line_indent = snapshot
+            .display_snapshot
+            .buffer_snapshot
+            .line_indent_for_row(MultiBufferRow(buffer_point.row));
+        if line_indent.is_line_blank() {
+            return None;
+        }
+
+        const INLINE_SLOT_CHAR_LIMIT: u32 = 4;
+        const MAX_ALTERNATE_DISTANCE: u32 = 8;
+
+        let excerpt_id = snapshot
+            .display_snapshot
+            .buffer_snapshot
+            .excerpt_containing(buffer_point..buffer_point)
+            .map(|excerpt| excerpt.id());
+
+        let is_valid_row = |row_candidate: u32| -> bool {
+            // move to other row if folded row
+            if snapshot.is_line_folded(MultiBufferRow(row_candidate)) {
+                return false;
+            }
+            if buffer_point.row == row_candidate {
+                // move to other row if cursor is in slot
+                if buffer_point.column < INLINE_SLOT_CHAR_LIMIT {
+                    return false;
+                }
+            } else {
+                let candidate_point = MultiBufferPoint {
+                    row: row_candidate,
+                    column: 0,
+                };
+                let candidate_excerpt_id = snapshot
+                    .display_snapshot
+                    .buffer_snapshot
+                    .excerpt_containing(candidate_point..candidate_point)
+                    .map(|excerpt| excerpt.id());
+                // move to other row if different excerpt
+                if excerpt_id != candidate_excerpt_id {
+                    return false;
+                }
+            }
+            let line_indent = snapshot
+                .display_snapshot
+                .buffer_snapshot
+                .line_indent_for_row(MultiBufferRow(row_candidate));
+            // use this row if it's blank
+            if line_indent.is_line_blank() {
+                true
+            } else {
+                // use this row if code starts after slot
+                let indent_size = snapshot
+                    .display_snapshot
+                    .buffer_snapshot
+                    .indent_size_for_line(MultiBufferRow(row_candidate));
+                indent_size.len >= INLINE_SLOT_CHAR_LIMIT
+            }
+        };
+
+        let new_buffer_row = if is_valid_row(buffer_point.row) {
+            Some(buffer_point.row)
+        } else {
+            let max_row = snapshot.display_snapshot.buffer_snapshot.max_point().row;
+            (1..=MAX_ALTERNATE_DISTANCE).find_map(|offset| {
+                let row_above = buffer_point.row.saturating_sub(offset);
+                let row_below = buffer_point.row + offset;
+                if row_above != buffer_point.row && is_valid_row(row_above) {
+                    Some(row_above)
+                } else if row_below <= max_row && is_valid_row(row_below) {
+                    Some(row_below)
+                } else {
+                    None
+                }
+            })
+        }?;
+
+        let new_display_row = snapshot
+            .display_snapshot
+            .point_to_display_point(
+                Point {
+                    row: new_buffer_row,
+                    column: buffer_point.column,
+                },
+                text::Bias::Left,
+            )
+            .row();
+
+        let start_y = content_origin.y
+            + ((new_display_row.as_f32() - (scroll_pixel_position.y / line_height)) * line_height)
+            + (line_height / 2.0)
+            - (icon_size.square(window, cx) / 2.);
+        let start_x = content_origin.x - scroll_pixel_position.x + (window.rem_size() * 0.1);
+
+        let absolute_offset = gpui::point(start_x, start_y);
+        button.layout_as_root(gpui::AvailableSpace::min_size(), window, cx);
+        button.prepaint_as_root(
+            absolute_offset,
+            gpui::AvailableSpace::min_size(),
+            window,
+            cx,
+        );
+        Some(button)
+    }
+
     fn layout_inline_blame(
         &self,
         display_row: DisplayRow,

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

@@ -786,7 +786,7 @@ mod tests {
             })
             .await
             .unwrap();
-        let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id());
+        let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
 
         let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
 
@@ -896,7 +896,7 @@ mod tests {
             })
             .await
             .unwrap();
-        let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id());
+        let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
 
         let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
 

crates/editor/src/highlight_matching_bracket.rs 🔗

@@ -19,6 +19,11 @@ pub fn refresh_matching_bracket_highlights(
 
     let snapshot = editor.snapshot(window, cx);
     let head = newest_selection.head();
+    if head > snapshot.buffer_snapshot.len() {
+        log::error!("bug: cursor offset is out of range while refreshing bracket highlights");
+        return;
+    }
+
     let mut tail = head;
     if (editor.cursor_shape == CursorShape::Block || editor.cursor_shape == CursorShape::Hollow)
         && head < snapshot.buffer_snapshot.len()
@@ -35,7 +40,7 @@ pub fn refresh_matching_bracket_highlights(
                 opening_range.to_anchors(&snapshot.buffer_snapshot),
                 closing_range.to_anchors(&snapshot.buffer_snapshot),
             ],
-            |theme| theme.editor_document_highlight_bracket_background,
+            |theme| theme.colors().editor_document_highlight_bracket_background,
             cx,
         )
     }

crates/editor/src/hover_links.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{
     Anchor, Editor, EditorSettings, EditorSnapshot, FindAllReferences, GoToDefinition,
     GoToTypeDefinition, GotoDefinitionKind, InlayId, Navigated, PointForPosition, SelectPhase,
-    editor_settings::{GoToDefinitionFallback, MultiCursorModifier},
+    editor_settings::GoToDefinitionFallback,
     hover_popover::{self, InlayHover},
     scroll::ScrollAmount,
 };
@@ -120,11 +120,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let multi_cursor_setting = EditorSettings::get_global(cx).multi_cursor_modifier;
-        let hovered_link_modifier = match multi_cursor_setting {
-            MultiCursorModifier::Alt => modifiers.secondary(),
-            MultiCursorModifier::CmdOrCtrl => modifiers.alt,
-        };
+        let hovered_link_modifier = Editor::multi_cursor_modifier(false, &modifiers, cx);
         if !hovered_link_modifier || self.has_pending_selection() {
             self.hide_hovered_link(cx);
             return;
@@ -539,7 +535,7 @@ pub fn show_link_definition(
             let result = match &trigger_point {
                 TriggerPoint::Text(_) => {
                     if let Some((url_range, url)) = find_url(&buffer, buffer_position, cx.clone()) {
-                        this.update(cx, |_, _| {
+                        this.read_with(cx, |_, _| {
                             let range = maybe!({
                                 let start =
                                     snapshot.anchor_in_excerpt(excerpt_id, url_range.start)?;
@@ -649,7 +645,7 @@ pub fn show_link_definition(
                 }
             })?;
 
-            Ok::<_, anyhow::Error>(())
+            anyhow::Ok(())
         }
         .log_err()
         .await
@@ -665,7 +661,7 @@ pub(crate) fn find_url(
 ) -> Option<(Range<text::Anchor>, String)> {
     const LIMIT: usize = 2048;
 
-    let Ok(snapshot) = buffer.update(&mut cx, |buffer, _| buffer.snapshot()) else {
+    let Ok(snapshot) = buffer.read_with(&mut cx, |buffer, _| buffer.snapshot()) else {
         return None;
     };
 
@@ -727,7 +723,7 @@ pub(crate) fn find_url_from_range(
 ) -> Option<String> {
     const LIMIT: usize = 2048;
 
-    let Ok(snapshot) = buffer.update(&mut cx, |buffer, _| buffer.snapshot()) else {
+    let Ok(snapshot) = buffer.read_with(&mut cx, |buffer, _| buffer.snapshot()) else {
         return None;
     };
 
@@ -786,7 +782,7 @@ pub(crate) async fn find_file(
     cx: &mut AsyncWindowContext,
 ) -> Option<(Range<text::Anchor>, ResolvedPath)> {
     let project = project?;
-    let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()).ok()?;
+    let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()).ok()?;
     let scope = snapshot.language_scope_at(position);
     let (range, candidate_file_path) = surrounding_filename(snapshot, position)?;
 
@@ -1261,7 +1257,7 @@ mod tests {
             let snapshot = editor.buffer().read(cx).snapshot(cx);
             let anchor_range = snapshot.anchor_before(selection_range.start)
                 ..snapshot.anchor_after(selection_range.end);
-            editor.change_selections(Some(crate::Autoscroll::fit()), window, cx, |s| {
+            editor.change_selections(Default::default(), window, cx, |s| {
                 s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character)
             });
         });

crates/editor/src/hover_popover.rs 🔗

@@ -3,8 +3,9 @@ use crate::{
     EditorSnapshot, GlobalDiagnosticRenderer, Hover,
     display_map::{InlayOffset, ToDisplayPoint, invisibles::is_invisible},
     hover_links::{InlayHighlight, RangeInEditor},
-    scroll::{Autoscroll, ScrollAmount},
+    scroll::ScrollAmount,
 };
+use anyhow::Context as _;
 use gpui::{
     AnyElement, AsyncWindowContext, Context, Entity, Focusable as _, FontWeight, Hsla,
     InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size,
@@ -164,7 +165,7 @@ pub fn hover_at_inlay(
                     this.hover_state.diagnostic_popover = None;
                 })?;
 
-                let language_registry = project.update(cx, |p, _| p.languages().clone())?;
+                let language_registry = project.read_with(cx, |p, _| p.languages().clone())?;
                 let blocks = vec![inlay_hover.tooltip];
                 let parsed_content = parse_blocks(&blocks, &language_registry, None, cx).await;
 
@@ -341,7 +342,7 @@ fn show_hover(
                         .and_then(|renderer| {
                             renderer.render_hover(group, point_range, buffer_id, cx)
                         })
-                        .ok_or_else(|| anyhow::anyhow!("no rendered diagnostic"))
+                        .context("no rendered diagnostic")
                 })??;
 
                 let (background_color, border_color) = cx.update(|_, cx| {
@@ -519,7 +520,7 @@ fn show_hover(
                     // Highlight the selected symbol using a background highlight
                     editor.highlight_background::<HoverState>(
                         &hover_highlights,
-                        |theme| theme.element_hover, // todo update theme
+                        |theme| theme.colors().element_hover, // todo update theme
                         cx,
                     );
                 }
@@ -582,13 +583,6 @@ async fn parse_blocks(
     language: Option<Arc<Language>>,
     cx: &mut AsyncWindowContext,
 ) -> Option<Entity<Markdown>> {
-    let fallback_language_name = if let Some(ref l) = language {
-        let l = Arc::clone(l);
-        Some(l.lsp_id().clone())
-    } else {
-        None
-    };
-
     let combined_text = blocks
         .iter()
         .map(|block| match &block.kind {
@@ -606,7 +600,7 @@ async fn parse_blocks(
             Markdown::new(
                 combined_text.into(),
                 Some(language_registry.clone()),
-                fallback_language_name,
+                language.map(|language| language.name()),
                 cx,
             )
         })
@@ -654,7 +648,7 @@ pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
             ..Default::default()
         },
         syntax: cx.theme().syntax().clone(),
-        selection_background_color: { cx.theme().players().local().selection },
+        selection_background_color: cx.theme().colors().element_selection_background,
         heading: StyleRefinement::default()
             .font_weight(FontWeight::BOLD)
             .text_base()
@@ -703,7 +697,7 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
             ..Default::default()
         },
         syntax: cx.theme().syntax().clone(),
-        selection_background_color: { cx.theme().players().local().selection },
+        selection_background_color: cx.theme().colors().element_selection_background,
         height_is_multiple_of_line_height: true,
         heading: StyleRefinement::default()
             .font_weight(FontWeight::BOLD)
@@ -752,7 +746,7 @@ pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App)
                         };
                         editor.update_in(cx, |editor, window, cx| {
                             editor.change_selections(
-                                Some(Autoscroll::fit()),
+                                Default::default(),
                                 window,
                                 cx,
                                 |selections| {
@@ -875,6 +869,7 @@ impl InfoPopover {
         let keyboard_grace = Rc::clone(&self.keyboard_grace);
         div()
             .id("info_popover")
+            .occlude()
             .elevation_2(cx)
             // Prevent a mouse down/move on the popover from being propagated to the editor,
             // because that would dismiss the popover.
@@ -884,6 +879,7 @@ impl InfoPopover {
                 *keyboard_grace = false;
                 cx.stop_propagation();
             })
+            .p_2()
             .when_some(self.parsed_content.clone(), |this, markdown| {
                 this.child(
                     div()
@@ -891,7 +887,6 @@ impl InfoPopover {
                         .overflow_y_scroll()
                         .max_w(max_size.width)
                         .max_h(max_size.height)
-                        .p_2()
                         .track_scroll(&self.scroll_handle)
                         .child(
                             MarkdownElement::new(markdown, hover_markdown_style(window, cx))
@@ -1056,7 +1051,9 @@ mod tests {
 
                 for (range, event) in slice.iter() {
                     match event {
-                        MarkdownEvent::SubstitutedText(parsed) => rendered_text.push_str(parsed),
+                        MarkdownEvent::SubstitutedText(parsed) => {
+                            rendered_text.push_str(parsed.as_str())
+                        }
                         MarkdownEvent::Text | MarkdownEvent::Code => {
                             rendered_text.push_str(&text[range.clone()])
                         }
@@ -1099,14 +1096,15 @@ mod tests {
         //prompt autocompletion menu
         cx.simulate_keystroke(".");
         handle_completion_request(
-            &mut cx,
             indoc! {"
                         one.|<>
                         two
                         three
                     "},
             vec!["first_completion", "second_completion"],
+            true,
             counter.clone(),
+            &mut cx,
         )
         .await;
         cx.condition(|editor, _| editor.context_menu_visible()) // wait until completion menu is visible

crates/editor/src/indent_guides.rs 🔗

@@ -1,9 +1,9 @@
-use std::{ops::Range, time::Duration};
+use std::{cmp::Ordering, ops::Range, time::Duration};
 
 use collections::HashSet;
 use gpui::{App, AppContext as _, Context, Task, Window};
 use language::language_settings::language_settings;
-use multi_buffer::{IndentGuide, MultiBufferRow};
+use multi_buffer::{IndentGuide, MultiBufferRow, ToPoint};
 use text::{LineIndent, Point};
 use util::ResultExt;
 
@@ -154,12 +154,28 @@ pub fn indent_guides_in_range(
     snapshot: &DisplaySnapshot,
     cx: &App,
 ) -> Vec<IndentGuide> {
-    let start_anchor = snapshot
+    let start_offset = snapshot
         .buffer_snapshot
-        .anchor_before(Point::new(visible_buffer_range.start.0, 0));
-    let end_anchor = snapshot
+        .point_to_offset(Point::new(visible_buffer_range.start.0, 0));
+    let end_offset = snapshot
         .buffer_snapshot
-        .anchor_after(Point::new(visible_buffer_range.end.0, 0));
+        .point_to_offset(Point::new(visible_buffer_range.end.0, 0));
+    let start_anchor = snapshot.buffer_snapshot.anchor_before(start_offset);
+    let end_anchor = snapshot.buffer_snapshot.anchor_after(end_offset);
+
+    let mut fold_ranges = Vec::<Range<Point>>::new();
+    let mut folds = snapshot.folds_in_range(start_offset..end_offset).peekable();
+    while let Some(fold) = folds.next() {
+        let start = fold.range.start.to_point(&snapshot.buffer_snapshot);
+        let end = fold.range.end.to_point(&snapshot.buffer_snapshot);
+        if let Some(last_range) = fold_ranges.last_mut() {
+            if last_range.end >= start {
+                last_range.end = last_range.end.max(end);
+                continue;
+            }
+        }
+        fold_ranges.push(start..end);
+    }
 
     snapshot
         .buffer_snapshot
@@ -169,15 +185,19 @@ pub fn indent_guides_in_range(
                 return false;
             }
 
-            let start = MultiBufferRow(indent_guide.start_row.0.saturating_sub(1));
-            // Filter out indent guides that are inside a fold
-            // All indent guides that are starting "offscreen" have a start value of the first visible row minus one
-            // Therefore checking if a line is folded at first visible row minus one causes the other indent guides that are not related to the fold to disappear as well
-            let is_folded = snapshot.is_line_folded(start);
-            let line_indent = snapshot.line_indent_for_buffer_row(start);
-            let contained_in_fold =
-                line_indent.len(indent_guide.tab_size) <= indent_guide.indent_level();
-            !(is_folded && contained_in_fold)
+            let has_containing_fold = fold_ranges
+                .binary_search_by(|fold_range| {
+                    if fold_range.start >= Point::new(indent_guide.start_row.0, 0) {
+                        Ordering::Greater
+                    } else if fold_range.end < Point::new(indent_guide.end_row.0, 0) {
+                        Ordering::Less
+                    } else {
+                        Ordering::Equal
+                    }
+                })
+                .is_ok();
+
+            !has_containing_fold
         })
         .collect()
 }

crates/editor/src/inlay_hint_cache.rs 🔗

@@ -639,7 +639,7 @@ impl InlayHintCache {
                         if let Some(resolved_hint_task) = resolved_hint_task {
                             let mut resolved_hint =
                                 resolved_hint_task.await.context("hint resolve task")?;
-                            editor.update(cx, |editor, _| {
+                            editor.read_with(cx, |editor, _| {
                                 if let Some(excerpt_hints) =
                                     editor.inlay_hint_cache.hints.get(&excerpt_id)
                                 {
@@ -933,7 +933,7 @@ fn fetch_and_update_hints(
     cx: &mut Context<Editor>,
 ) -> Task<anyhow::Result<()>> {
     cx.spawn(async move |editor, cx|{
-        let buffer_snapshot = excerpt_buffer.update(cx, |buffer, _| buffer.snapshot())?;
+        let buffer_snapshot = excerpt_buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
         let (lsp_request_limiter, multi_buffer_snapshot) =
             editor.update(cx, |editor, cx| {
                 let multi_buffer_snapshot =
@@ -956,7 +956,7 @@ fn fetch_and_update_hints(
             .update(cx, |editor, cx| {
                 if got_throttled {
                     let query_not_around_visible_range = match editor
-                        .excerpts_for_inlay_hints_query(None, cx)
+                        .visible_excerpts(None, cx)
                         .remove(&query.excerpt_id)
                     {
                         Some((_, _, current_visible_range)) => {
@@ -1009,7 +1009,7 @@ fn fetch_and_update_hints(
             .ok()
             .flatten();
 
-        let cached_excerpt_hints = editor.update(cx, |editor, _| {
+        let cached_excerpt_hints = editor.read_with(cx, |editor, _| {
             editor
                 .inlay_hint_cache
                 .hints
@@ -1302,6 +1302,7 @@ fn apply_hint_update(
 
 #[cfg(test)]
 pub mod tests {
+    use crate::SelectionEffects;
     use crate::editor_tests::update_test_language_settings;
     use crate::scroll::ScrollAmount;
     use crate::{ExcerptRange, scroll::Autoscroll, test::editor_lsp_test_context::rust_lang};
@@ -1384,7 +1385,9 @@ pub mod tests {
 
         editor
             .update(cx, |editor, window, cx| {
-                editor.change_selections(None, window, cx, |s| s.select_ranges([13..13]));
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                    s.select_ranges([13..13])
+                });
                 editor.handle_input("some change", window, cx);
             })
             .unwrap();
@@ -1698,7 +1701,9 @@ pub mod tests {
 
         rs_editor
             .update(cx, |editor, window, cx| {
-                editor.change_selections(None, window, cx, |s| s.select_ranges([13..13]));
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                    s.select_ranges([13..13])
+                });
                 editor.handle_input("some rs change", window, cx);
             })
             .unwrap();
@@ -1733,7 +1738,9 @@ pub mod tests {
 
         md_editor
             .update(cx, |editor, window, cx| {
-                editor.change_selections(None, window, cx, |s| s.select_ranges([13..13]));
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                    s.select_ranges([13..13])
+                });
                 editor.handle_input("some md change", window, cx);
             })
             .unwrap();
@@ -2155,7 +2162,9 @@ pub mod tests {
         ] {
             editor
                 .update(cx, |editor, window, cx| {
-                    editor.change_selections(None, window, cx, |s| s.select_ranges([13..13]));
+                    editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                        s.select_ranges([13..13])
+                    });
                     editor.handle_input(change_after_opening, window, cx);
                 })
                 .unwrap();
@@ -2199,7 +2208,9 @@ pub mod tests {
             edits.push(cx.spawn(|mut cx| async move {
                 task_editor
                     .update(&mut cx, |editor, window, cx| {
-                        editor.change_selections(None, window, cx, |s| s.select_ranges([13..13]));
+                        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                            s.select_ranges([13..13])
+                        });
                         editor.handle_input(async_later_change, window, cx);
                     })
                     .unwrap();
@@ -2447,9 +2458,12 @@ pub mod tests {
 
         editor
             .update(cx, |editor, window, cx| {
-                editor.change_selections(Some(Autoscroll::center()), window, cx, |s| {
-                    s.select_ranges([selection_in_cached_range..selection_in_cached_range])
-                });
+                editor.change_selections(
+                    SelectionEffects::scroll(Autoscroll::center()),
+                    window,
+                    cx,
+                    |s| s.select_ranges([selection_in_cached_range..selection_in_cached_range]),
+                );
             })
             .unwrap();
         cx.executor().advance_clock(Duration::from_millis(
@@ -2511,9 +2525,7 @@ pub mod tests {
         cx: &mut gpui::TestAppContext,
     ) -> Range<Point> {
         let ranges = editor
-            .update(cx, |editor, _window, cx| {
-                editor.excerpts_for_inlay_hints_query(None, cx)
-            })
+            .update(cx, |editor, _window, cx| editor.visible_excerpts(None, cx))
             .unwrap();
         assert_eq!(
             ranges.len(),
@@ -2521,7 +2533,7 @@ pub mod tests {
             "Single buffer should produce a single excerpt with visible range"
         );
         let (_, (excerpt_buffer, _, excerpt_visible_range)) = ranges.into_iter().next().unwrap();
-        excerpt_buffer.update(cx, |buffer, _| {
+        excerpt_buffer.read_with(cx, |buffer, _| {
             let snapshot = buffer.snapshot();
             let start = buffer
                 .anchor_before(excerpt_visible_range.start)
@@ -2712,15 +2724,24 @@ pub mod tests {
 
         editor
             .update(cx, |editor, window, cx| {
-                editor.change_selections(Some(Autoscroll::Next), window, cx, |s| {
-                    s.select_ranges([Point::new(4, 0)..Point::new(4, 0)])
-                });
-                editor.change_selections(Some(Autoscroll::Next), window, cx, |s| {
-                    s.select_ranges([Point::new(22, 0)..Point::new(22, 0)])
-                });
-                editor.change_selections(Some(Autoscroll::Next), window, cx, |s| {
-                    s.select_ranges([Point::new(50, 0)..Point::new(50, 0)])
-                });
+                editor.change_selections(
+                    SelectionEffects::scroll(Autoscroll::Next),
+                    window,
+                    cx,
+                    |s| s.select_ranges([Point::new(4, 0)..Point::new(4, 0)]),
+                );
+                editor.change_selections(
+                    SelectionEffects::scroll(Autoscroll::Next),
+                    window,
+                    cx,
+                    |s| s.select_ranges([Point::new(22, 0)..Point::new(22, 0)]),
+                );
+                editor.change_selections(
+                    SelectionEffects::scroll(Autoscroll::Next),
+                    window,
+                    cx,
+                    |s| s.select_ranges([Point::new(50, 0)..Point::new(50, 0)]),
+                );
             })
             .unwrap();
         cx.executor().run_until_parked();
@@ -2745,9 +2766,12 @@ pub mod tests {
 
         editor
             .update(cx, |editor, window, cx| {
-                editor.change_selections(Some(Autoscroll::Next), window, cx, |s| {
-                    s.select_ranges([Point::new(100, 0)..Point::new(100, 0)])
-                });
+                editor.change_selections(
+                    SelectionEffects::scroll(Autoscroll::Next),
+                    window,
+                    cx,
+                    |s| s.select_ranges([Point::new(100, 0)..Point::new(100, 0)]),
+                );
             })
             .unwrap();
         cx.executor().advance_clock(Duration::from_millis(
@@ -2778,9 +2802,12 @@ pub mod tests {
 
         editor
             .update(cx, |editor, window, cx| {
-                editor.change_selections(Some(Autoscroll::Next), window, cx, |s| {
-                    s.select_ranges([Point::new(4, 0)..Point::new(4, 0)])
-                });
+                editor.change_selections(
+                    SelectionEffects::scroll(Autoscroll::Next),
+                    window,
+                    cx,
+                    |s| s.select_ranges([Point::new(4, 0)..Point::new(4, 0)]),
+                );
             })
             .unwrap();
         cx.executor().advance_clock(Duration::from_millis(
@@ -2812,7 +2839,7 @@ pub mod tests {
         editor_edited.store(true, Ordering::Release);
         editor
             .update(cx, |editor, window, cx| {
-                editor.change_selections(None, window, cx, |s| {
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                     s.select_ranges([Point::new(57, 0)..Point::new(57, 0)])
                 });
                 editor.handle_input("++++more text++++", window, cx);
@@ -3130,7 +3157,7 @@ pub mod tests {
         cx.executor().run_until_parked();
         editor
             .update(cx, |editor, window, cx| {
-                editor.change_selections(None, window, cx, |s| {
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                     s.select_ranges([Point::new(10, 0)..Point::new(10, 0)])
                 })
             })
@@ -3412,7 +3439,7 @@ pub mod tests {
         cx.executor().run_until_parked();
         editor
             .update(cx, |editor, window, cx| {
-                editor.change_selections(None, window, cx, |s| {
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                     s.select_ranges([Point::new(10, 0)..Point::new(10, 0)])
                 })
             })

crates/editor/src/inline_completion_tests.rs 🔗

@@ -302,8 +302,8 @@ fn assign_editor_completion_provider(
 }
 
 #[derive(Default, Clone)]
-struct FakeInlineCompletionProvider {
-    completion: Option<inline_completion::InlineCompletion>,
+pub struct FakeInlineCompletionProvider {
+    pub completion: Option<inline_completion::InlineCompletion>,
 }
 
 impl FakeInlineCompletionProvider {

crates/editor/src/items.rs 🔗

@@ -1,6 +1,8 @@
 use crate::{
     Anchor, Autoscroll, Editor, EditorEvent, EditorSettings, ExcerptId, ExcerptRange, FormatTarget,
-    MultiBuffer, MultiBufferSnapshot, NavigationData, SearchWithinRange, ToPoint as _,
+    MultiBuffer, MultiBufferSnapshot, NavigationData, SearchWithinRange, SelectionEffects,
+    ToPoint as _,
+    display_map::HighlightKey,
     editor_settings::SeedQuerySetting,
     persistence::{DB, SerializedEditor},
     scroll::ScrollAnchor,
@@ -40,7 +42,7 @@ use ui::{IconDecorationKind, prelude::*};
 use util::{ResultExt, TryFutureExt, paths::PathExt};
 use workspace::{
     CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId,
-    item::{FollowableItem, Item, ItemEvent, ProjectItem},
+    item::{FollowableItem, Item, ItemEvent, ProjectItem, SaveOptions},
     searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
 };
 use workspace::{
@@ -445,7 +447,7 @@ async fn update_editor_from_message(
             }
 
             multibuffer.remove_excerpts(removed_excerpt_ids, cx);
-            Result::<(), anyhow::Error>::Ok(())
+            anyhow::Ok(())
         })
     })??;
 
@@ -611,12 +613,13 @@ impl Item for Editor {
             if newest_selection.head() == offset {
                 false
             } else {
-                let nav_history = self.nav_history.take();
                 self.set_scroll_anchor(scroll_anchor, window, cx);
-                self.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
-                    s.select_ranges([offset..offset])
-                });
-                self.nav_history = nav_history;
+                self.change_selections(
+                    SelectionEffects::default().nav_history(false),
+                    window,
+                    cx,
+                    |s| s.select_ranges([offset..offset]),
+                );
                 true
             }
         } else {
@@ -775,7 +778,7 @@ impl Item for Editor {
 
     fn deactivated(&mut self, _: &mut Window, cx: &mut Context<Self>) {
         let selection = self.selections.newest_anchor();
-        self.push_to_nav_history(selection.head(), None, true, cx);
+        self.push_to_nav_history(selection.head(), None, true, false, cx);
     }
 
     fn workspace_deactivated(&mut self, _: &mut Window, cx: &mut Context<Self>) {
@@ -805,7 +808,7 @@ impl Item for Editor {
 
     fn save(
         &mut self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -816,13 +819,25 @@ impl Item for Editor {
             .into_iter()
             .map(|handle| handle.read(cx).base_buffer().unwrap_or(handle.clone()))
             .collect::<HashSet<_>>();
+
+        // let mut buffers_to_save =
+        let buffers_to_save = if self.buffer.read(cx).is_singleton() && !options.autosave {
+            buffers.clone()
+        } else {
+            buffers
+                .iter()
+                .filter(|buffer| buffer.read(cx).is_dirty())
+                .cloned()
+                .collect()
+        };
+
         cx.spawn_in(window, async move |this, cx| {
-            if format {
+            if options.format {
                 this.update_in(cx, |editor, window, cx| {
                     editor.perform_format(
                         project.clone(),
                         FormatTrigger::Save,
-                        FormatTarget::Buffers,
+                        FormatTarget::Buffers(buffers_to_save.clone()),
                         window,
                         cx,
                     )
@@ -830,33 +845,28 @@ impl Item for Editor {
                 .await?;
             }
 
-            if buffers.len() == 1 {
-                // Apply full save routine for singleton buffers, to allow to `touch` the file via the editor.
+            if !buffers_to_save.is_empty() {
                 project
-                    .update(cx, |project, cx| project.save_buffers(buffers, cx))?
+                    .update(cx, |project, cx| {
+                        project.save_buffers(buffers_to_save.clone(), cx)
+                    })?
                     .await?;
-            } else {
-                // For multi-buffers, only format and save the buffers with changes.
-                // For clean buffers, we simulate saving by calling `Buffer::did_save`,
-                // so that language servers or other downstream listeners of save events get notified.
-                let (dirty_buffers, clean_buffers) = buffers.into_iter().partition(|buffer| {
-                    buffer
-                        .update(cx, |buffer, _| buffer.is_dirty() || buffer.has_conflict())
-                        .unwrap_or(false)
-                });
+            }
 
-                project
-                    .update(cx, |project, cx| project.save_buffers(dirty_buffers, cx))?
-                    .await?;
-                for buffer in clean_buffers {
-                    buffer
-                        .update(cx, |buffer, cx| {
-                            let version = buffer.saved_version().clone();
-                            let mtime = buffer.saved_mtime();
-                            buffer.did_save(version, mtime, cx);
-                        })
-                        .ok();
-                }
+            // Notify about clean buffers for language server events
+            let buffers_that_were_not_saved: Vec<_> = buffers
+                .into_iter()
+                .filter(|b| !buffers_to_save.contains(b))
+                .collect();
+
+            for buffer in buffers_that_were_not_saved {
+                buffer
+                    .update(cx, |buffer, cx| {
+                        let version = buffer.saved_version().clone();
+                        let mtime = buffer.saved_mtime();
+                        buffer.did_save(version, mtime, cx);
+                    })
+                    .ok();
             }
 
             Ok(())
@@ -1089,7 +1099,7 @@ impl SerializableItem for Editor {
                 let project = project.clone();
                 async move |cx| {
                     let language_registry =
-                        project.update(cx, |project, _| project.languages().clone())?;
+                        project.read_with(cx, |project, _| project.languages().clone())?;
 
                     let language = if let Some(language_name) = language {
                         // We don't fail here, because we'd rather not set the language if the name changed
@@ -1135,7 +1145,7 @@ impl SerializableItem for Editor {
                 mtime,
                 ..
             } => {
-                let project_item = project.update(cx, |project, cx| {
+                let opened_buffer = project.update(cx, |project, cx| {
                     let (worktree, path) = project.find_worktree(&abs_path, cx)?;
                     let project_path = ProjectPath {
                         worktree_id: worktree.read(cx).id(),
@@ -1144,13 +1154,10 @@ impl SerializableItem for Editor {
                     Some(project.open_path(project_path, cx))
                 });
 
-                match project_item {
-                    Some(project_item) => {
+                match opened_buffer {
+                    Some(opened_buffer) => {
                         window.spawn(cx, async move |cx| {
-                            let (_, project_item) = project_item.await?;
-                            let buffer = project_item.downcast::<Buffer>().map_err(|_| {
-                                anyhow!("Project item at stored path was not a buffer")
-                            })?;
+                            let (_, buffer) = opened_buffer.await?;
 
                             // This is a bit wasteful: we're loading the whole buffer from
                             // disk and then overwrite the content.
@@ -1345,7 +1352,7 @@ impl ProjectItem for Editor {
                         cx,
                     );
                     if !restoration_data.selections.is_empty() {
-                        editor.change_selections(None, window, cx, |s| {
+                        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                             s.select_ranges(clip_ranges(&restoration_data.selections, &snapshot));
                         });
                     }
@@ -1425,7 +1432,7 @@ impl SearchableItem for Editor {
 
     fn get_matches(&self, _window: &mut Window, _: &mut App) -> Vec<Range<Anchor>> {
         self.background_highlights
-            .get(&TypeId::of::<BufferSearchHighlights>())
+            .get(&HighlightKey::Type(TypeId::of::<BufferSearchHighlights>()))
             .map_or(Vec::new(), |(_color, ranges)| {
                 ranges.iter().cloned().collect()
             })
@@ -1448,12 +1455,12 @@ impl SearchableItem for Editor {
     ) {
         let existing_range = self
             .background_highlights
-            .get(&TypeId::of::<BufferSearchHighlights>())
+            .get(&HighlightKey::Type(TypeId::of::<BufferSearchHighlights>()))
             .map(|(_, range)| range.as_ref());
         let updated = existing_range != Some(matches);
         self.highlight_background::<BufferSearchHighlights>(
             matches,
-            |theme| theme.search_match_background,
+            |theme| theme.colors().search_match_background,
             cx,
         );
         if updated {
@@ -1514,7 +1521,7 @@ impl SearchableItem for Editor {
     fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context<Self>) -> String {
         let setting = EditorSettings::get_global(cx).seed_search_query_from_cursor;
         let snapshot = &self.snapshot(window, cx).buffer_snapshot;
-        let selection = self.selections.newest::<usize>(cx);
+        let selection = self.selections.newest_adjusted(cx);
 
         match setting {
             SeedQuerySetting::Never => String::new(),
@@ -1551,7 +1558,7 @@ impl SearchableItem for Editor {
     ) {
         self.unfold_ranges(&[matches[index].clone()], false, true, cx);
         let range = self.range_for_match(&matches[index]);
-        self.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+        self.change_selections(Default::default(), window, cx, |s| {
             s.select_ranges([range]);
         })
     }
@@ -1563,7 +1570,7 @@ impl SearchableItem for Editor {
         cx: &mut Context<Self>,
     ) {
         self.unfold_ranges(matches, false, false, cx);
-        self.change_selections(None, window, cx, |s| {
+        self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
             s.select_ranges(matches.iter().cloned())
         });
     }
@@ -1695,7 +1702,7 @@ impl SearchableItem for Editor {
         let buffer = self.buffer().read(cx).snapshot(cx);
         let search_within_ranges = self
             .background_highlights
-            .get(&TypeId::of::<SearchWithinRange>())
+            .get(&HighlightKey::Type(TypeId::of::<SearchWithinRange>()))
             .map_or(vec![], |(_color, ranges)| {
                 ranges.iter().cloned().collect::<Vec<_>>()
             });
@@ -2035,7 +2042,7 @@ mod tests {
         {
             let project = Project::test(fs.clone(), [path!("/file.rs").as_ref()], cx).await;
             // Add Rust to the language, so that we can restore the language of the buffer
-            project.update(cx, |project, _| project.languages().add(rust_language()));
+            project.read_with(cx, |project, _| project.languages().add(rust_language()));
 
             let (workspace, cx) =
                 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));

crates/editor/src/jsx_tag_auto_close.rs 🔗

@@ -8,7 +8,7 @@ use util::ResultExt as _;
 use language::{BufferSnapshot, JsxTagAutoCloseConfig, Node};
 use text::{Anchor, OffsetRangeExt as _};
 
-use crate::Editor;
+use crate::{Editor, SelectionEffects};
 
 pub struct JsxTagCompletionState {
     edit_index: usize,
@@ -316,6 +316,10 @@ pub(crate) fn refresh_enabled_in_any_buffer(
         let multi_buffer = multi_buffer.read(cx);
         let mut found_enabled = false;
         multi_buffer.for_each_buffer(|buffer| {
+            if found_enabled {
+                return;
+            }
+
             let buffer = buffer.read(cx);
             let snapshot = buffer.snapshot();
             for syntax_layer in snapshot.syntax_layers() {
@@ -454,13 +458,12 @@ pub(crate) fn handle_from(
             let ensure_no_edits_since_start = || -> Option<()> {
                 let has_edits_since_start = this
                     .read_with(cx, |this, cx| {
-                        this.buffer.read_with(cx, |buffer, cx| {
-                            buffer.buffer(buffer_id).map_or(true, |buffer| {
-                                buffer.read_with(cx, |buffer, _| {
-                                    buffer.has_edits_since(&buffer_version_initial)
-                                })
+                        this.buffer
+                            .read(cx)
+                            .buffer(buffer_id)
+                            .map_or(true, |buffer| {
+                                buffer.read(cx).has_edits_since(&buffer_version_initial)
                             })
-                        })
                     })
                     .ok()?;
 
@@ -503,9 +506,7 @@ pub(crate) fn handle_from(
             ensure_no_edits_since_start()?;
 
             let multi_buffer_snapshot = this
-                .read_with(cx, |this, cx| {
-                    this.buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx))
-                })
+                .read_with(cx, |this, cx| this.buffer.read(cx).snapshot(cx))
                 .ok()?;
 
             let mut base_selections = Vec::new();
@@ -599,9 +600,14 @@ pub(crate) fn handle_from(
                     })
                     .collect::<Vec<_>>();
                 this.update_in(cx, |this, window, cx| {
-                    this.change_selections_inner(None, false, window, cx, |s| {
-                        s.select(base_selections);
-                    });
+                    this.change_selections(
+                        SelectionEffects::no_scroll().completions(false),
+                        window,
+                        cx,
+                        |s| {
+                            s.select(base_selections);
+                        },
+                    );
                 })
                 .ok()?;
             }
@@ -837,7 +843,7 @@ mod jsx_tag_autoclose_tests {
         let mut cx = EditorTestContext::for_editor(editor, cx).await;
 
         cx.update_editor(|editor, window, cx| {
-            editor.change_selections(None, window, cx, |selections| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
                 selections.select(vec![
                     Selection::from_offset(4),
                     Selection::from_offset(9),

crates/editor/src/lsp_colors.rs 🔗

@@ -0,0 +1,375 @@
+use std::{cmp, ops::Range};
+
+use collections::HashMap;
+use futures::future::join_all;
+use gpui::{Hsla, Rgba};
+use itertools::Itertools;
+use language::point_from_lsp;
+use multi_buffer::Anchor;
+use project::{DocumentColor, lsp_store::ColorFetchStrategy};
+use settings::Settings as _;
+use text::{Bias, BufferId, OffsetRangeExt as _};
+use ui::{App, Context, Window};
+use util::post_inc;
+
+use crate::{
+    DisplayPoint, Editor, EditorSettings, EditorSnapshot, InlayId, InlaySplice, RangeToAnchorExt,
+    display_map::Inlay, editor_settings::DocumentColorsRenderMode,
+};
+
+#[derive(Debug)]
+pub(super) struct LspColorData {
+    cache_version_used: usize,
+    colors: Vec<(Range<Anchor>, DocumentColor, InlayId)>,
+    inlay_colors: HashMap<InlayId, usize>,
+    render_mode: DocumentColorsRenderMode,
+}
+
+impl LspColorData {
+    pub fn new(cx: &App) -> Self {
+        Self {
+            cache_version_used: 0,
+            colors: Vec::new(),
+            inlay_colors: HashMap::default(),
+            render_mode: EditorSettings::get_global(cx).lsp_document_colors,
+        }
+    }
+
+    pub fn render_mode_updated(
+        &mut self,
+        new_render_mode: DocumentColorsRenderMode,
+    ) -> Option<InlaySplice> {
+        if self.render_mode == new_render_mode {
+            return None;
+        }
+        self.render_mode = new_render_mode;
+        match new_render_mode {
+            DocumentColorsRenderMode::Inlay => Some(InlaySplice {
+                to_remove: Vec::new(),
+                to_insert: self
+                    .colors
+                    .iter()
+                    .map(|(range, color, id)| {
+                        Inlay::color(
+                            id.id(),
+                            range.start,
+                            Rgba {
+                                r: color.color.red,
+                                g: color.color.green,
+                                b: color.color.blue,
+                                a: color.color.alpha,
+                            },
+                        )
+                    })
+                    .collect(),
+            }),
+            DocumentColorsRenderMode::None => {
+                self.colors.clear();
+                Some(InlaySplice {
+                    to_remove: self.inlay_colors.drain().map(|(id, _)| id).collect(),
+                    to_insert: Vec::new(),
+                })
+            }
+            DocumentColorsRenderMode::Border | DocumentColorsRenderMode::Background => {
+                Some(InlaySplice {
+                    to_remove: self.inlay_colors.drain().map(|(id, _)| id).collect(),
+                    to_insert: Vec::new(),
+                })
+            }
+        }
+    }
+
+    fn set_colors(&mut self, colors: Vec<(Range<Anchor>, DocumentColor, InlayId)>) -> bool {
+        if self.colors == colors {
+            return false;
+        }
+
+        self.inlay_colors = colors
+            .iter()
+            .enumerate()
+            .map(|(i, (_, _, id))| (*id, i))
+            .collect();
+        self.colors = colors;
+        true
+    }
+
+    pub fn editor_display_highlights(
+        &self,
+        snapshot: &EditorSnapshot,
+    ) -> (DocumentColorsRenderMode, Vec<(Range<DisplayPoint>, Hsla)>) {
+        let render_mode = self.render_mode;
+        let highlights = if render_mode == DocumentColorsRenderMode::None
+            || render_mode == DocumentColorsRenderMode::Inlay
+        {
+            Vec::new()
+        } else {
+            self.colors
+                .iter()
+                .map(|(range, color, _)| {
+                    let display_range = range.clone().to_display_points(snapshot);
+                    let color = Hsla::from(Rgba {
+                        r: color.color.red,
+                        g: color.color.green,
+                        b: color.color.blue,
+                        a: color.color.alpha,
+                    });
+                    (display_range, color)
+                })
+                .collect()
+        };
+        (render_mode, highlights)
+    }
+}
+
+impl Editor {
+    pub(super) fn refresh_colors(
+        &mut self,
+        ignore_cache: bool,
+        buffer_id: Option<BufferId>,
+        _: &Window,
+        cx: &mut Context<Self>,
+    ) {
+        if !self.mode().is_full() {
+            return;
+        }
+        let Some(project) = self.project.clone() else {
+            return;
+        };
+        if self
+            .colors
+            .as_ref()
+            .is_none_or(|colors| colors.render_mode == DocumentColorsRenderMode::None)
+        {
+            return;
+        }
+
+        let visible_buffers = self
+            .visible_excerpts(None, cx)
+            .into_values()
+            .map(|(buffer, ..)| buffer)
+            .filter(|editor_buffer| {
+                buffer_id.is_none_or(|buffer_id| buffer_id == editor_buffer.read(cx).remote_id())
+            })
+            .unique_by(|buffer| buffer.read(cx).remote_id())
+            .collect::<Vec<_>>();
+
+        let all_colors_task = project.read(cx).lsp_store().update(cx, |lsp_store, cx| {
+            visible_buffers
+                .into_iter()
+                .filter_map(|buffer| {
+                    let buffer_id = buffer.read(cx).remote_id();
+                    let fetch_strategy = if ignore_cache {
+                        ColorFetchStrategy::IgnoreCache
+                    } else {
+                        ColorFetchStrategy::UseCache {
+                            known_cache_version: self
+                                .colors
+                                .as_ref()
+                                .map(|colors| colors.cache_version_used),
+                        }
+                    };
+                    let colors_task = lsp_store.document_colors(fetch_strategy, buffer, cx)?;
+                    Some(async move { (buffer_id, colors_task.await) })
+                })
+                .collect::<Vec<_>>()
+        });
+        cx.spawn(async move |editor, cx| {
+            let all_colors = join_all(all_colors_task).await;
+            if all_colors.is_empty() {
+                return;
+            }
+            let Ok((multi_buffer_snapshot, editor_excerpts)) = editor.update(cx, |editor, cx| {
+                let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
+                let editor_excerpts = multi_buffer_snapshot.excerpts().fold(
+                    HashMap::default(),
+                    |mut acc, (excerpt_id, buffer_snapshot, excerpt_range)| {
+                        let excerpt_data = acc
+                            .entry(buffer_snapshot.remote_id())
+                            .or_insert_with(Vec::new);
+                        let excerpt_point_range =
+                            excerpt_range.context.to_point_utf16(&buffer_snapshot);
+                        excerpt_data.push((
+                            excerpt_id,
+                            buffer_snapshot.clone(),
+                            excerpt_point_range,
+                        ));
+                        acc
+                    },
+                );
+                (multi_buffer_snapshot, editor_excerpts)
+            }) else {
+                return;
+            };
+
+            let mut cache_version = None;
+            let mut new_editor_colors = Vec::<(Range<Anchor>, DocumentColor)>::new();
+            for (buffer_id, colors) in all_colors {
+                let Some(excerpts) = editor_excerpts.get(&buffer_id) else {
+                    continue;
+                };
+                match colors {
+                    Ok(colors) => {
+                        cache_version = colors.cache_version;
+                        for color in colors.colors {
+                            let color_start = point_from_lsp(color.lsp_range.start);
+                            let color_end = point_from_lsp(color.lsp_range.end);
+
+                            for (excerpt_id, buffer_snapshot, excerpt_range) in excerpts {
+                                if !excerpt_range.contains(&color_start.0)
+                                    || !excerpt_range.contains(&color_end.0)
+                                {
+                                    continue;
+                                }
+                                let Some(color_start_anchor) = multi_buffer_snapshot
+                                    .anchor_in_excerpt(
+                                        *excerpt_id,
+                                        buffer_snapshot.anchor_before(
+                                            buffer_snapshot
+                                                .clip_point_utf16(color_start, Bias::Left),
+                                        ),
+                                    )
+                                else {
+                                    continue;
+                                };
+                                let Some(color_end_anchor) = multi_buffer_snapshot
+                                    .anchor_in_excerpt(
+                                        *excerpt_id,
+                                        buffer_snapshot.anchor_after(
+                                            buffer_snapshot
+                                                .clip_point_utf16(color_end, Bias::Right),
+                                        ),
+                                    )
+                                else {
+                                    continue;
+                                };
+
+                                let (Ok(i) | Err(i)) =
+                                    new_editor_colors.binary_search_by(|(probe, _)| {
+                                        probe
+                                            .start
+                                            .cmp(&color_start_anchor, &multi_buffer_snapshot)
+                                            .then_with(|| {
+                                                probe
+                                                    .end
+                                                    .cmp(&color_end_anchor, &multi_buffer_snapshot)
+                                            })
+                                    });
+                                new_editor_colors
+                                    .insert(i, (color_start_anchor..color_end_anchor, color));
+                                break;
+                            }
+                        }
+                    }
+                    Err(e) => log::error!("Failed to retrieve document colors: {e}"),
+                }
+            }
+
+            editor
+                .update(cx, |editor, cx| {
+                    let mut colors_splice = InlaySplice::default();
+                    let mut new_color_inlays = Vec::with_capacity(new_editor_colors.len());
+                    let Some(colors) = &mut editor.colors else {
+                        return;
+                    };
+                    let mut existing_colors = colors.colors.iter().peekable();
+                    for (new_range, new_color) in new_editor_colors {
+                        let rgba_color = Rgba {
+                            r: new_color.color.red,
+                            g: new_color.color.green,
+                            b: new_color.color.blue,
+                            a: new_color.color.alpha,
+                        };
+
+                        loop {
+                            match existing_colors.peek() {
+                                Some((existing_range, existing_color, existing_inlay_id)) => {
+                                    match existing_range
+                                        .start
+                                        .cmp(&new_range.start, &multi_buffer_snapshot)
+                                        .then_with(|| {
+                                            existing_range
+                                                .end
+                                                .cmp(&new_range.end, &multi_buffer_snapshot)
+                                        }) {
+                                        cmp::Ordering::Less => {
+                                            colors_splice.to_remove.push(*existing_inlay_id);
+                                            existing_colors.next();
+                                            continue;
+                                        }
+                                        cmp::Ordering::Equal => {
+                                            if existing_color == &new_color {
+                                                new_color_inlays.push((
+                                                    new_range,
+                                                    new_color,
+                                                    *existing_inlay_id,
+                                                ));
+                                            } else {
+                                                colors_splice.to_remove.push(*existing_inlay_id);
+
+                                                let inlay = Inlay::color(
+                                                    post_inc(&mut editor.next_color_inlay_id),
+                                                    new_range.start,
+                                                    rgba_color,
+                                                );
+                                                let inlay_id = inlay.id;
+                                                colors_splice.to_insert.push(inlay);
+                                                new_color_inlays
+                                                    .push((new_range, new_color, inlay_id));
+                                            }
+                                            existing_colors.next();
+                                            break;
+                                        }
+                                        cmp::Ordering::Greater => {
+                                            let inlay = Inlay::color(
+                                                post_inc(&mut editor.next_color_inlay_id),
+                                                new_range.start,
+                                                rgba_color,
+                                            );
+                                            let inlay_id = inlay.id;
+                                            colors_splice.to_insert.push(inlay);
+                                            new_color_inlays.push((new_range, new_color, inlay_id));
+                                            break;
+                                        }
+                                    }
+                                }
+                                None => {
+                                    let inlay = Inlay::color(
+                                        post_inc(&mut editor.next_color_inlay_id),
+                                        new_range.start,
+                                        rgba_color,
+                                    );
+                                    let inlay_id = inlay.id;
+                                    colors_splice.to_insert.push(inlay);
+                                    new_color_inlays.push((new_range, new_color, inlay_id));
+                                    break;
+                                }
+                            }
+                        }
+                    }
+                    if existing_colors.peek().is_some() {
+                        colors_splice
+                            .to_remove
+                            .extend(existing_colors.map(|(_, _, id)| *id));
+                    }
+
+                    let mut updated = colors.set_colors(new_color_inlays);
+                    if let Some(cache_version) = cache_version {
+                        colors.cache_version_used = cache_version;
+                    }
+                    if colors.render_mode == DocumentColorsRenderMode::Inlay
+                        && (!colors_splice.to_insert.is_empty()
+                            || !colors_splice.to_remove.is_empty())
+                    {
+                        editor.splice_inlays(&colors_splice.to_remove, colors_splice.to_insert, cx);
+                        updated = true;
+                    }
+
+                    if updated {
+                        cx.notify();
+                    }
+                })
+                .ok();
+        })
+        .detach();
+    }
+}

crates/editor/src/lsp_ext.rs 🔗

@@ -1,4 +1,5 @@
 use std::sync::Arc;
+use std::time::Duration;
 
 use crate::Editor;
 use collections::HashMap;
@@ -16,10 +17,12 @@ use project::LocationLink;
 use project::Project;
 use project::TaskSourceKind;
 use project::lsp_store::lsp_ext_command::GetLspRunnables;
+use smol::future::FutureExt as _;
 use smol::stream::StreamExt;
 use task::ResolvedTask;
 use task::TaskContext;
 use text::BufferId;
+use ui::SharedString;
 use util::ResultExt as _;
 
 pub(crate) fn find_specific_language_server_in_selection<F>(
@@ -39,8 +42,8 @@ where
         .selections
         .disjoint_anchors()
         .iter()
-        .filter(|selection| selection.start == selection.end)
-        .filter_map(|selection| Some((selection.start, selection.start.buffer_id?)))
+        .filter_map(|selection| Some((selection.head(), selection.head().buffer_id?)))
+        .unique_by(|(_, buffer_id)| *buffer_id)
         .filter_map(|(trigger_anchor, buffer_id)| {
             let buffer = editor.buffer().read(cx).buffer(buffer_id)?;
             let language = buffer.read(cx).language_at(trigger_anchor.text_anchor)?;
@@ -50,7 +53,6 @@ where
                 None
             }
         })
-        .unique_by(|(_, buffer, _)| buffer.read(cx).remote_id())
         .collect::<Vec<_>>();
 
     let applicable_buffer_tasks = applicable_buffers
@@ -81,7 +83,7 @@ async fn lsp_task_context(
     cx: &mut AsyncApp,
 ) -> Option<TaskContext> {
     let worktree_store = project
-        .update(cx, |project, _| project.worktree_store())
+        .read_with(cx, |project, _| project.worktree_store())
         .ok()?;
 
     let worktree_abs_path = cx
@@ -130,44 +132,70 @@ pub fn lsp_tasks(
         .collect::<FuturesUnordered<_>>();
 
     cx.spawn(async move |cx| {
-        let mut lsp_tasks = Vec::new();
-        while let Some(server_to_query) = lsp_task_sources.next().await {
-            if let Some((server_id, buffers)) = server_to_query {
-                let source_kind = TaskSourceKind::Lsp(server_id);
-                let id_base = source_kind.to_id_base();
-                let mut new_lsp_tasks = Vec::new();
-                for buffer in buffers {
-                    let lsp_buffer_context = lsp_task_context(&project, &buffer, cx)
-                        .await
-                        .unwrap_or_default();
-
-                    if let Ok(runnables_task) = project.update(cx, |project, cx| {
-                        let buffer_id = buffer.read(cx).remote_id();
-                        project.request_lsp(
-                            buffer,
-                            LanguageServerToQuery::Other(server_id),
-                            GetLspRunnables {
-                                buffer_id,
-                                position: for_position,
+        cx.spawn(async move |cx| {
+            let mut lsp_tasks = HashMap::default();
+            while let Some(server_to_query) = lsp_task_sources.next().await {
+                if let Some((server_id, buffers)) = server_to_query {
+                    let mut new_lsp_tasks = Vec::new();
+                    for buffer in buffers {
+                        let source_kind = match buffer.update(cx, |buffer, _| {
+                            buffer.language().map(|language| language.name())
+                        }) {
+                            Ok(Some(language_name)) => TaskSourceKind::Lsp {
+                                server: server_id,
+                                language_name: SharedString::from(language_name),
                             },
-                            cx,
-                        )
-                    }) {
-                        if let Some(new_runnables) = runnables_task.await.log_err() {
-                            new_lsp_tasks.extend(new_runnables.runnables.into_iter().filter_map(
-                                |(location, runnable)| {
-                                    let resolved_task =
-                                        runnable.resolve_task(&id_base, &lsp_buffer_context)?;
-                                    Some((location, resolved_task))
+                            Ok(None) => continue,
+                            Err(_) => return Vec::new(),
+                        };
+                        let id_base = source_kind.to_id_base();
+                        let lsp_buffer_context = lsp_task_context(&project, &buffer, cx)
+                            .await
+                            .unwrap_or_default();
+
+                        if let Ok(runnables_task) = project.update(cx, |project, cx| {
+                            let buffer_id = buffer.read(cx).remote_id();
+                            project.request_lsp(
+                                buffer,
+                                LanguageServerToQuery::Other(server_id),
+                                GetLspRunnables {
+                                    buffer_id,
+                                    position: for_position,
                                 },
-                            ));
+                                cx,
+                            )
+                        }) {
+                            if let Some(new_runnables) = runnables_task.await.log_err() {
+                                new_lsp_tasks.extend(
+                                    new_runnables.runnables.into_iter().filter_map(
+                                        |(location, runnable)| {
+                                            let resolved_task = runnable
+                                                .resolve_task(&id_base, &lsp_buffer_context)?;
+                                            Some((location, resolved_task))
+                                        },
+                                    ),
+                                );
+                            }
                         }
+                        lsp_tasks
+                            .entry(source_kind)
+                            .or_insert_with(Vec::new)
+                            .append(&mut new_lsp_tasks);
                     }
                 }
-                lsp_tasks.push((source_kind, new_lsp_tasks));
             }
-        }
-        lsp_tasks
+            lsp_tasks.into_iter().collect()
+        })
+        .race({
+            // `lsp::LSP_REQUEST_TIMEOUT` is larger than we want for the modal to open fast
+            let timer = cx.background_executor().timer(Duration::from_millis(200));
+            async move {
+                timer.await;
+                log::info!("Timed out waiting for LSP tasks");
+                Vec::new()
+            }
+        })
+        .await
     })
 }
 

crates/editor/src/mouse_context_menu.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{
-    Copy, CopyAndTrim, CopyPermalinkToLine, Cut, DebuggerEvaluateSelectedText, DisplayPoint,
-    DisplaySnapshot, Editor, FindAllReferences, GoToDeclaration, GoToDefinition,
-    GoToImplementation, GoToTypeDefinition, Paste, Rename, RevealInFileManager, SelectMode,
+    Copy, CopyAndTrim, CopyPermalinkToLine, Cut, DisplayPoint, DisplaySnapshot, Editor,
+    EvaluateSelectedText, FindAllReferences, GoToDeclaration, GoToDefinition, GoToImplementation,
+    GoToTypeDefinition, Paste, Rename, RevealInFileManager, SelectMode, SelectionEffects,
     SelectionExt, ToDisplayPoint, ToggleCodeActions,
     actions::{Format, FormatSelections},
     selections_collection::SelectionsCollection,
@@ -177,7 +177,7 @@ pub fn deploy_context_menu(
         let anchor = buffer.anchor_before(point.to_point(&display_map));
         if !display_ranges(&display_map, &editor.selections).any(|r| r.contains(&point)) {
             // Move the cursor to the clicked location so that dispatched actions make sense
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.clear_disjoint();
                 s.set_pending_anchor_range(anchor..anchor, SelectMode::Character);
             });
@@ -199,17 +199,14 @@ pub fn deploy_context_menu(
                 .is_some()
         });
 
-        let evaluate_selection = command_palette_hooks::CommandPaletteFilter::try_global(cx)
-            .map_or(false, |filter| {
-                !filter.is_hidden(&DebuggerEvaluateSelectedText)
-            });
+        let evaluate_selection = window.is_action_available(&EvaluateSelectedText, cx);
 
         ui::ContextMenu::build(window, cx, |menu, _window, _cx| {
             let builder = menu
                 .on_blur_subscription(Subscription::new(|| {}))
                 .when(evaluate_selection && has_selections, |builder| {
                     builder
-                        .action("Evaluate Selection", Box::new(DebuggerEvaluateSelectedText))
+                        .action("Evaluate Selection", Box::new(EvaluateSelectedText))
                         .separator()
                 })
                 .action("Go to Definition", Box::new(GoToDefinition))
@@ -226,7 +223,7 @@ pub fn deploy_context_menu(
                 .action(
                     "Show Code Actions",
                     Box::new(ToggleCodeActions {
-                        deployed_from_indicator: None,
+                        deployed_from: None,
                         quick_launch: false,
                     }),
                 )
@@ -278,10 +275,10 @@ pub fn deploy_context_menu(
             cx,
         ),
         None => {
-            let character_size = editor.character_size(window);
+            let character_size = editor.character_dimensions(window);
             let menu_position = MenuPosition::PinnedToEditor {
                 source: source_anchor,
-                offset: gpui::point(character_size.width, character_size.height),
+                offset: gpui::point(character_size.em_width, character_size.line_height),
             };
             Some(MouseContextMenu::new(
                 editor,

crates/editor/src/movement.rs 🔗

@@ -2,7 +2,7 @@
 //! in editor given a given motion (e.g. it handles converting a "move left" command into coordinates in editor). It is exposed mostly for use by vim crate.
 
 use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint};
-use crate::{CharKind, DisplayRow, EditorStyle, ToOffset, ToPoint, scroll::ScrollAnchor};
+use crate::{DisplayRow, EditorStyle, ToOffset, ToPoint, scroll::ScrollAnchor};
 use gpui::{Pixels, WindowTextSystem};
 use language::Point;
 use multi_buffer::{MultiBufferRow, MultiBufferSnapshot};
@@ -58,8 +58,8 @@ pub fn saturating_left(map: &DisplaySnapshot, mut point: DisplayPoint) -> Displa
     map.clip_point(point, Bias::Left)
 }
 
-/// Returns a column to the right of the current point, doing nothing
-// if that point is at the end of the line.
+/// Returns a column to the right of the current point, wrapping
+/// to the next line if that point is at the end of line.
 pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
     if point.column() < map.line_len(point.row()) {
         *point.column_mut() += 1;
@@ -264,7 +264,19 @@ pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> Displa
     let raw_point = point.to_point(map);
     let classifier = map.buffer_snapshot.char_classifier_at(raw_point);
 
+    let mut is_first_iteration = true;
     find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| {
+        // Make alt-left skip punctuation to respect VSCode behaviour. For example: hello.| goes to |hello.
+        if is_first_iteration
+            && classifier.is_punctuation(right)
+            && !classifier.is_punctuation(left)
+            && left != '\n'
+        {
+            is_first_iteration = false;
+            return false;
+        }
+        is_first_iteration = false;
+
         (classifier.kind(left) != classifier.kind(right) && !classifier.is_whitespace(right))
             || left == '\n'
     })
@@ -305,8 +317,19 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis
 pub fn next_word_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
     let raw_point = point.to_point(map);
     let classifier = map.buffer_snapshot.char_classifier_at(raw_point);
-
+    let mut is_first_iteration = true;
     find_boundary(map, point, FindRange::MultiLine, |left, right| {
+        // Make alt-right skip punctuation to respect VSCode behaviour. For example: |.hello goes to .hello|
+        if is_first_iteration
+            && classifier.is_punctuation(left)
+            && !classifier.is_punctuation(right)
+            && right != '\n'
+        {
+            is_first_iteration = false;
+            return false;
+        }
+        is_first_iteration = false;
+
         (classifier.kind(left) != classifier.kind(right) && !classifier.is_whitespace(left))
             || right == '\n'
     })
@@ -698,38 +721,6 @@ pub fn chars_before(
         })
 }
 
-pub(crate) fn is_inside_word(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
-    let raw_point = point.to_point(map);
-    let classifier = map.buffer_snapshot.char_classifier_at(raw_point);
-    let ix = map.clip_point(point, Bias::Left).to_offset(map, Bias::Left);
-    let text = &map.buffer_snapshot;
-    let next_char_kind = text.chars_at(ix).next().map(|c| classifier.kind(c));
-    let prev_char_kind = text
-        .reversed_chars_at(ix)
-        .next()
-        .map(|c| classifier.kind(c));
-    prev_char_kind.zip(next_char_kind) == Some((CharKind::Word, CharKind::Word))
-}
-
-pub(crate) fn surrounding_word(
-    map: &DisplaySnapshot,
-    position: DisplayPoint,
-) -> Range<DisplayPoint> {
-    let position = map
-        .clip_point(position, Bias::Left)
-        .to_offset(map, Bias::Left);
-    let (range, _) = map.buffer_snapshot.surrounding_word(position, false);
-    let start = range
-        .start
-        .to_point(&map.buffer_snapshot)
-        .to_display_point(map);
-    let end = range
-        .end
-        .to_point(&map.buffer_snapshot)
-        .to_display_point(map);
-    start..end
-}
-
 /// Returns a list of lines (represented as a [`DisplayPoint`] range) contained
 /// within a passed range.
 ///
@@ -766,7 +757,7 @@ pub fn split_display_range_by_lines(
 mod tests {
     use super::*;
     use crate::{
-        Buffer, DisplayMap, DisplayRow, ExcerptRange, FoldPlaceholder, InlayId, MultiBuffer,
+        Buffer, DisplayMap, DisplayRow, ExcerptRange, FoldPlaceholder, MultiBuffer,
         display_map::Inlay,
         test::{editor_test_context::EditorTestContext, marked_display_snapshot},
     };
@@ -782,10 +773,15 @@ mod tests {
 
         fn assert(marked_text: &str, cx: &mut gpui::App) {
             let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
-            assert_eq!(
-                previous_word_start(&snapshot, display_points[1]),
-                display_points[0]
-            );
+            let actual = previous_word_start(&snapshot, display_points[1]);
+            let expected = display_points[0];
+            if actual != expected {
+                eprintln!(
+                    "previous_word_start mismatch for '{}': actual={:?}, expected={:?}",
+                    marked_text, actual, expected
+                );
+            }
+            assert_eq!(actual, expected);
         }
 
         assert("\nˇ   ˇlorem", cx);
@@ -796,12 +792,17 @@ mod tests {
         assert("\nlorem\nˇ   ˇipsum", cx);
         assert("\n\nˇ\nˇ", cx);
         assert("    ˇlorem  ˇipsum", cx);
-        assert("loremˇ-ˇipsum", cx);
+        assert("ˇlorem-ˇipsum", cx);
         assert("loremˇ-#$@ˇipsum", cx);
         assert("ˇlorem_ˇipsum", cx);
         assert(" ˇdefγˇ", cx);
         assert(" ˇbcΔˇ", cx);
-        assert(" abˇ——ˇcd", cx);
+        // Test punctuation skipping behavior
+        assert("ˇhello.ˇ", cx);
+        assert("helloˇ...ˇ", cx);
+        assert("helloˇ.---..ˇtest", cx);
+        assert("test  ˇ.--ˇtest", cx);
+        assert("oneˇ,;:!?ˇtwo", cx);
     }
 
     #[gpui::test]
@@ -906,26 +907,26 @@ mod tests {
         let inlays = (0..buffer_snapshot.len())
             .flat_map(|offset| {
                 [
-                    Inlay {
-                        id: InlayId::InlineCompletion(post_inc(&mut id)),
-                        position: buffer_snapshot.anchor_at(offset, Bias::Left),
-                        text: "test".into(),
-                    },
-                    Inlay {
-                        id: InlayId::InlineCompletion(post_inc(&mut id)),
-                        position: buffer_snapshot.anchor_at(offset, Bias::Right),
-                        text: "test".into(),
-                    },
-                    Inlay {
-                        id: InlayId::Hint(post_inc(&mut id)),
-                        position: buffer_snapshot.anchor_at(offset, Bias::Left),
-                        text: "test".into(),
-                    },
-                    Inlay {
-                        id: InlayId::Hint(post_inc(&mut id)),
-                        position: buffer_snapshot.anchor_at(offset, Bias::Right),
-                        text: "test".into(),
-                    },
+                    Inlay::inline_completion(
+                        post_inc(&mut id),
+                        buffer_snapshot.anchor_at(offset, Bias::Left),
+                        "test",
+                    ),
+                    Inlay::inline_completion(
+                        post_inc(&mut id),
+                        buffer_snapshot.anchor_at(offset, Bias::Right),
+                        "test",
+                    ),
+                    Inlay::mock_hint(
+                        post_inc(&mut id),
+                        buffer_snapshot.anchor_at(offset, Bias::Left),
+                        "test",
+                    ),
+                    Inlay::mock_hint(
+                        post_inc(&mut id),
+                        buffer_snapshot.anchor_at(offset, Bias::Right),
+                        "test",
+                    ),
                 ]
             })
             .collect();
@@ -955,10 +956,15 @@ mod tests {
 
         fn assert(marked_text: &str, cx: &mut gpui::App) {
             let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
-            assert_eq!(
-                next_word_end(&snapshot, display_points[0]),
-                display_points[1]
-            );
+            let actual = next_word_end(&snapshot, display_points[0]);
+            let expected = display_points[1];
+            if actual != expected {
+                eprintln!(
+                    "next_word_end mismatch for '{}': actual={:?}, expected={:?}",
+                    marked_text, actual, expected
+                );
+            }
+            assert_eq!(actual, expected);
         }
 
         assert("\nˇ   loremˇ", cx);
@@ -967,11 +973,18 @@ mod tests {
         assert("    loremˇ    ˇ\nipsum\n", cx);
         assert("\nˇ\nˇ\n\n", cx);
         assert("loremˇ    ipsumˇ   ", cx);
-        assert("loremˇ-ˇipsum", cx);
+        assert("loremˇ-ipsumˇ", cx);
         assert("loremˇ#$@-ˇipsum", cx);
         assert("loremˇ_ipsumˇ", cx);
         assert(" ˇbcΔˇ", cx);
         assert(" abˇ——ˇcd", cx);
+        // Test punctuation skipping behavior
+        assert("ˇ.helloˇ", cx);
+        assert("display_pointsˇ[0ˇ]", cx);
+        assert("ˇ...ˇhello", cx);
+        assert("helloˇ.---..ˇtest", cx);
+        assert("testˇ.--ˇ test", cx);
+        assert("oneˇ,;:!?ˇtwo", cx);
     }
 
     #[gpui::test]
@@ -1046,30 +1059,6 @@ mod tests {
         });
     }
 
-    #[gpui::test]
-    fn test_surrounding_word(cx: &mut gpui::App) {
-        init_test(cx);
-
-        fn assert(marked_text: &str, cx: &mut gpui::App) {
-            let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
-            assert_eq!(
-                surrounding_word(&snapshot, display_points[1]),
-                display_points[0]..display_points[2],
-                "{}",
-                marked_text
-            );
-        }
-
-        assert("ˇˇloremˇ  ipsum", cx);
-        assert("ˇloˇremˇ  ipsum", cx);
-        assert("ˇloremˇˇ  ipsum", cx);
-        assert("loremˇ ˇ  ˇipsum", cx);
-        assert("lorem\nˇˇˇ\nipsum", cx);
-        assert("lorem\nˇˇipsumˇ", cx);
-        assert("loremˇ,ˇˇ ipsum", cx);
-        assert("ˇloremˇˇ, ipsum", cx);
-    }
-
     #[gpui::test]
     async fn test_move_up_and_down_with_excerpts(cx: &mut gpui::TestAppContext) {
         cx.update(|cx| {

crates/editor/src/proposed_changes_editor.rs 🔗

@@ -1,4 +1,4 @@
-use crate::{ApplyAllDiffHunks, Editor, EditorEvent, SemanticsProvider};
+use crate::{ApplyAllDiffHunks, Editor, EditorEvent, SelectionEffects, SemanticsProvider};
 use buffer_diff::BufferDiff;
 use collections::HashSet;
 use futures::{channel::mpsc, future::join_all};
@@ -12,7 +12,7 @@ use text::ToOffset;
 use ui::{ButtonLike, KeyBinding, prelude::*};
 use workspace::{
     Item, ItemHandle as _, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
-    searchable::SearchableItemHandle,
+    item::SaveOptions, searchable::SearchableItemHandle,
 };
 
 pub struct ProposedChangesEditor {
@@ -213,7 +213,9 @@ impl ProposedChangesEditor {
 
         self.buffer_entries = buffer_entries;
         self.editor.update(cx, |editor, cx| {
-            editor.change_selections(None, window, cx, |selections| selections.refresh());
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
+                selections.refresh()
+            });
             editor.buffer.update(cx, |buffer, cx| {
                 for diff in new_diffs {
                     buffer.add_diff(diff, cx)
@@ -351,13 +353,13 @@ impl Item for ProposedChangesEditor {
 
     fn save(
         &mut self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) -> Task<gpui::Result<()>> {
+    ) -> Task<anyhow::Result<()>> {
         self.editor.update(cx, |editor, cx| {
-            Item::save(editor, format, project, window, cx)
+            Item::save(editor, options, project, window, cx)
         })
     }
 }
@@ -488,7 +490,7 @@ impl SemanticsProvider for BranchBufferSemanticsProvider {
         buffer: &Entity<Buffer>,
         position: text::Anchor,
         cx: &mut App,
-    ) -> Option<Task<gpui::Result<Vec<project::DocumentHighlight>>>> {
+    ) -> Option<Task<anyhow::Result<Vec<project::DocumentHighlight>>>> {
         let buffer = self.to_base(&buffer, &[position], cx)?;
         self.0.document_highlights(&buffer, position, cx)
     }
@@ -499,7 +501,7 @@ impl SemanticsProvider for BranchBufferSemanticsProvider {
         position: text::Anchor,
         kind: crate::GotoDefinitionKind,
         cx: &mut App,
-    ) -> Option<Task<gpui::Result<Vec<project::LocationLink>>>> {
+    ) -> Option<Task<anyhow::Result<Vec<project::LocationLink>>>> {
         let buffer = self.to_base(&buffer, &[position], cx)?;
         self.0.definitions(&buffer, position, kind, cx)
     }
@@ -509,7 +511,7 @@ impl SemanticsProvider for BranchBufferSemanticsProvider {
         _: &Entity<Buffer>,
         _: text::Anchor,
         _: &mut App,
-    ) -> Option<Task<gpui::Result<Option<Range<text::Anchor>>>>> {
+    ) -> Option<Task<anyhow::Result<Option<Range<text::Anchor>>>>> {
         None
     }
 
@@ -519,7 +521,7 @@ impl SemanticsProvider for BranchBufferSemanticsProvider {
         _: text::Anchor,
         _: String,
         _: &mut App,
-    ) -> Option<Task<gpui::Result<project::ProjectTransaction>>> {
+    ) -> Option<Task<anyhow::Result<project::ProjectTransaction>>> {
         None
     }
 }

crates/editor/src/rust_analyzer_ext.rs 🔗

@@ -73,7 +73,7 @@ pub fn go_to_parent_module(
         };
 
         let location_links = if let Some((client, project_id)) = upstream_client {
-            let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id())?;
+            let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id())?;
 
             let request = proto::LspExtGoToParentModule {
                 project_id,
@@ -95,7 +95,7 @@ pub fn go_to_parent_module(
             .collect::<anyhow::Result<_>>()
             .context("go to parent module via collab")?
         } else {
-            let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?;
+            let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
             let position = trigger_anchor.text_anchor.to_point_utf16(&buffer_snapshot);
             project
                 .update(cx, |project, cx| {
@@ -132,9 +132,6 @@ pub fn expand_macro_recursively(
     window: &mut Window,
     cx: &mut Context<Editor>,
 ) {
-    if editor.selections.count() == 0 {
-        return;
-    }
     let Some(project) = &editor.project else {
         return;
     };
@@ -173,7 +170,7 @@ pub fn expand_macro_recursively(
                 expansion: response.expansion,
             }
         } else {
-            let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?;
+            let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
             let position = trigger_anchor.text_anchor.to_point_utf16(&buffer_snapshot);
             project
                 .update(cx, |project, cx| {
@@ -249,7 +246,7 @@ pub fn open_docs(editor: &mut Editor, _: &OpenDocs, window: &mut Window, cx: &mu
         };
 
         let docs_urls = if let Some((client, project_id)) = upstream_client {
-            let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id())?;
+            let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id())?;
             let request = proto::LspExtOpenDocs {
                 project_id,
                 buffer_id: buffer_id.to_proto(),
@@ -264,7 +261,7 @@ pub fn open_docs(editor: &mut Editor, _: &OpenDocs, window: &mut Window, cx: &mu
                 local: response.local,
             }
         } else {
-            let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?;
+            let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
             let position = trigger_anchor.text_anchor.to_point_utf16(&buffer_snapshot);
             project
                 .update(cx, |project, cx| {

crates/editor/src/scroll.rs 🔗

@@ -123,8 +123,9 @@ impl OngoingScroll {
     }
 }
 
-#[derive(Copy, Clone, PartialEq, Eq)]
+#[derive(Copy, Clone, Default, PartialEq, Eq)]
 pub enum ScrollbarThumbState {
+    #[default]
     Idle,
     Hovered,
     Dragging,
@@ -157,8 +158,7 @@ pub struct ScrollManager {
     active_scrollbar: Option<ActiveScrollbarState>,
     visible_line_count: Option<f32>,
     forbid_vertical_scroll: bool,
-    dragging_minimap: bool,
-    show_minimap_thumb: bool,
+    minimap_thumb_state: Option<ScrollbarThumbState>,
 }
 
 impl ScrollManager {
@@ -174,8 +174,7 @@ impl ScrollManager {
             last_autoscroll: None,
             visible_line_count: None,
             forbid_vertical_scroll: false,
-            dragging_minimap: false,
-            show_minimap_thumb: false,
+            minimap_thumb_state: None,
         }
     }
 
@@ -345,24 +344,6 @@ impl ScrollManager {
         self.show_scrollbars
     }
 
-    pub fn show_minimap_thumb(&mut self, cx: &mut Context<Editor>) {
-        if !self.show_minimap_thumb {
-            self.show_minimap_thumb = true;
-            cx.notify();
-        }
-    }
-
-    pub fn hide_minimap_thumb(&mut self, cx: &mut Context<Editor>) {
-        if self.show_minimap_thumb {
-            self.show_minimap_thumb = false;
-            cx.notify();
-        }
-    }
-
-    pub fn minimap_thumb_visible(&mut self) -> bool {
-        self.show_minimap_thumb
-    }
-
     pub fn autoscroll_request(&self) -> Option<Autoscroll> {
         self.autoscroll_request.map(|(autoscroll, _)| autoscroll)
     }
@@ -374,6 +355,7 @@ impl ScrollManager {
     pub fn dragging_scrollbar_axis(&self) -> Option<Axis> {
         self.active_scrollbar
             .as_ref()
+            .filter(|scrollbar| scrollbar.thumb_state == ScrollbarThumbState::Dragging)
             .map(|scrollbar| scrollbar.axis)
     }
 
@@ -418,13 +400,43 @@ impl ScrollManager {
         }
     }
 
+    pub fn set_is_hovering_minimap_thumb(&mut self, hovered: bool, cx: &mut Context<Editor>) {
+        self.update_minimap_thumb_state(
+            Some(if hovered {
+                ScrollbarThumbState::Hovered
+            } else {
+                ScrollbarThumbState::Idle
+            }),
+            cx,
+        );
+    }
+
+    pub fn set_is_dragging_minimap(&mut self, cx: &mut Context<Editor>) {
+        self.update_minimap_thumb_state(Some(ScrollbarThumbState::Dragging), cx);
+    }
+
+    pub fn hide_minimap_thumb(&mut self, cx: &mut Context<Editor>) {
+        self.update_minimap_thumb_state(None, cx);
+    }
+
     pub fn is_dragging_minimap(&self) -> bool {
-        self.dragging_minimap
+        self.minimap_thumb_state
+            .is_some_and(|state| state == ScrollbarThumbState::Dragging)
     }
 
-    pub fn set_is_dragging_minimap(&mut self, dragging: bool, cx: &mut Context<Editor>) {
-        self.dragging_minimap = dragging;
-        cx.notify();
+    fn update_minimap_thumb_state(
+        &mut self,
+        thumb_state: Option<ScrollbarThumbState>,
+        cx: &mut Context<Editor>,
+    ) {
+        if self.minimap_thumb_state != thumb_state {
+            self.minimap_thumb_state = thumb_state;
+            cx.notify();
+        }
+    }
+
+    pub fn minimap_thumb_state(&self) -> Option<ScrollbarThumbState> {
+        self.minimap_thumb_state
     }
 
     pub fn clamp_scroll_left(&mut self, max: f32) -> bool {
@@ -475,8 +487,9 @@ impl Editor {
         if opened_first_time {
             cx.spawn_in(window, async move |editor, cx| {
                 editor
-                    .update(cx, |editor, cx| {
-                        editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx)
+                    .update_in(cx, |editor, window, cx| {
+                        editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
+                        editor.refresh_colors(false, None, window, cx);
                     })
                     .ok()
             })
@@ -587,6 +600,7 @@ impl Editor {
         );
 
         self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
+        self.refresh_colors(false, None, window, cx);
     }
 
     pub fn scroll_position(&self, cx: &mut Context<Self>) -> gpui::Point<f32> {
@@ -657,12 +671,23 @@ impl Editor {
             return;
         }
 
-        let cur_position = self.scroll_position(cx);
+        let mut current_position = self.scroll_position(cx);
         let Some(visible_line_count) = self.visible_line_count() else {
             return;
         };
-        let new_pos = cur_position + point(0., amount.lines(visible_line_count));
-        self.set_scroll_position(new_pos, window, cx);
+
+        // If the scroll position is currently at the left edge of the document
+        // (x == 0.0) and the intent is to scroll right, the gutter's margin
+        // should first be added to the current position, otherwise the cursor
+        // will end at the column position minus the margin, which looks off.
+        if current_position.x == 0.0 && amount.columns() > 0. {
+            if let Some(last_position_map) = &self.last_position_map {
+                current_position.x += self.gutter_dimensions.margin / last_position_map.em_advance;
+            }
+        }
+        let new_position =
+            current_position + point(amount.columns(), amount.lines(visible_line_count));
+        self.set_scroll_position(new_position, window, cx);
     }
 
     /// Returns an ordering. The newest selection is:

crates/editor/src/scroll/autoscroll.rs 🔗

@@ -1,12 +1,13 @@
 use crate::{
-    DisplayRow, Editor, EditorMode, LineWithInvisibles, RowExt, display_map::ToDisplayPoint,
+    DisplayRow, Editor, EditorMode, LineWithInvisibles, RowExt, SelectionEffects,
+    display_map::ToDisplayPoint,
 };
 use gpui::{Bounds, Context, Pixels, Window, px};
 use language::Point;
 use multi_buffer::Anchor;
 use std::{cmp, f32};
 
-#[derive(PartialEq, Eq, Clone, Copy)]
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
 pub enum Autoscroll {
     Next,
     Strategy(AutoscrollStrategy, Option<Anchor>),
@@ -66,7 +67,16 @@ impl Autoscroll {
     }
 }
 
-#[derive(PartialEq, Eq, Default, Clone, Copy)]
+impl Into<SelectionEffects> for Option<Autoscroll> {
+    fn into(self) -> SelectionEffects {
+        match self {
+            Some(autoscroll) => SelectionEffects::scroll(autoscroll),
+            None => SelectionEffects::no_scroll(),
+        }
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, Default, Clone, Copy)]
 pub enum AutoscrollStrategy {
     Fit,
     Newest,

crates/editor/src/scroll/scroll_amount.rs 🔗

@@ -5,6 +5,8 @@ use ui::{Pixels, px};
 pub enum ScrollDirection {
     Upwards,
     Downwards,
+    Rightwards,
+    Leftwards,
 }
 
 impl ScrollDirection {
@@ -19,6 +21,8 @@ pub enum ScrollAmount {
     Line(f32),
     // Scroll N pages (positive is towards the end of the document)
     Page(f32),
+    // Scroll N columns (positive is towards the right of the document)
+    Column(f32),
 }
 
 impl ScrollAmount {
@@ -32,6 +36,15 @@ impl ScrollAmount {
                 }
                 (visible_line_count * count).trunc()
             }
+            Self::Column(_count) => 0.0,
+        }
+    }
+
+    pub fn columns(&self) -> f32 {
+        match self {
+            Self::Line(_count) => 0.0,
+            Self::Page(_count) => 0.0,
+            Self::Column(count) => *count,
         }
     }
 
@@ -39,6 +52,12 @@ impl ScrollAmount {
         match self {
             ScrollAmount::Line(x) => px(line_height.0 * x),
             ScrollAmount::Page(x) => px(height.0 * x),
+            // This function seems to only be leveraged by the popover that is
+            // displayed by the editor when, for example, viewing a function's
+            // documentation. Right now that only supports vertical scrolling,
+            // so I'm leaving this at 0.0 for now to try and make it clear that
+            // this should not have an impact on that?
+            ScrollAmount::Column(_) => px(0.0),
         }
     }
 
@@ -53,6 +72,8 @@ impl ScrollAmount {
         match self {
             Self::Line(amount) if amount.is_sign_positive() => ScrollDirection::Downwards,
             Self::Page(amount) if amount.is_sign_positive() => ScrollDirection::Downwards,
+            Self::Column(amount) if amount.is_sign_positive() => ScrollDirection::Rightwards,
+            Self::Column(amount) if amount.is_sign_negative() => ScrollDirection::Leftwards,
             _ => ScrollDirection::Upwards,
         }
     }

crates/editor/src/selections_collection.rs 🔗

@@ -81,9 +81,9 @@ impl SelectionsCollection {
         count
     }
 
-    /// The non-pending, non-overlapping selections. There could still be a pending
-    /// selection that overlaps these if the mouse is being dragged, etc. Returned as
-    /// selections over Anchors.
+    /// The non-pending, non-overlapping selections. There could be a pending selection that
+    /// overlaps these if the mouse is being dragged, etc. This could also be empty if there is a
+    /// pending selection. Returned as selections over Anchors.
     pub fn disjoint_anchors(&self) -> Arc<[Selection<Anchor>]> {
         self.disjoint.clone()
     }
@@ -94,6 +94,20 @@ impl SelectionsCollection {
         (0..disjoint.len()).map(move |ix| disjoint[ix].range())
     }
 
+    /// Non-overlapping selections using anchors, including the pending selection.
+    pub fn all_anchors(&self, cx: &mut App) -> Arc<[Selection<Anchor>]> {
+        if self.pending.is_none() {
+            self.disjoint_anchors()
+        } else {
+            let all_offset_selections = self.all::<usize>(cx);
+            let buffer = self.buffer(cx);
+            all_offset_selections
+                .into_iter()
+                .map(|selection| selection_to_anchor_selection(selection, &buffer))
+                .collect()
+        }
+    }
+
     pub fn pending_anchor(&self) -> Option<Selection<Anchor>> {
         self.pending
             .as_ref()
@@ -352,28 +366,32 @@ impl SelectionsCollection {
     ) -> Option<Selection<Point>> {
         let is_empty = positions.start == positions.end;
         let line_len = display_map.line_len(row);
-
         let line = display_map.layout_row(row, text_layout_details);
-
         let start_col = line.closest_index_for_x(positions.start) as u32;
-        if start_col < line_len || (is_empty && positions.start == line.width) {
+
+        let (start, end) = if is_empty {
+            let point = DisplayPoint::new(row, std::cmp::min(start_col, line_len));
+            (point, point)
+        } else {
+            if start_col >= line_len {
+                return None;
+            }
             let start = DisplayPoint::new(row, start_col);
             let end_col = line.closest_index_for_x(positions.end) as u32;
             let end = DisplayPoint::new(row, end_col);
+            (start, end)
+        };
 
-            Some(Selection {
-                id: post_inc(&mut self.next_selection_id),
-                start: start.to_point(display_map),
-                end: end.to_point(display_map),
-                reversed,
-                goal: SelectionGoal::HorizontalRange {
-                    start: positions.start.into(),
-                    end: positions.end.into(),
-                },
-            })
-        } else {
-            None
-        }
+        Some(Selection {
+            id: post_inc(&mut self.next_selection_id),
+            start: start.to_point(display_map),
+            end: end.to_point(display_map),
+            reversed,
+            goal: SelectionGoal::HorizontalRange {
+                start: positions.start.into(),
+                end: positions.end.into(),
+            },
+        })
     }
 
     pub fn change_with<R>(
@@ -407,7 +425,7 @@ impl<'a> MutableSelectionsCollection<'a> {
         self.collection.display_map(self.cx)
     }
 
-    pub fn buffer(&self) -> Ref<MultiBufferSnapshot> {
+    pub fn buffer(&self) -> Ref<'_, MultiBufferSnapshot> {
         self.collection.buffer(self.cx)
     }
 
@@ -530,21 +548,11 @@ impl<'a> MutableSelectionsCollection<'a> {
             }
         }
 
-        self.collection.disjoint = Arc::from_iter(selections.into_iter().map(|selection| {
-            let end_bias = if selection.end > selection.start {
-                Bias::Left
-            } else {
-                Bias::Right
-            };
-            Selection {
-                id: selection.id,
-                start: buffer.anchor_after(selection.start),
-                end: buffer.anchor_at(selection.end, end_bias),
-                reversed: selection.reversed,
-                goal: selection.goal,
-            }
-        }));
-
+        self.collection.disjoint = Arc::from_iter(
+            selections
+                .into_iter()
+                .map(|selection| selection_to_anchor_selection(selection, &buffer)),
+        );
         self.collection.pending = None;
         self.selections_changed = true;
     }
@@ -655,6 +663,7 @@ impl<'a> MutableSelectionsCollection<'a> {
             .collect();
         self.select(selections);
     }
+
     pub fn reverse_selections(&mut self) {
         let map = &self.display_map();
         let mut new_selections: Vec<Selection<Point>> = Vec::new();
@@ -875,6 +884,27 @@ impl DerefMut for MutableSelectionsCollection<'_> {
     }
 }
 
+fn selection_to_anchor_selection<T>(
+    selection: Selection<T>,
+    buffer: &MultiBufferSnapshot,
+) -> Selection<Anchor>
+where
+    T: ToOffset + Ord,
+{
+    let end_bias = if selection.end > selection.start {
+        Bias::Left
+    } else {
+        Bias::Right
+    };
+    Selection {
+        id: selection.id,
+        start: buffer.anchor_after(selection.start),
+        end: buffer.anchor_at(selection.end, end_bias),
+        reversed: selection.reversed,
+        goal: selection.goal,
+    }
+}
+
 // Panics if passed selections are not in order
 fn resolve_selections_display<'a>(
     selections: impl 'a + IntoIterator<Item = &'a Selection<Anchor>>,

crates/editor/src/signature_help.rs 🔗

@@ -74,8 +74,6 @@ impl Editor {
     pub(super) fn should_open_signature_help_automatically(
         &mut self,
         old_cursor_position: &Anchor,
-        backspace_pressed: bool,
-
         cx: &mut Context<Self>,
     ) -> bool {
         if !(self.signature_help_state.is_shown() || self.auto_signature_help_enabled(cx)) {
@@ -84,9 +82,7 @@ impl Editor {
         let newest_selection = self.selections.newest::<usize>(cx);
         let head = newest_selection.head();
 
-        // There are two cases where the head and tail of a selection are different: selecting multiple ranges and using backspace.
-        // If we don’t exclude the backspace case, signature_help will blink every time backspace is pressed, so we need to prevent this.
-        if !newest_selection.is_empty() && !backspace_pressed && head != newest_selection.tail() {
+        if !newest_selection.is_empty() && head != newest_selection.tail() {
             self.signature_help_state
                 .hide(SignatureHelpHiddenBy::Selection);
             return false;
@@ -232,7 +228,6 @@ pub struct SignatureHelpState {
     task: Option<Task<()>>,
     popover: Option<SignatureHelpPopover>,
     hidden_by: Option<SignatureHelpHiddenBy>,
-    backspace_pressed: bool,
 }
 
 impl SignatureHelpState {
@@ -254,14 +249,6 @@ impl SignatureHelpState {
         self.popover.as_mut()
     }
 
-    pub fn backspace_pressed(&self) -> bool {
-        self.backspace_pressed
-    }
-
-    pub fn set_backspace_pressed(&mut self, backspace_pressed: bool) {
-        self.backspace_pressed = backspace_pressed;
-    }
-
     pub fn set_popover(&mut self, popover: SignatureHelpPopover) {
         self.popover = Some(popover);
         self.hidden_by = None;

crates/editor/src/test.rs 🔗

@@ -5,7 +5,7 @@ use std::{rc::Rc, sync::LazyLock};
 
 pub use crate::rust_analyzer_ext::expand_macro_recursively;
 use crate::{
-    DisplayPoint, Editor, EditorMode, FoldPlaceholder, MultiBuffer,
+    DisplayPoint, Editor, EditorMode, FoldPlaceholder, MultiBuffer, SelectionEffects,
     display_map::{
         Block, BlockPlacement, CustomBlockId, DisplayMap, DisplayRow, DisplaySnapshot,
         ToDisplayPoint,
@@ -25,9 +25,7 @@ use util::test::{marked_text_offsets, marked_text_ranges};
 #[cfg(test)]
 #[ctor::ctor]
 fn init_logger() {
-    if std::env::var("RUST_LOG").is_ok() {
-        env_logger::init();
-    }
+    zlog::init_test();
 }
 
 pub fn test_font() -> Font {
@@ -47,6 +45,7 @@ pub fn test_font() -> Font {
 }
 
 // Returns a snapshot from text containing '|' character markers with the markers removed, and DisplayPoints for each one.
+#[track_caller]
 pub fn marked_display_snapshot(
     text: &str,
     cx: &mut gpui::App,
@@ -85,6 +84,7 @@ pub fn marked_display_snapshot(
     (snapshot, markers)
 }
 
+#[track_caller]
 pub fn select_ranges(
     editor: &mut Editor,
     marked_text: &str,
@@ -93,7 +93,9 @@ pub fn select_ranges(
 ) {
     let (unmarked_text, text_ranges) = marked_text_ranges(marked_text, true);
     assert_eq!(editor.text(cx), unmarked_text);
-    editor.change_selections(None, window, cx, |s| s.select_ranges(text_ranges));
+    editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+        s.select_ranges(text_ranges)
+    });
 }
 
 #[track_caller]

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

@@ -169,6 +169,12 @@ impl EditorLspTestContext {
                 .expect("Opened test file wasn't an editor")
         });
         editor.update_in(&mut cx, |editor, window, cx| {
+            let nav_history = workspace
+                .read(cx)
+                .active_pane()
+                .read(cx)
+                .nav_history_for_item(&cx.entity());
+            editor.set_nav_history(Some(nav_history));
             window.focus(&editor.focus_handle(cx))
         });
 
@@ -345,7 +351,7 @@ impl EditorLspTestContext {
         T: 'static + request::Request,
         T::Params: 'static + Send,
         F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncApp) -> Fut,
-        Fut: 'static + Send + Future<Output = Result<T::Result>>,
+        Fut: 'static + Future<Output = Result<T::Result>>,
     {
         let url = self.buffer_lsp_url.clone();
         self.lsp.set_request_handler::<T, _, _>(move |params, cx| {

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

@@ -1,6 +1,6 @@
 use crate::{
-    AnchorRangeExt, Autoscroll, DisplayPoint, Editor, MultiBuffer, RowExt,
-    display_map::ToDisplayPoint,
+    AnchorRangeExt, DisplayPoint, Editor, MultiBuffer, RowExt,
+    display_map::{HighlightKey, ToDisplayPoint},
 };
 use buffer_diff::DiffHunkStatusKind;
 use collections::BTreeMap;
@@ -109,6 +109,7 @@ impl EditorTestContext {
         }
     }
 
+    #[track_caller]
     pub fn new_multibuffer<const COUNT: usize>(
         cx: &mut gpui::TestAppContext,
         excerpts: [&str; COUNT],
@@ -303,6 +304,7 @@ impl EditorTestContext {
         fs.set_head_for_repo(
             &Self::root_path().join(".git"),
             &[(path.into(), diff_base.to_string())],
+            "deadbeef",
         );
         self.cx.run_until_parked();
     }
@@ -351,6 +353,7 @@ impl EditorTestContext {
     /// editor state was needed to cause the failure.
     ///
     /// See the `util::test::marked_text_ranges` function for more information.
+    #[track_caller]
     pub fn set_state(&mut self, marked_text: &str) -> ContextHandle {
         let state_context = self.add_assertion_context(format!(
             "Initial Editor State: \"{}\"",
@@ -359,7 +362,7 @@ impl EditorTestContext {
         let (unmarked_text, selection_ranges) = marked_text_ranges(marked_text, true);
         self.editor.update_in(&mut self.cx, |editor, window, cx| {
             editor.set_text(unmarked_text, window, cx);
-            editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+            editor.change_selections(Default::default(), window, cx, |s| {
                 s.select_ranges(selection_ranges)
             })
         });
@@ -367,6 +370,7 @@ impl EditorTestContext {
     }
 
     /// Only change the editor's selections
+    #[track_caller]
     pub fn set_selections_state(&mut self, marked_text: &str) -> ContextHandle {
         let state_context = self.add_assertion_context(format!(
             "Initial Editor State: \"{}\"",
@@ -375,7 +379,7 @@ impl EditorTestContext {
         let (unmarked_text, selection_ranges) = marked_text_ranges(marked_text, true);
         self.editor.update_in(&mut self.cx, |editor, window, cx| {
             assert_eq!(editor.text(cx), unmarked_text);
-            editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+            editor.change_selections(Default::default(), window, cx, |s| {
                 s.select_ranges(selection_ranges)
             })
         });
@@ -505,7 +509,7 @@ impl EditorTestContext {
             let snapshot = editor.snapshot(window, cx);
             editor
                 .background_highlights
-                .get(&TypeId::of::<Tag>())
+                .get(&HighlightKey::Type(TypeId::of::<Tag>()))
                 .map(|h| h.1.clone())
                 .unwrap_or_default()
                 .iter()
@@ -532,7 +536,9 @@ impl EditorTestContext {
     #[track_caller]
     pub fn assert_editor_selections(&mut self, expected_selections: Vec<Range<usize>>) {
         let expected_marked_text =
-            generate_marked_text(&self.buffer_text(), &expected_selections, true);
+            generate_marked_text(&self.buffer_text(), &expected_selections, true)
+                .replace(" \n", "•\n");
+
         self.assert_selections(expected_selections, expected_marked_text)
     }
 
@@ -561,7 +567,8 @@ impl EditorTestContext {
     ) {
         let actual_selections = self.editor_selections();
         let actual_marked_text =
-            generate_marked_text(&self.buffer_text(), &actual_selections, true);
+            generate_marked_text(&self.buffer_text(), &actual_selections, true)
+                .replace(" \n", "•\n");
         if expected_selections != actual_selections {
             pretty_assertions::assert_eq!(
                 actual_marked_text,

crates/eval/Cargo.toml 🔗

@@ -19,17 +19,18 @@ path = "src/explorer.rs"
 
 [dependencies]
 agent.workspace = true
+agent_ui.workspace = true
+agent_settings.workspace = true
 anyhow.workspace = true
-assistant_settings.workspace = true
 assistant_tool.workspace = true
 assistant_tools.workspace = true
 async-trait.workspace = true
-async-watch.workspace = true
 buffer_diff.workspace = true
 chrono.workspace = true
 clap.workspace = true
 client.workspace = true
 collections.workspace = true
+debug_adapter_extension.workspace = true
 dirs.workspace = true
 dotenv.workspace = true
 env_logger.workspace = true
@@ -60,8 +61,11 @@ settings.workspace = true
 shellexpand.workspace = true
 smol.workspace = true
 telemetry.workspace = true
+terminal_view.workspace = true
 toml.workspace = true
 unindent.workspace = true
 util.workspace = true
 uuid.workspace = true
+watch.workspace = true
 workspace-hack.workspace = true
+zed_llm_client.workspace = true

crates/eval/src/assertions.rs 🔗

@@ -28,6 +28,17 @@ impl AssertionsReport {
         }
     }
 
+    pub fn error(msg: String) -> Self {
+        let assert = RanAssertion {
+            id: "no-unhandled-errors".into(),
+            result: Err(msg),
+        };
+        AssertionsReport {
+            ran: vec![assert],
+            max: Some(1),
+        }
+    }
+
     pub fn is_empty(&self) -> bool {
         self.ran.is_empty()
     }
@@ -145,7 +156,9 @@ pub fn print_table_divider() {
 }
 
 fn truncate(assertion: &str, max_width: usize) -> String {
-    if assertion.len() <= max_width {
+    let is_verbose = std::env::var("VERBOSE").is_ok_and(|v| !v.is_empty());
+
+    if assertion.len() <= max_width || is_verbose {
         assertion.to_string()
     } else {
         let mut end_ix = max_width - 1;

crates/eval/src/eval.rs 🔗

@@ -6,12 +6,11 @@ mod ids;
 mod instance;
 mod tool_metrics;
 
-use assertions::display_error_row;
+use assertions::{AssertionsReport, display_error_row};
 use instance::{ExampleInstance, JudgeOutput, RunOutput, run_git};
 pub(crate) use tool_metrics::*;
 
 use ::fs::RealFs;
-use anyhow::anyhow;
 use clap::Parser;
 use client::{Client, ProxySettings, UserStore};
 use collections::{HashMap, HashSet};
@@ -21,7 +20,7 @@ use gpui::http_client::read_proxy_from_env;
 use gpui::{App, AppContext, Application, AsyncApp, Entity, SemanticVersion, UpdateGlobal};
 use gpui_tokio::Tokio;
 use language::LanguageRegistry;
-use language_model::{ConfiguredModel, LanguageModel, LanguageModelRegistry};
+use language_model::{ConfiguredModel, LanguageModel, LanguageModelRegistry, SelectedModel};
 use node_runtime::{NodeBinaryOptions, NodeRuntime};
 use project::Project;
 use project::project_settings::ProjectSettings;
@@ -34,6 +33,7 @@ use std::collections::VecDeque;
 use std::env;
 use std::path::{Path, PathBuf};
 use std::rc::Rc;
+use std::str::FromStr;
 use std::sync::{Arc, LazyLock};
 use util::ResultExt as _;
 
@@ -46,12 +46,12 @@ struct Args {
     /// Runs all examples and threads that contain these substrings. If unspecified, all examples and threads are run.
     #[arg(value_name = "EXAMPLE_SUBSTRING")]
     filter: Vec<String>,
-    /// ID of model to use.
-    #[arg(long, default_value = "claude-3-7-sonnet-latest")]
+    /// provider/model to use for agent
+    #[arg(long, default_value = "anthropic/claude-3-7-sonnet-latest")]
     model: String,
-    /// Model provider to use.
-    #[arg(long, default_value = "anthropic")]
-    provider: String,
+    /// provider/model to use for judges
+    #[arg(long, default_value = "anthropic/claude-3-7-sonnet-latest")]
+    judge_model: String,
     #[arg(long, value_delimiter = ',', default_value = "rs,ts,py")]
     languages: Vec<String>,
     /// How many times to run each example.
@@ -125,25 +125,19 @@ fn main() {
 
         let mut cumulative_tool_metrics = ToolMetrics::default();
 
-        let model_registry = LanguageModelRegistry::read_global(cx);
-        let model = find_model(&args.provider, &args.model, model_registry, cx).unwrap();
-        let model_provider_id = model.provider_id();
-        let model_provider = model_registry.provider(&model_provider_id).unwrap();
+        let agent_model = load_model(&args.model, cx).unwrap();
+        let judge_model = load_model(&args.judge_model, cx).unwrap();
 
         LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
-            registry.set_default_model(
-                Some(ConfiguredModel {
-                    provider: model_provider.clone(),
-                    model: model.clone(),
-                }),
-                cx,
-            );
+            registry.set_default_model(Some(agent_model.clone()), cx);
         });
 
-        let authenticate_task = model_provider.authenticate(cx);
+        let auth1 = agent_model.provider.authenticate(cx);
+        let auth2 = judge_model.provider.authenticate(cx);
 
         cx.spawn(async move |cx| {
-            authenticate_task.await.unwrap();
+            auth1.await?;
+            auth2.await?;
 
             let mut examples = Vec::new();
 
@@ -255,13 +249,10 @@ fn main() {
 
                         let actual_origin =
                             run_git(&repo_path, &["remote", "get-url", "origin"]).await?;
-                        if actual_origin != repo_url {
-                            return Err(anyhow!(
-                                "remote origin {} does not match expected origin {}",
-                                actual_origin,
-                                repo_url,
-                            ));
-                        }
+                        anyhow::ensure!(
+                            actual_origin == repo_url,
+                            "remote origin {actual_origin} does not match expected origin {repo_url}"
+                        );
                     }
                 }
             }
@@ -277,7 +268,8 @@ fn main() {
 
             future::join_all((0..args.concurrency).map(|_| {
                 let app_state = app_state.clone();
-                let model = model.clone();
+                let model = agent_model.model.clone();
+                let judge_model = judge_model.model.clone();
                 let zed_commit_sha = zed_commit_sha.clone();
                 let zed_branch_name = zed_branch_name.clone();
                 let run_id = run_id.clone();
@@ -295,7 +287,7 @@ fn main() {
                                 .await?;
                             let judge_output = judge_example(
                                 example.clone(),
-                                model.clone(),
+                                judge_model.clone(),
                                 &zed_commit_sha,
                                 &zed_branch_name,
                                 &run_id,
@@ -393,7 +385,7 @@ pub fn init(cx: &mut App) -> Arc<AgentAppState> {
 
     extension::init(cx);
 
-    let (tx, rx) = async_watch::channel(None);
+    let (mut tx, rx) = watch::channel(None);
     cx.observe_global::<SettingsStore>(move |cx| {
         let settings = &ProjectSettings::get_global(cx).node;
         let options = NodeBinaryOptions {
@@ -422,18 +414,21 @@ pub fn init(cx: &mut App) -> Arc<AgentAppState> {
     let extension_host_proxy = ExtensionHostProxy::global(cx);
 
     language::init(cx);
+    debug_adapter_extension::init(extension_host_proxy.clone(), cx);
     language_extension::init(extension_host_proxy.clone(), languages.clone());
     language_model::init(client.clone(), cx);
-    language_models::init(user_store.clone(), client.clone(), fs.clone(), cx);
+    language_models::init(user_store.clone(), client.clone(), cx);
     languages::init(languages.clone(), node_runtime.clone(), cx);
     prompt_store::init(cx);
+    terminal_view::init(cx);
     let stdout_is_a_pty = false;
     let prompt_builder = PromptBuilder::load(fs.clone(), stdout_is_a_pty, cx);
-    agent::init(
+    agent_ui::init(
         fs.clone(),
         client.clone(),
         prompt_builder.clone(),
         languages.clone(),
+        true,
         cx,
     );
     assistant_tools::init(client.http_client(), cx);
@@ -454,36 +449,45 @@ pub fn init(cx: &mut App) -> Arc<AgentAppState> {
 }
 
 pub fn find_model(
-    provider_id: &str,
-    model_id: &str,
+    model_name: &str,
     model_registry: &LanguageModelRegistry,
     cx: &App,
 ) -> anyhow::Result<Arc<dyn LanguageModel>> {
-    let matching_models = model_registry
+    let selected = SelectedModel::from_str(model_name).map_err(|e| anyhow::anyhow!(e))?;
+    model_registry
         .available_models(cx)
-        .filter(|model| model.id().0 == model_id && model.provider_id().0 == provider_id)
-        .collect::<Vec<_>>();
+        .find(|model| model.id() == selected.model && model.provider_id() == selected.provider)
+        .ok_or_else(|| {
+            anyhow::anyhow!(
+                "No language model with ID {}/{} was available. Available models: {}",
+                selected.model.0,
+                selected.provider.0,
+                model_registry
+                    .available_models(cx)
+                    .map(|model| format!("{}/{}", model.provider_id().0, model.id().0))
+                    .collect::<Vec<_>>()
+                    .join(", ")
+            )
+        })
+}
 
-    match matching_models.as_slice() {
-        [model] => Ok(model.clone()),
-        [] => Err(anyhow!(
-            "No language model with ID {} was available. Available models: {}",
-            model_id,
-            model_registry
-                .available_models(cx)
-                .map(|model| model.id().0.clone())
-                .collect::<Vec<_>>()
-                .join(", ")
-        )),
-        _ => Err(anyhow!(
-            "Multiple language models with ID {} available - use `--provider` to choose one of: {:?}",
-            model_id,
-            matching_models
-                .iter()
-                .map(|model| model.provider_id().0)
-                .collect::<Vec<_>>()
-        )),
-    }
+pub fn load_model(model_name: &str, cx: &mut App) -> anyhow::Result<ConfiguredModel> {
+    let model = {
+        let model_registry = LanguageModelRegistry::read_global(cx);
+        find_model(model_name, model_registry, cx)?
+    };
+
+    let provider = {
+        let model_registry = LanguageModelRegistry::read_global(cx);
+        model_registry
+            .provider(&model.provider_id())
+            .ok_or_else(|| anyhow::anyhow!("Provider not found: {}", model.provider_id()))?
+    };
+
+    Ok(ConfiguredModel {
+        provider: provider.clone(),
+        model: model.clone(),
+    })
 }
 
 pub fn commit_sha_for_path(repo_path: &Path) -> String {
@@ -581,12 +585,15 @@ fn print_report(
                 Err(err) => {
                     display_error_row(&mut table_rows, example.repetition, err.to_string())?;
                     error_count += 1;
+                    programmatic_scores.push(0.0);
+                    diff_scores.push(0.0);
+                    thread_scores.push(0.0);
                 }
                 Ok((run_output, judge_output)) => {
                     cumulative_tool_metrics.merge(&run_output.tool_metrics);
                     example_cumulative_tool_metrics.merge(&run_output.tool_metrics);
 
-                    if !run_output.programmatic_assertions.total_count() > 0 {
+                    if run_output.programmatic_assertions.total_count() > 0 {
                         for assertion in &run_output.programmatic_assertions.ran {
                             assertions::display_table_row(
                                 &mut table_rows,
@@ -626,6 +633,8 @@ fn print_report(
             }
         }
 
+        let mut all_asserts = Vec::new();
+
         if !table_rows.is_empty() {
             assertions::print_table_header();
             print!("{}", table_rows);
@@ -634,33 +643,29 @@ fn print_report(
 
             for (example, result) in results.iter() {
                 if let Ok((run_output, judge_output)) = result {
+                    let asserts = [
+                        run_output.programmatic_assertions.clone(),
+                        judge_output.diff.clone(),
+                        judge_output.thread.clone(),
+                    ];
+                    all_asserts.extend_from_slice(&asserts);
                     assertions::print_table_round_summary(
                         &example.repetition.to_string(),
-                        [
-                            &run_output.programmatic_assertions,
-                            &judge_output.diff,
-                            &judge_output.thread,
-                        ]
-                        .into_iter(),
+                        asserts.iter(),
+                    )
+                } else if let Err(err) = result {
+                    let assert = AssertionsReport::error(err.to_string());
+                    all_asserts.push(assert.clone());
+                    assertions::print_table_round_summary(
+                        &example.repetition.to_string(),
+                        [assert].iter(),
                     )
                 }
             }
 
             assertions::print_table_divider();
 
-            assertions::print_table_round_summary(
-                "avg",
-                results.iter().flat_map(|(_, result)| {
-                    result.iter().flat_map(|(run_output, judge_output)| {
-                        [
-                            &run_output.programmatic_assertions,
-                            &judge_output.diff,
-                            &judge_output.thread,
-                        ]
-                        .into_iter()
-                    })
-                }),
-            );
+            assertions::print_table_round_summary("avg", all_asserts.iter());
 
             assertions::print_table_footer();
         }
@@ -711,9 +716,9 @@ fn print_report(
         .values()
         .flat_map(|results| {
             results.iter().map(|(example, _)| {
-                let absolute_path = example.run_directory.join("last.messages.json");
-                pathdiff::diff_paths(&absolute_path, run_dir)
-                    .unwrap_or_else(|| absolute_path.clone())
+                let absolute_path = run_dir.join(example.run_directory.join("last.messages.json"));
+                let cwd = std::env::current_dir().expect("Can't get current dir");
+                pathdiff::diff_paths(&absolute_path, cwd).unwrap_or_else(|| absolute_path.clone())
             })
         })
         .collect::<Vec<_>>();

crates/eval/src/example.rs 🔗

@@ -11,14 +11,15 @@ use crate::{
     assertions::{AssertionsReport, RanAssertion, RanAssertionResult},
 };
 use agent::{ContextLoadResult, Thread, ThreadEvent};
+use agent_settings::AgentProfileId;
 use anyhow::{Result, anyhow};
-use assistant_settings::AgentProfileId;
 use async_trait::async_trait;
 use buffer_diff::DiffHunkStatus;
 use collections::HashMap;
 use futures::{FutureExt as _, StreamExt, channel::mpsc, select_biased};
 use gpui::{App, AppContext, AsyncApp, Entity};
 use language_model::{LanguageModel, Role, StopReason};
+use zed_llm_client::CompletionIntent;
 
 pub const THREAD_EVENT_TIMEOUT: Duration = Duration::from_secs(60 * 2);
 
@@ -48,6 +49,8 @@ pub struct ExampleMetadata {
     pub language_server: Option<LanguageServer>,
     pub max_assertions: Option<usize>,
     pub profile_id: AgentProfileId,
+    pub existing_thread_json: Option<String>,
+    pub max_turns: Option<u32>,
 }
 
 #[derive(Clone, Debug)]
@@ -97,7 +100,7 @@ impl ExampleContext {
     pub fn new(
         meta: ExampleMetadata,
         log_prefix: String,
-        agent_thread: Entity<agent::Thread>,
+        agent_thread: Entity<Thread>,
         model: Arc<dyn LanguageModel>,
         app: AsyncApp,
     ) -> Self {
@@ -176,12 +179,10 @@ impl ExampleContext {
 
     fn log_assertion<T>(&mut self, result: Result<T>, message: String) -> Result<T> {
         if let Some(max) = self.meta.max_assertions {
-            if self.assertions.run_count() > max {
-                return Err(anyhow!(
-                    "More assertions were run than the stated max_assertions of {}",
-                    max
-                ));
-            }
+            anyhow::ensure!(
+                self.assertions.run_count() <= max,
+                "More assertions were run than the stated max_assertions of {max}"
+            );
         }
 
         self.assertions.ran.push(RanAssertion {
@@ -220,6 +221,9 @@ impl ExampleContext {
                 ThreadEvent::ShowError(thread_error) => {
                     tx.try_send(Err(anyhow!(thread_error.clone()))).ok();
                 }
+                ThreadEvent::RetriesFailed { .. } => {
+                    // Ignore retries failed events
+                }
                 ThreadEvent::Stopped(reason) => match reason {
                     Ok(StopReason::EndTurn) => {
                         tx.close_channel();
@@ -232,6 +236,10 @@ impl ExampleContext {
                     Ok(StopReason::MaxTokens) => {
                         tx.try_send(Err(anyhow!("Exceeded maximum tokens"))).ok();
                     }
+                    Ok(StopReason::Refusal) => {
+                        tx.try_send(Err(anyhow!("Model refused to generate content")))
+                            .ok();
+                    }
                     Err(err) => {
                         tx.try_send(Err(anyhow!(err.clone()))).ok();
                     }
@@ -241,6 +249,7 @@ impl ExampleContext {
                 | ThreadEvent::StreamedAssistantThinking(_, _)
                 | ThreadEvent::UsePendingTools { .. }
                 | ThreadEvent::CompletionCanceled => {}
+                ThreadEvent::ToolUseLimitReached => {}
                 ThreadEvent::ToolFinished {
                     tool_use_id,
                     pending_tool_use,
@@ -288,6 +297,7 @@ impl ExampleContext {
                 | ThreadEvent::MessageDeleted(_)
                 | ThreadEvent::SummaryChanged
                 | ThreadEvent::SummaryGenerated
+                | ThreadEvent::ProfileChanged
                 | ThreadEvent::ReceivedTextChunk
                 | ThreadEvent::StreamedToolUse { .. }
                 | ThreadEvent::CheckpointChanged
@@ -304,7 +314,7 @@ impl ExampleContext {
 
         let message_count_before = self.app.update_entity(&self.agent_thread, |thread, cx| {
             thread.set_remaining_turns(iterations);
-            thread.send_to_model(model, None, cx);
+            thread.send_to_model(model, CompletionIntent::UserPrompt, None, cx);
             thread.messages().len()
         })?;
 
@@ -318,7 +328,7 @@ impl ExampleContext {
                     }
                 }
                 _ = self.app.background_executor().timer(THREAD_EVENT_TIMEOUT).fuse() => {
-                    return Err(anyhow!("Agentic loop stalled - waited {:?} without any events", THREAD_EVENT_TIMEOUT));
+                    anyhow::bail!("Agentic loop stalled - waited {THREAD_EVENT_TIMEOUT:?} without any events");
                 }
             }
         }
@@ -477,12 +487,16 @@ impl Response {
         tool_name: &'static str,
         cx: &mut ExampleContext,
     ) -> Result<&ToolUse> {
-        let result = self.messages.iter().find_map(|msg| {
+        let result = self.find_tool_call(tool_name);
+        cx.assert_some(result, format!("called `{}`", tool_name))
+    }
+
+    pub fn find_tool_call(&self, tool_name: &str) -> Option<&ToolUse> {
+        self.messages.iter().rev().find_map(|msg| {
             msg.tool_use
                 .iter()
                 .find(|tool_use| tool_use.name == tool_name)
-        });
-        cx.assert_some(result, format!("called `{}`", tool_name))
+        })
     }
 
     #[allow(dead_code)]

crates/eval/src/examples/add_arg_to_trait_method.rs 🔗

@@ -1,7 +1,7 @@
 use std::path::Path;
 
+use agent_settings::AgentProfileId;
 use anyhow::Result;
-use assistant_settings::AgentProfileId;
 use async_trait::async_trait;
 
 use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion, LanguageServer};
@@ -21,6 +21,8 @@ impl Example for AddArgToTraitMethod {
             }),
             max_assertions: None,
             profile_id: AgentProfileId::default(),
+            existing_thread_json: None,
+            max_turns: None,
         }
     }
 

crates/eval/src/examples/code_block_citations.rs 🔗

@@ -1,5 +1,5 @@
+use agent_settings::AgentProfileId;
 use anyhow::Result;
-use assistant_settings::AgentProfileId;
 use async_trait::async_trait;
 use markdown::PathWithRange;
 
@@ -22,6 +22,8 @@ impl Example for CodeBlockCitations {
             }),
             max_assertions: None,
             profile_id: AgentProfileId::default(),
+            existing_thread_json: None,
+            max_turns: None,
         }
     }
 

crates/eval/src/examples/comment_translation.rs 🔗

@@ -1,7 +1,7 @@
 use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion};
+use agent_settings::AgentProfileId;
 use anyhow::Result;
-use assistant_settings::AgentProfileId;
-use assistant_tools::EditFileToolInput;
+use assistant_tools::{EditFileMode, EditFileToolInput};
 use async_trait::async_trait;
 
 pub struct CommentTranslation;
@@ -16,6 +16,8 @@ impl Example for CommentTranslation {
             language_server: None,
             max_assertions: Some(1),
             profile_id: AgentProfileId::default(),
+            existing_thread_json: None,
+            max_turns: None,
         }
     }
 
@@ -35,7 +37,7 @@ impl Example for CommentTranslation {
                 for tool_use in thread.tool_uses_for_message(message.id, cx) {
                     if tool_use.name == "edit_file" {
                         let input: EditFileToolInput = serde_json::from_value(tool_use.input)?;
-                        if input.create_or_overwrite {
+                        if !matches!(input.mode, EditFileMode::Edit) {
                             create_or_overwrite_count += 1;
                         }
                     }

crates/eval/src/examples/file_change_notification.rs 🔗

@@ -0,0 +1,74 @@
+use agent_settings::AgentProfileId;
+use anyhow::Result;
+use async_trait::async_trait;
+
+use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion};
+
+pub struct FileChangeNotificationExample;
+
+#[async_trait(?Send)]
+impl Example for FileChangeNotificationExample {
+    fn meta(&self) -> ExampleMetadata {
+        ExampleMetadata {
+            name: "file_change_notification".to_string(),
+            url: "https://github.com/octocat/hello-world".to_string(),
+            revision: "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d".to_string(),
+            language_server: None,
+            max_assertions: Some(1),
+            profile_id: AgentProfileId::default(),
+            existing_thread_json: None,
+            max_turns: Some(3),
+        }
+    }
+
+    async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> {
+        // Track README so that the model gets notified of its changes
+        let project_path = cx.agent_thread().read_with(cx, |thread, cx| {
+            thread
+                .project()
+                .read(cx)
+                .find_project_path("README", cx)
+                .expect("README file should exist in this repo")
+        })?;
+
+        let buffer = {
+            cx.agent_thread()
+                .update(cx, |thread, cx| {
+                    thread
+                        .project()
+                        .update(cx, |project, cx| project.open_buffer(project_path, cx))
+                })?
+                .await?
+        };
+
+        cx.agent_thread().update(cx, |thread, cx| {
+            thread.action_log().update(cx, |action_log, cx| {
+                action_log.buffer_read(buffer.clone(), cx);
+            });
+        })?;
+
+        // Start conversation (specific message is not important)
+        cx.push_user_message("Find all files in this repo");
+        cx.run_turn().await?;
+
+        // Edit the README buffer - the model should get a notification on next turn
+        buffer.update(cx, |buffer, cx| {
+            buffer.edit([(0..buffer.len(), "Surprise!")], None, cx);
+        })?;
+
+        // Run for some more turns.
+        // The model shouldn't thank us for letting it know about the file change.
+        cx.run_turns(3).await?;
+
+        Ok(())
+    }
+
+    fn thread_assertions(&self) -> Vec<JudgeAssertion> {
+        vec![JudgeAssertion {
+            id: "change-file-notification".into(),
+            description:
+                "Agent should not acknowledge or mention anything about files that have been changed"
+                    .into(),
+        }]
+    }
+}

crates/eval/src/examples/file_search.rs 🔗

@@ -1,5 +1,5 @@
+use agent_settings::AgentProfileId;
 use anyhow::Result;
-use assistant_settings::AgentProfileId;
 use assistant_tools::FindPathToolInput;
 use async_trait::async_trait;
 use regex::Regex;
@@ -18,6 +18,8 @@ impl Example for FileSearchExample {
             language_server: None,
             max_assertions: Some(3),
             profile_id: AgentProfileId::default(),
+            existing_thread_json: None,
+            max_turns: None,
         }
     }
 

crates/eval/src/examples/grep_params_escapement.rs 🔗

@@ -0,0 +1,59 @@
+use agent_settings::AgentProfileId;
+use anyhow::Result;
+use assistant_tools::GrepToolInput;
+use async_trait::async_trait;
+
+use crate::example::{Example, ExampleContext, ExampleMetadata};
+
+pub struct GrepParamsEscapementExample;
+
+/*
+
+This eval checks that the model doesn't use HTML escapement for characters like `<` and
+`>` in tool parameters.
+
+                      original     +system_prompt change    +tool description
+  claude-opus-4        89%          92%                     97%+
+  claude-sonnet-4      100%
+  gpt-4.1-mini         100%
+  gemini-2.5-pro                    98%
+
+*/
+
+#[async_trait(?Send)]
+impl Example for GrepParamsEscapementExample {
+    fn meta(&self) -> ExampleMetadata {
+        ExampleMetadata {
+            name: "grep_params_escapement".to_string(),
+            url: "https://github.com/octocat/hello-world".to_string(),
+            revision: "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d".to_string(),
+            language_server: None,
+            max_assertions: Some(1),
+            profile_id: AgentProfileId::default(),
+            existing_thread_json: None,
+            max_turns: Some(2),
+        }
+    }
+
+    async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> {
+        // cx.push_user_message("How does the precedence/specificity work with Keymap contexts? I am seeing that `MessageEditor > Editor` is lower precendence than `Editor` which is surprising to me, but might be how it works");
+        cx.push_user_message("Search for files containing the characters `>` or `<`");
+        let response = cx.run_turns(2).await?;
+        let grep_input = response
+            .find_tool_call("grep")
+            .and_then(|tool_use| tool_use.parse_input::<GrepToolInput>().ok());
+
+        cx.assert_some(grep_input.as_ref(), "`grep` tool should be called")?;
+
+        cx.assert(
+            !contains_html_entities(&grep_input.unwrap().regex),
+            "Tool parameters should not be escaped",
+        )
+    }
+}
+
+fn contains_html_entities(pattern: &str) -> bool {
+    regex::Regex::new(r"&[a-zA-Z]+;|&#[0-9]+;|&#x[0-9a-fA-F]+;")
+        .unwrap()
+        .is_match(pattern)
+}

crates/eval/src/examples/mod.rs 🔗

@@ -1,5 +1,5 @@
+use agent_settings::AgentProfileId;
 use anyhow::Result;
-use assistant_settings::AgentProfileId;
 use async_trait::async_trait;
 use serde::Deserialize;
 use std::collections::BTreeMap;
@@ -15,7 +15,10 @@ use crate::example::{Example, ExampleContext, ExampleMetadata, JudgeAssertion};
 mod add_arg_to_trait_method;
 mod code_block_citations;
 mod comment_translation;
+mod file_change_notification;
 mod file_search;
+mod grep_params_escapement;
+mod overwrite_file;
 mod planets;
 
 pub fn all(examples_dir: &Path) -> Vec<Rc<dyn Example>> {
@@ -25,6 +28,9 @@ pub fn all(examples_dir: &Path) -> Vec<Rc<dyn Example>> {
         Rc::new(code_block_citations::CodeBlockCitations),
         Rc::new(planets::Planets),
         Rc::new(comment_translation::CommentTranslation),
+        Rc::new(overwrite_file::FileOverwriteExample),
+        Rc::new(file_change_notification::FileChangeNotificationExample),
+        Rc::new(grep_params_escapement::GrepParamsEscapementExample),
     ];
 
     for example_path in list_declarative_examples(examples_dir).unwrap() {
@@ -45,6 +51,7 @@ impl DeclarativeExample {
     pub fn load(example_path: &Path) -> Result<Self> {
         let name = Self::name_from_path(example_path);
         let base: ExampleToml = toml::from_str(&fs::read_to_string(&example_path)?)?;
+        let example_dir = example_path.parent().unwrap();
 
         let language_server = if base.require_lsp {
             Some(crate::example::LanguageServer {
@@ -63,6 +70,14 @@ impl DeclarativeExample {
             AgentProfileId::default()
         };
 
+        let existing_thread_json = if let Some(path) = base.existing_thread_path {
+            let content = fs::read_to_string(example_dir.join(&path))
+                .unwrap_or_else(|_| panic!("Failed to read existing thread file: {}", path));
+            Some(content)
+        } else {
+            None
+        };
+
         let metadata = ExampleMetadata {
             name,
             url: base.url,
@@ -70,6 +85,8 @@ impl DeclarativeExample {
             language_server,
             max_assertions: None,
             profile_id,
+            existing_thread_json,
+            max_turns: base.max_turns,
         };
 
         Ok(DeclarativeExample {
@@ -110,6 +127,10 @@ pub struct ExampleToml {
     pub diff_assertions: BTreeMap<String, String>,
     #[serde(default)]
     pub thread_assertions: BTreeMap<String, String>,
+    #[serde(default)]
+    pub existing_thread_path: Option<String>,
+    #[serde(default)]
+    pub max_turns: Option<u32>,
 }
 
 #[async_trait(?Send)]
@@ -120,7 +141,8 @@ impl Example for DeclarativeExample {
 
     async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> {
         cx.push_user_message(&self.prompt);
-        let _ = cx.run_to_end().await;
+        let max_turns = self.metadata.max_turns.unwrap_or(1000);
+        let _ = cx.run_turns(max_turns).await;
         Ok(())
     }
 

crates/eval/src/examples/no_tools_enabled.toml 🔗

@@ -0,0 +1,19 @@
+url = "https://github.com/zed-industries/zed"
+revision = "main"
+require_lsp = false
+prompt = """
+I need to explore the codebase to understand what files are available in the project. What can you tell me about the structure of the codebase?
+
+Please find all uses of the 'find_path' function in the src directory.
+
+Also, can you tell me what the capital of France is? And how does garbage collection work in programming languages?
+"""
+
+profile_name = "minimal"
+
+[thread_assertions]
+no_hallucinated_tool_calls = """The agent should not hallucinate tool calls - for example, by writing markdown code blocks that simulate commands like `find`, `grep`, `ls`, etc. - since no tools are available. However, it is totally fine if the agent describes to the user what should be done, e.g. telling the user \"You can run `find` to...\" etc."""
+
+doesnt_hallucinate_file_paths = """The agent should not make up file paths or pretend to know the structure of the project when tools are not available."""
+
+correctly_answers_general_questions = """The agent should correctly answer general knowledge questions about the capital of France and garbage collection without asking for more context, demonstrating it can still be helpful with areas it knows about."""

crates/eval/src/examples/overwrite_file.rs 🔗

@@ -0,0 +1,54 @@
+use agent_settings::AgentProfileId;
+use anyhow::Result;
+use assistant_tools::{EditFileMode, EditFileToolInput};
+use async_trait::async_trait;
+
+use crate::example::{Example, ExampleContext, ExampleMetadata};
+
+pub struct FileOverwriteExample;
+
+/*
+This eval tests a fix for a destructive behavior of the `edit_file` tool.
+Previously, it would rewrite existing files too aggressively, which often
+resulted in content loss.
+
+Model           | Pass rate
+----------------|----------
+Sonnet 3.7      | 100%
+Gemini 2.5 Pro  |  80%
+*/
+
+#[async_trait(?Send)]
+impl Example for FileOverwriteExample {
+    fn meta(&self) -> ExampleMetadata {
+        let thread_json = include_str!("threads/overwrite-file.json");
+
+        ExampleMetadata {
+            name: "file_overwrite".to_string(),
+            url: "https://github.com/zed-industries/zed.git".to_string(),
+            revision: "023a60806a8cc82e73bd8d88e63b4b07fc7a0040".to_string(),
+            language_server: None,
+            max_assertions: Some(1),
+            profile_id: AgentProfileId::default(),
+            existing_thread_json: Some(thread_json.to_string()),
+            max_turns: None,
+        }
+    }
+
+    async fn conversation(&self, cx: &mut ExampleContext) -> Result<()> {
+        let response = cx.run_turns(1).await?;
+        let file_overwritten = if let Some(tool_use) = response.find_tool_call("edit_file") {
+            let input = tool_use.parse_input::<EditFileToolInput>()?;
+            match input.mode {
+                EditFileMode::Edit => false,
+                EditFileMode::Create | EditFileMode::Overwrite => {
+                    input.path.ends_with("src/language_model_selector.rs")
+                }
+            }
+        } else {
+            false
+        };
+
+        cx.assert(!file_overwritten, "File should be edited, not overwritten")
+    }
+}

crates/eval/src/examples/planets.rs 🔗

@@ -1,5 +1,5 @@
+use agent_settings::AgentProfileId;
 use anyhow::Result;
-use assistant_settings::AgentProfileId;
 use assistant_tool::Tool;
 use assistant_tools::{OpenTool, TerminalTool};
 use async_trait::async_trait;
@@ -18,6 +18,8 @@ impl Example for Planets {
             language_server: None,
             max_assertions: None,
             profile_id: AgentProfileId::default(),
+            existing_thread_json: None,
+            max_turns: None,
         }
     }
 

crates/eval/src/examples/threads/overwrite-file.json 🔗

@@ -0,0 +1,262 @@
+{
+  "completion_mode": "normal",
+  "cumulative_token_usage": {
+    "cache_creation_input_tokens": 18383,
+    "cache_read_input_tokens": 97250,
+    "input_tokens": 45,
+    "output_tokens": 776
+  },
+  "detailed_summary_state": "NotGenerated",
+  "exceeded_window_error": null,
+  "initial_project_snapshot": {
+    "timestamp": "2025-05-08T14:31:16.701157512Z",
+    "unsaved_buffer_paths": [],
+    "worktree_snapshots": [
+      {
+        "git_state": {
+          "current_branch": null,
+          "diff": "diff --git a/crates/language_model_selector/src/language_model_selector.rs b/crates/language_model_selector/src/language_model_selector.rs\nindex 6775bee98a..e25c9e1415 100644\n--- a/crates/language_model_selector/src/language_model_selector.rs\n+++ b/crates/language_model_selector/src/language_model_selector.rs\n@@ -410,7 +410,8 @@ impl ModelMatcher {\n     }\n \n     pub fn is_match(self: &Self, info: &ModelInfo) -> bool {\n-        self.matched_ids.contains(&info.model.id().0)\n+        let q = (info.model.provider_id(), info.model.id());\n+        self.matched_models.contains(&q)\n     }\n }\n \n",
+          "head_sha": "9245656485e58a5d6d717d82209bc8c57cb9c539",
+          "remote_url": "git@github.com:zed-industries/zed.git"
+        },
+        "worktree_path": "/home/silver/develop/zed"
+      }
+    ]
+  },
+  "messages": [
+    {

crates/eval/src/explorer.rs 🔗

@@ -1,23 +1,66 @@
-use anyhow::{Context, Result, anyhow};
+use anyhow::{Context as _, Result};
 use clap::Parser;
 use serde_json::{Value, json};
 use std::fs;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
 
 #[derive(Parser, Debug)]
 #[clap(about = "Generate HTML explorer from JSON thread files")]
 struct Args {
-    /// Paths to JSON files containing thread data
+    /// Paths to JSON files or directories. If a directory is provided,
+    /// it will be searched for 'last.messages.json' files up to 2 levels deep.
     #[clap(long, required = true, num_args = 1..)]
     input: Vec<PathBuf>,
 
-    /// Path where the HTML explorer file will be written
+    /// Path where the output HTML file will be written
     #[clap(long)]
     output: PathBuf,
 }
 
-pub fn generate_explorer_html(inputs: &[PathBuf], output: &PathBuf) -> Result<String> {
-    if let Some(parent) = output.parent() {
+/// Recursively finds files with `target_filename` in `dir_path` up to `max_depth`.
+#[allow(dead_code)]
+fn find_target_files_recursive(
+    dir_path: &Path,
+    target_filename: &str,
+    current_depth: u8,
+    max_depth: u8,
+    found_files: &mut Vec<PathBuf>,
+) -> Result<()> {
+    if current_depth > max_depth {
+        return Ok(());
+    }
+
+    for entry_result in fs::read_dir(dir_path)
+        .with_context(|| format!("Failed to read directory: {}", dir_path.display()))?
+    {
+        let entry = entry_result.with_context(|| {
+            format!("Failed to read directory entry in: {}", dir_path.display())
+        })?;
+        let path = entry.path();
+
+        if path.is_dir() {
+            find_target_files_recursive(
+                &path,
+                target_filename,
+                current_depth + 1,
+                max_depth,
+                found_files,
+            )?;
+        } else if path.is_file() {
+            if let Some(filename_osstr) = path.file_name() {
+                if let Some(filename_str) = filename_osstr.to_str() {
+                    if filename_str == target_filename {
+                        found_files.push(path);
+                    }
+                }
+            }
+        }
+    }
+    Ok(())
+}
+
+pub fn generate_explorer_html(input_paths: &[PathBuf], output_path: &PathBuf) -> Result<String> {
+    if let Some(parent) = output_path.parent() {
         if !parent.exists() {
             fs::create_dir_all(parent).context(format!(
                 "Failed to create output directory: {}",
@@ -27,41 +70,67 @@ pub fn generate_explorer_html(inputs: &[PathBuf], output: &PathBuf) -> Result<St
     }
 
     let template_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/explorer.html");
-    let template = fs::read_to_string(&template_path).context(format!(
+    let template_content = fs::read_to_string(&template_path).context(format!(
         "Template file not found or couldn't be read: {}",
         template_path.display()
     ))?;
 
-    let threads = inputs
+    if input_paths.is_empty() {
+        println!(
+            "No input JSON files found to process. Explorer will be generated with template defaults or empty data."
+        );
+    }
+
+    let threads = input_paths
         .iter()
         .map(|input_path| {
-            let mut thread_data: Value = fs::read_to_string(input_path)
-                .context(format!("Failed to read file: {}", input_path.display()))?
+            let file_content = fs::read_to_string(input_path)
+                .context(format!("Failed to read file: {}", input_path.display()))?;
+            let mut thread_data: Value = file_content
                 .parse::<Value>()
-                .context(format!("Failed to parse JSON: {}", input_path.display()))?;
-            thread_data["filename"] = json!(input_path); // This will be shown in a thread heading
+                .context(format!("Failed to parse JSON from file: {}", input_path.display()))?;
+
+            if let Some(obj) = thread_data.as_object_mut() {
+                obj.insert("filename".to_string(), json!(input_path.display().to_string()));
+            } else {
+                eprintln!("Warning: JSON data in {} is not a root object. Wrapping it to include filename.", input_path.display());
+                thread_data = json!({
+                    "original_data": thread_data,
+                    "filename": input_path.display().to_string()
+                });
+            }
             Ok(thread_data)
         })
         .collect::<Result<Vec<_>>>()?;
 
-    let all_threads = json!({ "threads": threads });
-    let html_content = inject_thread_data(template, all_threads)?;
-    fs::write(&output, &html_content)
-        .context(format!("Failed to write output: {}", output.display()))?;
+    let all_threads_data = json!({ "threads": threads });
+    let html_content = inject_thread_data(template_content, all_threads_data)?;
+    fs::write(&output_path, &html_content)
+        .context(format!("Failed to write output: {}", output_path.display()))?;
 
-    println!("Saved {} thread(s) to {}", threads.len(), output.display());
+    println!(
+        "Saved data from {} resolved file(s) ({} threads) to {}",
+        input_paths.len(),
+        threads.len(),
+        output_path.display()
+    );
     Ok(html_content)
 }
 
 fn inject_thread_data(template: String, threads_data: Value) -> Result<String> {
     let injection_marker = "let threadsData = window.threadsData || { threads: [dummyThread] };";
-    template
-        .find(injection_marker)
-        .ok_or_else(|| anyhow!("Could not find the thread injection point in the template"))?;
+    if !template.contains(injection_marker) {
+        anyhow::bail!(
+            "Could not find the thread injection point in the template. Expected: '{}'",
+            injection_marker
+        );
+    }
 
-    let threads_json = serde_json::to_string_pretty(&threads_data)
-        .context("Failed to serialize threads data to JSON")?;
-    let script_injection = format!("let threadsData = {};", threads_json);
+    let threads_json_string = serde_json::to_string_pretty(&threads_data)
+        .context("Failed to serialize threads data to JSON")?
+        .replace("</script>", r"<\/script>");
+
+    let script_injection = format!("let threadsData = {};", threads_json_string);
     let final_html = template.replacen(injection_marker, &script_injection, 1);
 
     Ok(final_html)
@@ -71,5 +140,45 @@ fn inject_thread_data(template: String, threads_data: Value) -> Result<String> {
 #[allow(dead_code)]
 fn main() -> Result<()> {
     let args = Args::parse();
-    generate_explorer_html(&args.input, &args.output).map(|_| ())
+
+    const DEFAULT_FILENAME: &str = "last.messages.json";
+    const MAX_SEARCH_DEPTH: u8 = 2;
+
+    let mut resolved_input_files: Vec<PathBuf> = Vec::new();
+
+    for input_path_arg in &args.input {
+        if !input_path_arg.exists() {
+            eprintln!(
+                "Warning: Input path {} does not exist. Skipping.",
+                input_path_arg.display()
+            );
+            continue;
+        }
+
+        if input_path_arg.is_dir() {
+            find_target_files_recursive(
+                input_path_arg,
+                DEFAULT_FILENAME,
+                0, // starting depth
+                MAX_SEARCH_DEPTH,
+                &mut resolved_input_files,
+            )
+            .with_context(|| {
+                format!(
+                    "Error searching for '{}' files in directory: {}",
+                    DEFAULT_FILENAME,
+                    input_path_arg.display()
+                )
+            })?;
+        } else if input_path_arg.is_file() {
+            resolved_input_files.push(input_path_arg.clone());
+        }
+    }
+
+    resolved_input_files.sort_unstable();
+    resolved_input_files.dedup();
+
+    println!("No input paths provided/found.");
+
+    generate_explorer_html(&resolved_input_files, &args.output).map(|_| ())
 }

crates/eval/src/ids.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use std::fs;
 use std::path::{Path, PathBuf};
 use uuid::Uuid;
@@ -11,7 +11,7 @@ pub fn get_or_create_id(path: &Path) -> Result<String> {
         }
     }
     let new_id = Uuid::new_v4().to_string();
-    fs::create_dir_all(path.parent().ok_or_else(|| anyhow!("invalid id path"))?)?;
+    fs::create_dir_all(path.parent().context("invalid id path")?)?;
     fs::write(path, &new_id)?;
     Ok(new_id)
 }

crates/eval/src/instance.rs 🔗

@@ -1,5 +1,5 @@
-use agent::{Message, MessageSegment, ThreadStore};
-use anyhow::{Context, Result, anyhow, bail};
+use agent::{Message, MessageSegment, SerializedThread, ThreadStore};
+use anyhow::{Context as _, Result, anyhow, bail};
 use assistant_tool::ToolWorkingSet;
 use client::proto::LspWorkProgress;
 use futures::channel::mpsc;
@@ -9,7 +9,7 @@ use handlebars::Handlebars;
 use language::{Buffer, DiagnosticSeverity, OffsetRangeExt as _};
 use language_model::{
     LanguageModel, LanguageModelCompletionEvent, LanguageModelRequest, LanguageModelRequestMessage,
-    MessageContent, Role, TokenUsage,
+    LanguageModelToolResultContent, MessageContent, Role, TokenUsage,
 };
 use project::lsp_store::OpenLspBufferHandle;
 use project::{DiagnosticSummary, Project, ProjectPath};
@@ -285,7 +285,7 @@ impl ExampleInstance {
 
                 diagnostics_before = query_lsp_diagnostics(project.clone(), cx).await?;
                 if diagnostics_before.is_some() && language_server.allow_preexisting_diagnostics {
-                    return Err(anyhow!("Example has pre-existing diagnostics. If you want to run this example regardless, set `allow_preexisting_diagnostics` to `true` in `base.toml`"));
+                    anyhow::bail!("Example has pre-existing diagnostics. If you want to run this example regardless, set `allow_preexisting_diagnostics` to `true` in `base.toml`");
                 }
 
                 Some(LanguageServerState {
@@ -296,9 +296,7 @@ impl ExampleInstance {
                 None
             };
 
-            if std::env::var("ZED_EVAL_SETUP_ONLY").is_ok() {
-                return Err(anyhow!("Setup only mode"));
-            }
+            anyhow::ensure!(std::env::var("ZED_EVAL_SETUP_ONLY").is_err(), "Setup only mode");
 
             let last_diff_file_path = this.run_directory.join("last.diff");
 
@@ -308,11 +306,20 @@ impl ExampleInstance {
 
             let thread_store = thread_store.await?;
 
-            let profile_id = meta.profile_id.clone();
-            thread_store.update(cx, |thread_store, cx| thread_store.load_profile_by_id(profile_id, cx)).expect("Failed to load profile");
 
             let thread =
-                thread_store.update(cx, |thread_store, cx| thread_store.create_thread(cx))?;
+                thread_store.update(cx, |thread_store, cx| {
+                    let thread = if let Some(json) = &meta.existing_thread_json {
+                        let serialized = SerializedThread::from_json(json.as_bytes()).expect("Can't read serialized thread");
+                        thread_store.create_thread_from_serialized(serialized, cx)
+                    } else {
+                        thread_store.create_thread(cx)
+                    };
+                    thread.update(cx, |thread, cx| {
+                        thread.set_profile(meta.profile_id.clone(), cx);
+                    });
+                    thread
+                })?;
 
 
             thread.update(cx, |thread, _cx| {
@@ -360,7 +367,13 @@ impl ExampleInstance {
                 });
             })?;
 
-            let mut example_cx = ExampleContext::new(meta.clone(), this.log_prefix.clone(), thread.clone(), model.clone(), cx.clone());
+            let mut example_cx = ExampleContext::new(
+                meta.clone(),
+                this.log_prefix.clone(),
+                thread.clone(),
+                model.clone(),
+                cx.clone(),
+            );
             let result = this.thread.conversation(&mut example_cx).await;
 
             if let Err(err) = result {
@@ -571,6 +584,7 @@ impl ExampleInstance {
                 thread_id: None,
                 prompt_id: None,
                 mode: None,
+                intent: None,
                 messages: vec![LanguageModelRequestMessage {
                     role: Role::User,
                     content: vec![MessageContent::Text(to_prompt(assertion.description))],
@@ -639,7 +653,7 @@ pub fn wait_for_lang_server(
     let (mut tx, mut rx) = mpsc::channel(1);
 
     let lsp_store = project
-        .update(cx, |project, _| project.lsp_store())
+        .read_with(cx, |project, _| project.lsp_store())
         .unwrap();
 
     let has_lang_server = buffer
@@ -703,7 +717,7 @@ pub fn wait_for_lang_server(
                 anyhow::Ok(())
             },
             _ = timeout.fuse() => {
-                Err(anyhow!("LSP wait timed out after 5 minutes"))
+                anyhow::bail!("LSP wait timed out after 5 minutes");
             }
         };
         drop(subscriptions);
@@ -801,18 +815,16 @@ pub async fn run_git(repo_path: &Path, args: &[&str]) -> Result<String> {
         .output()
         .await?;
 
-    if output.status.success() {
-        Ok(String::from_utf8(output.stdout)?.trim().to_string())
-    } else {
-        Err(anyhow!(
-            "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}",
-            args.join(" "),
-            repo_path.display(),
-            output.status,
-            String::from_utf8_lossy(&output.stderr),
-            String::from_utf8_lossy(&output.stdout),
-        ))
-    }
+    anyhow::ensure!(
+        output.status.success(),
+        "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}",
+        args.join(" "),
+        repo_path.display(),
+        output.status,
+        String::from_utf8_lossy(&output.stderr),
+        String::from_utf8_lossy(&output.stdout),
+    );
+    Ok(String::from_utf8(output.stdout)?.trim().to_string())
 }
 
 fn messages_to_markdown<'a>(message_iter: impl IntoIterator<Item = &'a Message>) -> String {
@@ -874,9 +886,7 @@ pub async fn send_language_model_request(
                         full_response.push_str(&chunk_str);
                     }
                     Err(err) => {
-                        return Err(anyhow!(
-                            "Error receiving response from language model: {err}"
-                        ));
+                        anyhow::bail!("Error receiving response from language model: {err}");
                     }
                 }
             }
@@ -964,7 +974,15 @@ impl RequestMarkdown {
                         if tool_result.is_error {
                             messages.push_str("**ERROR:**\n");
                         }
-                        messages.push_str(&format!("{}\n\n", tool_result.content));
+
+                        match &tool_result.content {
+                            LanguageModelToolResultContent::Text(text) => {
+                                writeln!(messages, "{text}\n").ok();
+                            }
+                            LanguageModelToolResultContent::Image(image) => {
+                                writeln!(messages, "![Image](data:base64,{})\n", image.source).ok();
+                            }
+                        }
 
                         if let Some(output) = tool_result.output.as_ref() {
                             writeln!(
@@ -1012,6 +1030,7 @@ pub fn response_events_to_markdown(
             Ok(LanguageModelCompletionEvent::Thinking { text, .. }) => {
                 thinking_buffer.push_str(text);
             }
+            Ok(LanguageModelCompletionEvent::RedactedThinking { .. }) => {}
             Ok(LanguageModelCompletionEvent::Stop(reason)) => {
                 flush_buffers(&mut response, &mut text_buffer, &mut thinking_buffer);
                 response.push_str(&format!("**Stop**: {:?}\n\n", reason));
@@ -1108,6 +1127,7 @@ impl ThreadDialog {
 
                 // Skip these
                 Ok(LanguageModelCompletionEvent::UsageUpdate(_))
+                | Ok(LanguageModelCompletionEvent::RedactedThinking { .. })
                 | Ok(LanguageModelCompletionEvent::StatusUpdate { .. })
                 | Ok(LanguageModelCompletionEvent::StartMessage { .. })
                 | Ok(LanguageModelCompletionEvent::Stop(_)) => {}

crates/extension/Cargo.toml 🔗

@@ -17,6 +17,7 @@ async-compression.workspace = true
 async-tar.workspace = true
 async-trait.workspace = true
 collections.workspace = true
+dap.workspace = true
 fs.workspace = true
 futures.workspace = true
 gpui.workspace = true
@@ -29,9 +30,9 @@ parking_lot.workspace = true
 semantic_version.workspace = true
 serde.workspace = true
 serde_json.workspace = true
+task.workspace = true
 toml.workspace = true
 util.workspace = true
 wasm-encoder.workspace = true
 wasmparser.workspace = true
-wit-component.workspace = true
 workspace-hack.workspace = true

crates/extension/src/extension.rs 🔗

@@ -8,12 +8,13 @@ use std::path::{Path, PathBuf};
 use std::sync::Arc;
 
 use ::lsp::LanguageServerName;
-use anyhow::{Context as _, Result, anyhow, bail};
+use anyhow::{Context as _, Result, bail};
 use async_trait::async_trait;
 use fs::normalize_path;
 use gpui::{App, Task};
 use language::LanguageName;
 use semantic_version::SemanticVersion;
+use task::{SpawnInTerminal, ZedDebugConfig};
 
 pub use crate::extension_events::*;
 pub use crate::extension_host_proxy::*;
@@ -135,6 +136,35 @@ pub trait Extension: Send + Sync + 'static {
         package_name: Arc<str>,
         kv_store: Arc<dyn KeyValueStoreDelegate>,
     ) -> Result<()>;
+
+    async fn get_dap_binary(
+        &self,
+        dap_name: Arc<str>,
+        config: DebugTaskDefinition,
+        user_installed_path: Option<PathBuf>,
+        worktree: Arc<dyn WorktreeDelegate>,
+    ) -> Result<DebugAdapterBinary>;
+
+    async fn dap_request_kind(
+        &self,
+        dap_name: Arc<str>,
+        config: serde_json::Value,
+    ) -> Result<StartDebuggingRequestArgumentsRequest>;
+
+    async fn dap_config_to_scenario(&self, config: ZedDebugConfig) -> Result<DebugScenario>;
+
+    async fn dap_locator_create_scenario(
+        &self,
+        locator_name: String,
+        build_config_template: BuildTaskTemplate,
+        resolved_label: String,
+        debug_adapter_name: String,
+    ) -> Result<Option<DebugScenario>>;
+    async fn run_dap_locator(
+        &self,
+        locator_name: String,
+        config: SpawnInTerminal,
+    ) -> Result<DebugRequest>;
 }
 
 pub fn parse_wasm_extension_version(
@@ -165,7 +195,7 @@ pub fn parse_wasm_extension_version(
     //
     // By parsing the entirety of the Wasm bytes before we return, we're able to detect this problem
     // earlier as an `Err` rather than as a panic.
-    version.ok_or_else(|| anyhow!("extension {} has no zed:api-version section", extension_id))
+    version.with_context(|| format!("extension {extension_id} has no zed:api-version section"))
 }
 
 fn parse_wasm_extension_version_custom_section(data: &[u8]) -> Option<SemanticVersion> {

crates/extension/src/extension_builder.rs 🔗

@@ -1,10 +1,9 @@
 use crate::{
     ExtensionLibraryKind, ExtensionManifest, GrammarManifestEntry, parse_wasm_extension_version,
 };
-use anyhow::{Context as _, Result, anyhow, bail};
+use anyhow::{Context as _, Result, bail};
 use async_compression::futures::bufread::GzipDecoder;
 use async_tar::Archive;
-use futures::AsyncReadExt;
 use futures::io::BufReader;
 use heck::ToSnakeCase;
 use http_client::{self, AsyncBody, HttpClient};
@@ -13,20 +12,14 @@ use std::{
     env, fs, mem,
     path::{Path, PathBuf},
     process::Stdio,
+    str::FromStr,
     sync::Arc,
 };
 use wasm_encoder::{ComponentSectionId, Encode as _, RawSection, Section as _};
 use wasmparser::Parser;
-use wit_component::ComponentEncoder;
 
-/// Currently, we compile with Rust's `wasm32-wasip1` target, which works with WASI `preview1`.
-/// But the WASM component model is based on WASI `preview2`. So we need an 'adapter' WASM
-/// module, which implements the `preview1` interface in terms of `preview2`.
-///
-/// Once Rust 1.78 is released, there will be a `wasm32-wasip2` target available, so we will
-/// not need the adapter anymore.
-const RUST_TARGET: &str = "wasm32-wasip1";
-const WASI_ADAPTER_URL: &str = "https://github.com/bytecodealliance/wasmtime/releases/download/v18.0.2/wasi_snapshot_preview1.reactor.wasm";
+/// Currently, we compile with Rust's `wasm32-wasip2` target, which works with WASI `preview2` and the component model.
+const RUST_TARGET: &str = "wasm32-wasip2";
 
 /// Compiling Tree-sitter parsers from C to WASM requires Clang 17, and a WASM build of libc
 /// and clang's runtime library. The `wasi-sdk` provides these binaries.
@@ -105,6 +98,22 @@ impl ExtensionBuilder {
             log::info!("compiled Rust extension {}", extension_dir.display());
         }
 
+        for (debug_adapter_name, meta) in &mut extension_manifest.debug_adapters {
+            let debug_adapter_relative_schema_path =
+                meta.schema_path.clone().unwrap_or_else(|| {
+                    Path::new("debug_adapter_schemas")
+                        .join(Path::new(debug_adapter_name.as_ref()).with_extension("json"))
+                });
+            let debug_adapter_schema_path = extension_dir.join(debug_adapter_relative_schema_path);
+
+            let debug_adapter_schema = fs::read_to_string(&debug_adapter_schema_path)
+                .with_context(|| {
+                    format!("failed to read debug adapter schema for `{debug_adapter_name}` from `{debug_adapter_schema_path:?}`")
+                })?;
+            _ = serde_json::Value::from_str(&debug_adapter_schema).with_context(|| {
+                format!("Debug adapter schema for `{debug_adapter_name}` (path: `{debug_adapter_schema_path:?}`) is not a valid JSON")
+            })?;
+        }
         for (grammar_name, grammar_metadata) in &extension_manifest.grammars {
             let snake_cased_grammar_name = grammar_name.to_snake_case();
             if grammar_name.as_ref() != snake_cased_grammar_name.as_str() {
@@ -135,9 +144,8 @@ impl ExtensionBuilder {
         extension_dir: &Path,
         manifest: &mut ExtensionManifest,
         options: CompileExtensionOptions,
-    ) -> Result<(), anyhow::Error> {
+    ) -> anyhow::Result<()> {
         self.install_rust_wasm_target_if_needed()?;
-        let adapter_bytes = self.install_wasi_preview1_adapter_if_needed().await?;
 
         let cargo_toml_content = fs::read_to_string(extension_dir.join("Cargo.toml"))?;
         let cargo_toml: CargoToml = toml::from_str(&cargo_toml_content)?;
@@ -176,28 +184,18 @@ impl ExtensionBuilder {
             &cargo_toml
                 .package
                 .name
-                // The wasm32-wasip1 target normalizes `-` in package names to `_` in the resulting `.wasm` file.
+                // The wasm32-wasip2 target normalizes `-` in package names to `_` in the resulting `.wasm` file.
                 .replace('-', "_"),
         ]);
         wasm_path.set_extension("wasm");
 
-        let wasm_bytes = fs::read(&wasm_path)
-            .with_context(|| format!("failed to read output module `{}`", wasm_path.display()))?;
-
-        let mut encoder = ComponentEncoder::default()
-            .module(&wasm_bytes)?
-            .adapter("wasi_snapshot_preview1", &adapter_bytes)
-            .context("failed to load adapter module")?
-            .validate(true);
-
         log::info!(
             "encoding wasm component for extension {}",
             extension_dir.display()
         );
 
-        let component_bytes = encoder
-            .encode()
-            .context("failed to encode wasm component")?;
+        let component_bytes = fs::read(&wasm_path)
+            .with_context(|| format!("failed to read output module `{}`", wasm_path.display()))?;
 
         let component_bytes = self
             .strip_custom_sections(&component_bytes)
@@ -395,41 +393,9 @@ impl ExtensionBuilder {
         Ok(())
     }
 
-    async fn install_wasi_preview1_adapter_if_needed(&self) -> Result<Vec<u8>> {
-        let cache_path = self.cache_dir.join("wasi_snapshot_preview1.reactor.wasm");
-        if let Ok(content) = fs::read(&cache_path) {
-            if Parser::is_core_wasm(&content) {
-                return Ok(content);
-            }
-        }
-
-        fs::remove_file(&cache_path).ok();
-
-        log::info!(
-            "downloading wasi adapter module to {}",
-            cache_path.display()
-        );
-        let mut response = self
-            .http
-            .get(WASI_ADAPTER_URL, AsyncBody::default(), true)
-            .await?;
-
-        let mut content = Vec::new();
-        let mut body = BufReader::new(response.body_mut());
-        body.read_to_end(&mut content).await?;
-
-        fs::write(&cache_path, &content)
-            .with_context(|| format!("failed to save file {}", cache_path.display()))?;
-
-        if !Parser::is_core_wasm(&content) {
-            bail!("downloaded wasi adapter is invalid");
-        }
-        Ok(content)
-    }
-
     async fn install_wasi_sdk_if_needed(&self) -> Result<PathBuf> {
         let url = if let Some(asset_name) = WASI_SDK_ASSET_NAME {
-            format!("{WASI_SDK_URL}/{asset_name}")
+            format!("{WASI_SDK_URL}{asset_name}")
         } else {
             bail!("wasi-sdk is not available for platform {}", env::consts::OS);
         };
@@ -460,7 +426,7 @@ impl ExtensionBuilder {
 
         let inner_dir = fs::read_dir(&tar_out_dir)?
             .next()
-            .ok_or_else(|| anyhow!("no content"))?
+            .context("no content")?
             .context("failed to read contents of extracted wasi archive directory")?
             .path();
         fs::rename(&inner_dir, &wasi_sdk_dir).context("failed to move extracted wasi dir")?;
@@ -470,26 +436,34 @@ impl ExtensionBuilder {
     }
 
     // This was adapted from:
-    // https://github.com/bytecodealliance/wasm-tools/blob/1791a8f139722e9f8679a2bd3d8e423e55132b22/src/bin/wasm-tools/strip.rs
+    // https://github.com/bytecodealliance/wasm-tools/blob/e8809bb17fcf69aa8c85cd5e6db7cff5cf36b1de/src/bin/wasm-tools/strip.rs
     fn strip_custom_sections(&self, input: &Vec<u8>) -> Result<Vec<u8>> {
         use wasmparser::Payload::*;
 
-        let strip_custom_section = |name: &str| name.starts_with(".debug");
+        let strip_custom_section = |name: &str| {
+            // Default strip everything but:
+            // * the `name` section
+            // * any `component-type` sections
+            // * the `dylink.0` section
+            // * our custom version section
+            name != "name"
+                && !name.starts_with("component-type:")
+                && name != "dylink.0"
+                && name != "zed:api-version"
+        };
 
         let mut output = Vec::new();
         let mut stack = Vec::new();
 
-        for payload in Parser::new(0).parse_all(input) {
+        for payload in Parser::new(0).parse_all(&input) {
             let payload = payload?;
-            let component_header = wasm_encoder::Component::HEADER;
-            let module_header = wasm_encoder::Module::HEADER;
 
             // Track nesting depth, so that we don't mess with inner producer sections:
             match payload {
                 Version { encoding, .. } => {
                     output.extend_from_slice(match encoding {
-                        wasmparser::Encoding::Component => &component_header,
-                        wasmparser::Encoding::Module => &module_header,
+                        wasmparser::Encoding::Component => &wasm_encoder::Component::HEADER,
+                        wasmparser::Encoding::Module => &wasm_encoder::Module::HEADER,
                     });
                 }
                 ModuleSection { .. } | ComponentSection { .. } => {
@@ -501,7 +475,7 @@ impl ExtensionBuilder {
                         Some(c) => c,
                         None => break,
                     };
-                    if output.starts_with(&component_header) {
+                    if output.starts_with(&wasm_encoder::Component::HEADER) {
                         parent.push(ComponentSectionId::Component as u8);
                         output.encode(&mut parent);
                     } else {
@@ -513,12 +487,15 @@ impl ExtensionBuilder {
                 _ => {}
             }
 
-            if let CustomSection(c) = &payload {
-                if strip_custom_section(c.name()) {
-                    continue;
+            match &payload {
+                CustomSection(c) => {
+                    if strip_custom_section(c.name()) {
+                        continue;
+                    }
                 }
-            }
 
+                _ => {}
+            }
             if let Some((id, range)) = payload.as_section() {
                 RawSection {
                     id,
@@ -619,7 +596,7 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) ->
                     let grammar_name = grammar_path
                         .file_stem()
                         .and_then(|stem| stem.to_str())
-                        .ok_or_else(|| anyhow!("no grammar name"))?;
+                        .context("no grammar name")?;
                     if !manifest.grammars.contains_key(grammar_name) {
                         manifest.grammars.insert(
                             grammar_name.into(),

crates/extension/src/extension_events.rs 🔗

@@ -36,6 +36,7 @@ impl ExtensionEvents {
 #[derive(Clone)]
 pub enum Event {
     ExtensionInstalled(Arc<ExtensionManifest>),
+    ExtensionUninstalled(Arc<ExtensionManifest>),
     ExtensionsInstalledChanged,
     ConfigureExtensionRequested(Arc<ExtensionManifest>),
 }

crates/extension/src/extension_host_proxy.rs 🔗

@@ -1,4 +1,4 @@
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
 use std::sync::Arc;
 
 use anyhow::Result;
@@ -29,6 +29,7 @@ pub struct ExtensionHostProxy {
     slash_command_proxy: RwLock<Option<Arc<dyn ExtensionSlashCommandProxy>>>,
     context_server_proxy: RwLock<Option<Arc<dyn ExtensionContextServerProxy>>>,
     indexed_docs_provider_proxy: RwLock<Option<Arc<dyn ExtensionIndexedDocsProviderProxy>>>,
+    debug_adapter_provider_proxy: RwLock<Option<Arc<dyn ExtensionDebugAdapterProviderProxy>>>,
 }
 
 impl ExtensionHostProxy {
@@ -54,6 +55,7 @@ impl ExtensionHostProxy {
             slash_command_proxy: RwLock::default(),
             context_server_proxy: RwLock::default(),
             indexed_docs_provider_proxy: RwLock::default(),
+            debug_adapter_provider_proxy: RwLock::default(),
         }
     }
 
@@ -93,6 +95,11 @@ impl ExtensionHostProxy {
             .write()
             .replace(Arc::new(proxy));
     }
+    pub fn register_debug_adapter_proxy(&self, proxy: impl ExtensionDebugAdapterProviderProxy) {
+        self.debug_adapter_provider_proxy
+            .write()
+            .replace(Arc::new(proxy));
+    }
 }
 
 pub trait ExtensionThemeProxy: Send + Sync + 'static {
@@ -402,3 +409,52 @@ impl ExtensionIndexedDocsProviderProxy for ExtensionHostProxy {
         proxy.register_indexed_docs_provider(extension, provider_id)
     }
 }
+
+pub trait ExtensionDebugAdapterProviderProxy: Send + Sync + 'static {
+    fn register_debug_adapter(
+        &self,
+        extension: Arc<dyn Extension>,
+        debug_adapter_name: Arc<str>,
+        schema_path: &Path,
+    );
+    fn register_debug_locator(&self, extension: Arc<dyn Extension>, locator_name: Arc<str>);
+    fn unregister_debug_adapter(&self, debug_adapter_name: Arc<str>);
+    fn unregister_debug_locator(&self, locator_name: Arc<str>);
+}
+
+impl ExtensionDebugAdapterProviderProxy for ExtensionHostProxy {
+    fn register_debug_adapter(
+        &self,
+        extension: Arc<dyn Extension>,
+        debug_adapter_name: Arc<str>,
+        schema_path: &Path,
+    ) {
+        let Some(proxy) = self.debug_adapter_provider_proxy.read().clone() else {
+            return;
+        };
+
+        proxy.register_debug_adapter(extension, debug_adapter_name, schema_path)
+    }
+
+    fn register_debug_locator(&self, extension: Arc<dyn Extension>, locator_name: Arc<str>) {
+        let Some(proxy) = self.debug_adapter_provider_proxy.read().clone() else {
+            return;
+        };
+
+        proxy.register_debug_locator(extension, locator_name)
+    }
+    fn unregister_debug_adapter(&self, debug_adapter_name: Arc<str>) {
+        let Some(proxy) = self.debug_adapter_provider_proxy.read().clone() else {
+            return;
+        };
+
+        proxy.unregister_debug_adapter(debug_adapter_name)
+    }
+    fn unregister_debug_locator(&self, locator_name: Arc<str>) {
+        let Some(proxy) = self.debug_adapter_provider_proxy.read().clone() else {
+            return;
+        };
+
+        proxy.unregister_debug_locator(locator_name)
+    }
+}

crates/extension/src/extension_manifest.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Context as _, Result, anyhow, bail};
+use anyhow::{Context as _, Result, bail};
 use collections::{BTreeMap, HashMap};
 use fs::Fs;
 use language::LanguageName;
@@ -87,6 +87,10 @@ pub struct ExtensionManifest {
     pub snippets: Option<PathBuf>,
     #[serde(default)]
     pub capabilities: Vec<ExtensionCapability>,
+    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+    pub debug_adapters: BTreeMap<Arc<str>, DebugAdapterManifestEntry>,
+    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+    pub debug_locators: BTreeMap<Arc<str>, DebugLocatorManifestEntry>,
 }
 
 impl ExtensionManifest {
@@ -162,7 +166,7 @@ pub struct GrammarManifestEntry {
     pub path: Option<String>,
 }
 
-#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
+#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
 pub struct LanguageServerManifestEntry {
     /// Deprecated in favor of `languages`.
     #[serde(default)]
@@ -206,12 +210,20 @@ pub struct SlashCommandManifestEntry {
 #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
 pub struct IndexedDocsProviderEntry {}
 
+#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
+pub struct DebugAdapterManifestEntry {
+    pub schema_path: Option<PathBuf>,
+}
+
+#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
+pub struct DebugLocatorManifestEntry {}
+
 impl ExtensionManifest {
     pub async fn load(fs: Arc<dyn Fs>, extension_dir: &Path) -> Result<Self> {
         let extension_name = extension_dir
             .file_name()
             .and_then(OsStr::to_str)
-            .ok_or_else(|| anyhow!("invalid extension name"))?;
+            .context("invalid extension name")?;
 
         let mut extension_manifest_path = extension_dir.join("extension.json");
         if fs.is_file(&extension_manifest_path).await {
@@ -274,6 +286,8 @@ fn manifest_from_old_manifest(
         indexed_docs_providers: BTreeMap::default(),
         snippets: None,
         capabilities: Vec::new(),
+        debug_adapters: Default::default(),
+        debug_locators: Default::default(),
     }
 }
 
@@ -301,6 +315,8 @@ mod tests {
             indexed_docs_providers: BTreeMap::default(),
             snippets: None,
             capabilities: vec![],
+            debug_adapters: Default::default(),
+            debug_locators: Default::default(),
         }
     }
 

crates/extension/src/types.rs 🔗

@@ -1,10 +1,14 @@
 mod context_server;
+mod dap;
 mod lsp;
 mod slash_command;
 
 use std::ops::Range;
 
+use util::redact::should_redact;
+
 pub use context_server::*;
+pub use dap::*;
 pub use lsp::*;
 pub use slash_command::*;
 
@@ -12,7 +16,6 @@ pub use slash_command::*;
 pub type EnvVars = Vec<(String, String)>;
 
 /// A command.
-#[derive(Debug)]
 pub struct Command {
     /// The command to execute.
     pub command: String,
@@ -22,6 +25,22 @@ pub struct Command {
     pub env: EnvVars,
 }
 
+impl std::fmt::Debug for Command {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let filtered_env = self
+            .env
+            .iter()
+            .map(|(k, v)| (k, if should_redact(k) { "[REDACTED]" } else { v }))
+            .collect::<Vec<_>>();
+
+        f.debug_struct("Command")
+            .field("command", &self.command)
+            .field("args", &self.args)
+            .field("env", &filtered_env)
+            .finish()
+    }
+}
+
 /// A label containing some code.
 #[derive(Debug, Clone)]
 pub struct CodeLabel {

crates/extension/src/types/dap.rs 🔗

@@ -0,0 +1,8 @@
+pub use dap::{
+    StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest,
+    adapters::{DebugAdapterBinary, DebugTaskDefinition, TcpArguments},
+};
+pub use task::{
+    AttachRequest, BuildTaskDefinition, DebugRequest, DebugScenario, LaunchRequest,
+    TaskTemplate as BuildTaskTemplate, TcpArgumentsTemplate,
+};

crates/extension_api/Cargo.toml 🔗

@@ -1,6 +1,6 @@
 [package]
 name = "zed_extension_api"
-version = "0.5.0"
+version = "0.6.0"
 description = "APIs for creating Zed extensions in Rust"
 repository = "https://github.com/zed-industries/zed"
 documentation = "https://docs.rs/zed_extension_api"

crates/extension_api/README.md 🔗

@@ -23,7 +23,7 @@ need to set your `crate-type` accordingly:
 
 ```toml
 [dependencies]
-zed_extension_api = "0.5.0"
+zed_extension_api = "0.6.0"
 
 [lib]
 crate-type = ["cdylib"]
@@ -51,6 +51,8 @@ zed::register_extension!(MyExtension);
 
 To run your extension in Zed as you're developing it:
 
+- Make sure you have [Rust installed](https://www.rust-lang.org/learn/get-started)
+- Have the `wasm32-wasip2` target installed (`rustup target add wasm32-wasip2`)
 - Open the extensions view using the `zed: extensions` action in the command palette.
 - Click the `Install Dev Extension` button in the top right
 - Choose the path to your extension directory.
@@ -63,6 +65,7 @@ Here is the compatibility of the `zed_extension_api` with versions of Zed:
 
 | Zed version | `zed_extension_api` version |
 | ----------- | --------------------------- |
+| `0.192.x`   | `0.0.1` - `0.6.0`           |
 | `0.186.x`   | `0.0.1` - `0.5.0`           |
 | `0.184.x`   | `0.0.1` - `0.4.0`           |
 | `0.178.x`   | `0.0.1` - `0.3.0`           |

crates/extension_api/src/extension_api.rs 🔗

@@ -19,6 +19,12 @@ pub use wit::{
     KeyValueStore, LanguageServerInstallationStatus, Project, Range, Worktree, download_file,
     make_file_executable,
     zed::extension::context_server::ContextServerConfiguration,
+    zed::extension::dap::{
+        AttachRequest, BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, BuildTaskTemplate,
+        DebugAdapterBinary, DebugConfig, DebugRequest, DebugScenario, DebugTaskDefinition,
+        LaunchRequest, StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest,
+        TaskTemplate, TcpArguments, TcpArgumentsTemplate, resolve_tcp_template,
+    },
     zed::extension::github::{
         GithubRelease, GithubReleaseAsset, GithubReleaseOptions, github_release_by_tag_name,
         latest_github_release,
@@ -187,6 +193,72 @@ pub trait Extension: Send + Sync {
     ) -> Result<(), String> {
         Err("`index_docs` not implemented".to_string())
     }
+
+    /// Returns the debug adapter binary for the specified adapter name and configuration.
+    fn get_dap_binary(
+        &mut self,
+        _adapter_name: String,
+        _config: DebugTaskDefinition,
+        _user_provided_debug_adapter_path: Option<String>,
+        _worktree: &Worktree,
+    ) -> Result<DebugAdapterBinary, String> {
+        Err("`get_dap_binary` not implemented".to_string())
+    }
+
+    /// Determines whether the specified adapter configuration should *launch* a new debuggee process
+    /// or *attach* to an existing one. This function should not perform any further validation (outside of determining the kind of a request).
+    /// This function should return an error when the kind cannot be determined (rather than fall back to a known default).
+    fn dap_request_kind(
+        &mut self,
+        _adapter_name: String,
+        _config: serde_json::Value,
+    ) -> Result<StartDebuggingRequestArgumentsRequest, String> {
+        Err("`dap_request_kind` not implemented".to_string())
+    }
+    /// Converts a high-level definition of a debug scenario (originating in a new session UI) to a "low-level" configuration suitable for a particular adapter.
+    ///
+    /// In layman's terms: given a program, list of arguments, current working directory and environment variables,
+    /// create a configuration that can be used to start a debug session.
+    fn dap_config_to_scenario(&mut self, _config: DebugConfig) -> Result<DebugScenario, String> {
+        Err("`dap_config_to_scenario` not implemented".to_string())
+    }
+
+    /// Locators are entities that convert a Zed task into a debug scenario.
+    ///
+    /// They can be provided even by extensions that don't provide a debug adapter.
+    /// For all tasks applicable to a given buffer, Zed will query all locators to find one that can turn the task into a debug scenario.
+    /// A converted debug scenario can include a build task (it shouldn't contain any configuration in such case); a build task result will later
+    /// be resolved with [`Extension::run_dap_locator`].
+    ///
+    /// To work through a real-world example, take a `cargo run` task and a hypothetical `cargo` locator:
+    /// 1. We may need to modify the task; in this case, it is problematic that `cargo run` spawns a binary. We should turn `cargo run` into a debug scenario with
+    /// `cargo build` task. This is the decision we make at `dap_locator_create_scenario` scope.
+    /// 2. Then, after the build task finishes, we will run `run_dap_locator` of the locator that produced the build task to find the program to be debugged. This function
+    /// should give us a debugger-agnostic configuration for launching a debug target (that we end up resolving with [`Extension::dap_config_to_scenario`]). It's almost as if the user
+    /// found the artifact path by themselves.
+    ///
+    /// Note that you're not obliged to use build tasks with locators. Specifically, it is sufficient to provide a debug configuration directly in the return value of
+    /// `dap_locator_create_scenario` if you're able to do that. Make sure to not fill out `build` field in that case, as that will prevent Zed from running second phase of resolution in such case.
+    /// This might be of particular relevance to interpreted languages.
+    fn dap_locator_create_scenario(
+        &mut self,
+        _locator_name: String,
+        _build_task: TaskTemplate,
+        _resolved_label: String,
+        _debug_adapter_name: String,
+    ) -> Option<DebugScenario> {
+        None
+    }
+
+    /// Runs the second phase of locator resolution.
+    /// See [`Extension::dap_locator_create_scenario`] for a hefty comment on locators.
+    fn run_dap_locator(
+        &mut self,
+        _locator_name: String,
+        _build_task: TaskTemplate,
+    ) -> Result<DebugRequest, String> {
+        Err("`run_dap_locator` not implemented".to_string())
+    }
 }
 
 /// Registers the provided type as a Zed extension.
@@ -228,7 +300,7 @@ mod wit {
 
     wit_bindgen::generate!({
         skip: ["init-extension"],
-        path: "./wit/since_v0.5.0",
+        path: "./wit/since_v0.6.0",
     });
 }
 
@@ -371,6 +443,47 @@ impl wit::Guest for Component {
     ) -> Result<(), String> {
         extension().index_docs(provider, package, database)
     }
+
+    fn get_dap_binary(
+        adapter_name: String,
+        config: DebugTaskDefinition,
+        user_installed_path: Option<String>,
+        worktree: &Worktree,
+    ) -> Result<wit::DebugAdapterBinary, String> {
+        extension().get_dap_binary(adapter_name, config, user_installed_path, worktree)
+    }
+
+    fn dap_request_kind(
+        adapter_name: String,
+        config: String,
+    ) -> Result<StartDebuggingRequestArgumentsRequest, String> {
+        extension().dap_request_kind(
+            adapter_name,
+            serde_json::from_str(&config).map_err(|e| format!("Failed to parse config: {e}"))?,
+        )
+    }
+    fn dap_config_to_scenario(config: DebugConfig) -> Result<DebugScenario, String> {
+        extension().dap_config_to_scenario(config)
+    }
+    fn dap_locator_create_scenario(
+        locator_name: String,
+        build_task: TaskTemplate,
+        resolved_label: String,
+        debug_adapter_name: String,
+    ) -> Option<DebugScenario> {
+        extension().dap_locator_create_scenario(
+            locator_name,
+            build_task,
+            resolved_label,
+            debug_adapter_name,
+        )
+    }
+    fn run_dap_locator(
+        locator_name: String,
+        build_task: TaskTemplate,
+    ) -> Result<DebugRequest, String> {
+        extension().run_dap_locator(locator_name, build_task)
+    }
 }
 
 /// The ID of a language server.

crates/extension_api/wit/since_v0.6.0/common.wit 🔗

@@ -0,0 +1,12 @@
+interface common {
+    /// A (half-open) range (`[start, end)`).
+    record range {
+        /// The start of the range (inclusive).
+        start: u32,
+        /// The end of the range (exclusive).
+        end: u32,
+    }
+
+    /// A list of environment variables.
+    type env-vars = list<tuple<string, string>>;
+}

crates/extension_api/wit/since_v0.6.0/context-server.wit 🔗

@@ -0,0 +1,11 @@
+interface context-server {
+    /// Configuration for context server setup and installation.
+    record context-server-configuration {
+        /// Installation instructions in Markdown format.
+        installation-instructions: string,
+        /// JSON schema for settings validation.
+        settings-schema: string,
+        /// Default settings template.
+        default-settings: string,
+    }
+}

crates/extension_api/wit/since_v0.6.0/dap.wit 🔗

@@ -0,0 +1,123 @@
+interface dap {
+    use common.{env-vars};
+
+    /// Resolves a specified TcpArgumentsTemplate into TcpArguments
+    resolve-tcp-template: func(template: tcp-arguments-template) -> result<tcp-arguments, string>;
+
+    record launch-request {
+        program: string,
+        cwd: option<string>,
+        args: list<string>,
+        envs: env-vars,
+    }
+
+    record attach-request {
+        process-id: option<u32>,
+    }
+
+    variant debug-request {
+        launch(launch-request),
+        attach(attach-request)
+    }
+
+    record tcp-arguments {
+        port: u16,
+        host: u32,
+        timeout: option<u64>,
+    }
+
+    record tcp-arguments-template {
+        port: option<u16>,
+        host: option<u32>,
+        timeout: option<u64>,
+    }
+
+    /// Debug Config is the "highest-level" configuration for a debug session.
+    /// It comes from a new session modal UI; thus, it is essentially debug-adapter-agnostic.
+    /// It is expected of the extension to translate this generic configuration into something that can be debugged by the adapter (debug scenario).
+    record debug-config {
+        /// Name of the debug task
+        label: string,
+        /// The debug adapter to use
+        adapter: string,
+        request: debug-request,
+        stop-on-entry: option<bool>,
+    }
+
+    record task-template {
+        /// Human readable name of the task to display in the UI.
+        label: string,
+        /// Executable command to spawn.
+        command: string,
+        args: list<string>,
+        env: env-vars,
+        cwd: option<string>,
+    }
+
+    /// A task template with substituted task variables.
+    type resolved-task = task-template;
+
+    /// A task template for building a debug target.
+    type build-task-template = task-template;
+
+    variant build-task-definition {
+        by-name(string),
+        template(build-task-definition-template-payload )
+    }
+    record build-task-definition-template-payload {
+        locator-name: option<string>,
+        template: build-task-template
+    }
+
+    /// Debug Scenario is the user-facing configuration type (used in debug.json). It is still concerned with what to debug and not necessarily how to do it (except for any
+    /// debug-adapter-specific configuration options).
+    record debug-scenario {
+        /// Unsubstituted label for the task.DebugAdapterBinary
+        label: string,
+        /// Name of the Debug Adapter this configuration is intended for.
+        adapter: string,
+        /// An optional build step to be ran prior to starting a debug session. Build steps are used by Zed's locators to locate the executable to debug.
+        build: option<build-task-definition>,
+        /// JSON-encoded configuration for a given debug adapter.
+        config: string,
+        /// TCP connection parameters (if they were specified by user)
+        tcp-connection: option<tcp-arguments-template>,
+    }
+
+    enum start-debugging-request-arguments-request {
+        launch,
+        attach,
+    }
+
+    record debug-task-definition {
+        /// Unsubstituted label for the task.DebugAdapterBinary
+        label: string,
+        /// Name of the Debug Adapter this configuration is intended for.
+        adapter: string,
+        /// JSON-encoded configuration for a given debug adapter.
+        config: string,
+        /// TCP connection parameters (if they were specified by user)
+        tcp-connection: option<tcp-arguments-template>,
+    }
+
+    record start-debugging-request-arguments {
+        /// JSON-encoded configuration for a given debug adapter. It is specific to each debug adapter.
+        /// `configuration` will have it's Zed variable references substituted prior to being passed to the debug adapter.
+        configuration: string,
+        request: start-debugging-request-arguments-request,
+    }
+
+    /// The lowest-level representation of a debug session, which specifies:
+    /// - How to start a debug adapter process
+    /// - How to start a debug session with it (using DAP protocol)
+    /// for a given debug scenario.
+    record debug-adapter-binary {
+        command: option<string>,
+        arguments: list<string>,
+        envs: env-vars,
+        cwd: option<string>,
+        /// Zed will use TCP transport if `connection` is specified.
+        connection: option<tcp-arguments>,
+        request-args: start-debugging-request-arguments
+    }
+}

crates/extension_api/wit/since_v0.6.0/extension.wit 🔗

@@ -0,0 +1,167 @@
+package zed:extension;
+
+world extension {
+    import context-server;
+    import dap;
+    import github;
+    import http-client;
+    import platform;
+    import process;
+    import nodejs;
+
+    use common.{env-vars, range};
+    use context-server.{context-server-configuration};
+    use dap.{attach-request, build-task-template, debug-config, debug-adapter-binary, debug-task-definition, debug-request, debug-scenario, launch-request, resolved-task, start-debugging-request-arguments-request};
+    use lsp.{completion, symbol};
+    use process.{command};
+    use slash-command.{slash-command, slash-command-argument-completion, slash-command-output};
+
+    /// Initializes the extension.
+    export init-extension: func();
+
+    /// The type of a downloaded file.
+    enum downloaded-file-type {
+        /// A gzipped file (`.gz`).
+        gzip,
+        /// A gzipped tar archive (`.tar.gz`).
+        gzip-tar,
+        /// A ZIP file (`.zip`).
+        zip,
+        /// An uncompressed file.
+        uncompressed,
+    }
+
+    /// The installation status for a language server.
+    variant language-server-installation-status {
+        /// The language server has no installation status.
+        none,
+        /// The language server is being downloaded.
+        downloading,
+        /// The language server is checking for updates.
+        checking-for-update,
+        /// The language server installation failed for specified reason.
+        failed(string),
+    }
+
+    record settings-location {
+        worktree-id: u64,
+        path: string,
+    }
+
+    import get-settings: func(path: option<settings-location>, category: string, key: option<string>) -> result<string, string>;
+
+    /// Downloads a file from the given URL and saves it to the given path within the extension's
+    /// working directory.
+    ///
+    /// The file will be extracted according to the given file type.
+    import download-file: func(url: string, file-path: string, file-type: downloaded-file-type) -> result<_, string>;
+
+    /// Makes the file at the given path executable.
+    import make-file-executable: func(filepath: string) -> result<_, string>;
+
+    /// Updates the installation status for the given language server.
+    import set-language-server-installation-status: func(language-server-name: string, status: language-server-installation-status);
+
+    /// A Zed worktree.
+    resource worktree {
+        /// Returns the ID of the worktree.
+        id: func() -> u64;
+        /// Returns the root path of the worktree.
+        root-path: func() -> string;
+        /// Returns the textual contents of the specified file in the worktree.
+        read-text-file: func(path: string) -> result<string, string>;
+        /// Returns the path to the given binary name, if one is present on the `$PATH`.
+        which: func(binary-name: string) -> option<string>;
+        /// Returns the current shell environment.
+        shell-env: func() -> env-vars;
+    }
+
+    /// A Zed project.
+    resource project {
+        /// Returns the IDs of all of the worktrees in this project.
+        worktree-ids: func() -> list<u64>;
+    }
+
+    /// A key-value store.
+    resource key-value-store {
+        /// Inserts an entry under the specified key.
+        insert: func(key: string, value: string) -> result<_, string>;
+    }
+
+    /// Returns the command used to start up the language server.
+    export language-server-command: func(language-server-id: string, worktree: borrow<worktree>) -> result<command, string>;
+
+    /// Returns the initialization options to pass to the language server on startup.
+    ///
+    /// The initialization options are represented as a JSON string.
+    export language-server-initialization-options: func(language-server-id: string, worktree: borrow<worktree>) -> result<option<string>, string>;
+
+    /// Returns the workspace configuration options to pass to the language server.
+    export language-server-workspace-configuration: func(language-server-id: string, worktree: borrow<worktree>) -> result<option<string>, string>;
+
+    /// Returns the initialization options to pass to the other language server.
+    export language-server-additional-initialization-options: func(language-server-id: string, target-language-server-id: string, worktree: borrow<worktree>) -> result<option<string>, string>;
+
+    /// Returns the workspace configuration options to pass to the other language server.
+    export language-server-additional-workspace-configuration: func(language-server-id: string, target-language-server-id: string, worktree: borrow<worktree>) -> result<option<string>, string>;
+
+    /// A label containing some code.
+    record code-label {
+        /// The source code to parse with Tree-sitter.
+        code: string,
+        /// The spans to display in the label.
+        spans: list<code-label-span>,
+        /// The range of the displayed label to include when filtering.
+        filter-range: range,
+    }
+
+    /// A span within a code label.
+    variant code-label-span {
+        /// A range into the parsed code.
+        code-range(range),
+        /// A span containing a code literal.
+        literal(code-label-span-literal),
+    }
+
+    /// A span containing a code literal.
+    record code-label-span-literal {
+        /// The literal text.
+        text: string,
+        /// The name of the highlight to use for this literal.
+        highlight-name: option<string>,
+    }
+
+    export labels-for-completions: func(language-server-id: string, completions: list<completion>) -> result<list<option<code-label>>, string>;
+    export labels-for-symbols: func(language-server-id: string, symbols: list<symbol>) -> result<list<option<code-label>>, string>;
+
+
+    /// Returns the completions that should be shown when completing the provided slash command with the given query.
+    export complete-slash-command-argument: func(command: slash-command, args: list<string>) -> result<list<slash-command-argument-completion>, string>;
+
+    /// Returns the output from running the provided slash command.
+    export run-slash-command: func(command: slash-command, args: list<string>, worktree: option<borrow<worktree>>) -> result<slash-command-output, string>;
+
+    /// Returns the command used to start up a context server.
+    export context-server-command: func(context-server-id: string, project: borrow<project>) -> result<command, string>;
+
+    /// Returns the configuration for a context server.
+    export context-server-configuration: func(context-server-id: string, project: borrow<project>) -> result<option<context-server-configuration>, string>;
+
+    /// Returns a list of packages as suggestions to be included in the `/docs`
+    /// search results.
+    ///
+    /// This can be used to provide completions for known packages (e.g., from the
+    /// local project or a registry) before a package has been indexed.
+    export suggest-docs-packages: func(provider-name: string) -> result<list<string>, string>;
+
+    /// Indexes the docs for the specified package.
+    export index-docs: func(provider-name: string, package-name: string, database: borrow<key-value-store>) -> result<_, string>;
+
+    /// Returns a configured debug adapter binary for a given debug task.
+    export get-dap-binary: func(adapter-name: string, config: debug-task-definition, user-installed-path: option<string>, worktree: borrow<worktree>) -> result<debug-adapter-binary, string>;
+    /// Returns the kind of a debug scenario (launch or attach).
+    export dap-request-kind: func(adapter-name: string, config: string) -> result<start-debugging-request-arguments-request, string>;
+    export dap-config-to-scenario: func(config: debug-config) -> result<debug-scenario, string>;
+    export dap-locator-create-scenario: func(locator-name: string, build-config-template: build-task-template, resolved-label: string, debug-adapter-name: string) -> option<debug-scenario>;
+    export run-dap-locator: func(locator-name: string, config: resolved-task) -> result<debug-request, string>;
+}

crates/extension_api/wit/since_v0.6.0/github.wit 🔗

@@ -0,0 +1,35 @@
+interface github {
+    /// A GitHub release.
+    record github-release {
+        /// The version of the release.
+        version: string,
+        /// The list of assets attached to the release.
+        assets: list<github-release-asset>,
+    }
+
+    /// An asset from a GitHub release.
+    record github-release-asset {
+        /// The name of the asset.
+        name: string,
+        /// The download URL for the asset.
+        download-url: string,
+    }
+
+    /// The options used to filter down GitHub releases.
+    record github-release-options {
+        /// Whether releases without assets should be included.
+        require-assets: bool,
+        /// Whether pre-releases should be included.
+        pre-release: bool,
+    }
+
+    /// Returns the latest release for the given GitHub repository.
+    ///
+    /// Takes repo as a string in the form "<owner-name>/<repo-name>", for example: "zed-industries/zed".
+    latest-github-release: func(repo: string, options: github-release-options) -> result<github-release, string>;
+
+    /// Returns the GitHub release with the specified tag name for the given GitHub repository.
+    ///
+    /// Returns an error if a release with the given tag name does not exist.
+    github-release-by-tag-name: func(repo: string, tag: string) -> result<github-release, string>;
+}

crates/extension_api/wit/since_v0.6.0/http-client.wit 🔗

@@ -0,0 +1,67 @@
+interface http-client {
+    /// An HTTP request.
+    record http-request {
+        /// The HTTP method for the request.
+        method: http-method,
+        /// The URL to which the request should be made.
+        url: string,
+        /// The headers for the request.
+        headers: list<tuple<string, string>>,
+        /// The request body.
+        body: option<list<u8>>,
+        /// The policy to use for redirects.
+        redirect-policy: redirect-policy,
+    }
+
+    /// HTTP methods.
+    enum http-method {
+        /// `GET`
+        get,
+        /// `HEAD`
+        head,
+        /// `POST`
+        post,
+        /// `PUT`
+        put,
+        /// `DELETE`
+        delete,
+        /// `OPTIONS`
+        options,
+        /// `PATCH`
+        patch,
+    }
+
+    /// The policy for dealing with redirects received from the server.
+    variant redirect-policy {
+        /// Redirects from the server will not be followed.
+        ///
+        /// This is the default behavior.
+        no-follow,
+        /// Redirects from the server will be followed up to the specified limit.
+        follow-limit(u32),
+        /// All redirects from the server will be followed.
+        follow-all,
+    }
+
+    /// An HTTP response.
+    record http-response {
+        /// The response headers.
+        headers: list<tuple<string, string>>,
+        /// The response body.
+        body: list<u8>,
+    }
+
+    /// Performs an HTTP request and returns the response.
+    fetch: func(req: http-request) -> result<http-response, string>;
+
+    /// An HTTP response stream.
+    resource http-response-stream {
+        /// Retrieves the next chunk of data from the response stream.
+        ///
+        /// Returns `Ok(None)` if the stream has ended.
+        next-chunk: func() -> result<option<list<u8>>, string>;
+    }
+
+    /// Performs an HTTP request and returns a response stream.
+    fetch-stream: func(req: http-request) -> result<http-response-stream, string>;
+}

crates/extension_api/wit/since_v0.6.0/lsp.wit 🔗

@@ -0,0 +1,90 @@
+interface lsp {
+    /// An LSP completion.
+    record completion {
+        label: string,
+        label-details: option<completion-label-details>,
+        detail: option<string>,
+        kind: option<completion-kind>,
+        insert-text-format: option<insert-text-format>,
+    }
+
+    /// The kind of an LSP completion.
+    variant completion-kind {
+        text,
+        method,
+        function,
+        %constructor,
+        field,
+        variable,
+        class,
+        %interface,
+        module,
+        property,
+        unit,
+        value,
+        %enum,
+        keyword,
+        snippet,
+        color,
+        file,
+        reference,
+        folder,
+        enum-member,
+        constant,
+        struct,
+        event,
+        operator,
+        type-parameter,
+        other(s32),
+    }
+
+    /// Label details for an LSP completion.
+    record completion-label-details {
+        detail: option<string>,
+        description: option<string>,
+    }
+
+    /// Defines how to interpret the insert text in a completion item.
+    variant insert-text-format {
+        plain-text,
+        snippet,
+        other(s32),
+    }
+
+    /// An LSP symbol.
+    record symbol {
+        kind: symbol-kind,
+        name: string,
+    }
+
+    /// The kind of an LSP symbol.
+    variant symbol-kind {
+        file,
+        module,
+        namespace,
+        %package,
+        class,
+        method,
+        property,
+        field,
+        %constructor,
+        %enum,
+        %interface,
+        function,
+        variable,
+        constant,
+        %string,
+        number,
+        boolean,
+        array,
+        object,
+        key,
+        null,
+        enum-member,
+        struct,
+        event,
+        operator,
+        type-parameter,
+        other(s32),
+    }
+}

crates/extension_api/wit/since_v0.6.0/nodejs.wit 🔗

@@ -0,0 +1,13 @@
+interface nodejs {
+    /// Returns the path to the Node binary used by Zed.
+    node-binary-path: func() -> result<string, string>;
+
+    /// Returns the latest version of the given NPM package.
+    npm-package-latest-version: func(package-name: string) -> result<string, string>;
+
+    /// Returns the installed version of the given NPM package, if it exists.
+    npm-package-installed-version: func(package-name: string) -> result<option<string>, string>;
+
+    /// Installs the specified NPM package.
+    npm-install-package: func(package-name: string, version: string) -> result<_, string>;
+}

crates/extension_api/wit/since_v0.6.0/platform.wit 🔗

@@ -0,0 +1,24 @@
+interface platform {
+    /// An operating system.
+    enum os {
+        /// macOS.
+        mac,
+        /// Linux.
+        linux,
+        /// Windows.
+        windows,
+    }
+
+    /// A platform architecture.
+    enum architecture {
+        /// AArch64 (e.g., Apple Silicon).
+        aarch64,
+        /// x86.
+        x86,
+        /// x86-64.
+        x8664,
+    }
+
+    /// Gets the current operating system and architecture.
+    current-platform: func() -> tuple<os, architecture>;
+}

crates/extension_api/wit/since_v0.6.0/process.wit 🔗

@@ -0,0 +1,29 @@
+interface process {
+    use common.{env-vars};
+
+    /// A command.
+    record command {
+        /// The command to execute.
+        command: string,
+        /// The arguments to pass to the command.
+        args: list<string>,
+        /// The environment variables to set for the command.
+        env: env-vars,
+    }
+
+    /// The output of a finished process.
+    record output {
+        /// The status (exit code) of the process.
+        ///
+        /// On Unix, this will be `None` if the process was terminated by a signal.
+        status: option<s32>,
+        /// The data that the process wrote to stdout.
+        stdout: list<u8>,
+        /// The data that the process wrote to stderr.
+        stderr: list<u8>,
+    }
+
+    /// Executes the given command as a child process, waiting for it to finish
+    /// and collecting all of its output.
+    run-command: func(command: command) -> result<output, string>;
+}

crates/extension_api/wit/since_v0.6.0/settings.rs 🔗

@@ -0,0 +1,40 @@
+use serde::{Deserialize, Serialize};
+use std::{collections::HashMap, num::NonZeroU32};
+
+/// The settings for a particular language.
+#[derive(Debug, Serialize, Deserialize)]
+pub struct LanguageSettings {
+    /// How many columns a tab should occupy.
+    pub tab_size: NonZeroU32,
+}
+
+/// The settings for a particular language server.
+#[derive(Default, Debug, Serialize, Deserialize)]
+pub struct LspSettings {
+    /// The settings for the language server binary.
+    pub binary: Option<CommandSettings>,
+    /// The initialization options to pass to the language server.
+    pub initialization_options: Option<serde_json::Value>,
+    /// The settings to pass to language server.
+    pub settings: Option<serde_json::Value>,
+}
+
+/// The settings for a particular context server.
+#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
+pub struct ContextServerSettings {
+    /// The settings for the context server binary.
+    pub command: Option<CommandSettings>,
+    /// The settings to pass to the context server.
+    pub settings: Option<serde_json::Value>,
+}
+
+/// The settings for a command.
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
+pub struct CommandSettings {
+    /// The path to the command.
+    pub path: Option<String>,
+    /// The arguments to pass to the command.
+    pub arguments: Option<Vec<String>>,
+    /// The environment variables.
+    pub env: Option<HashMap<String, String>>,
+}

crates/extension_api/wit/since_v0.6.0/slash-command.wit 🔗

@@ -0,0 +1,41 @@
+interface slash-command {
+    use common.{range};
+
+    /// A slash command for use in the Assistant.
+    record slash-command {
+        /// The name of the slash command.
+        name: string,
+        /// The description of the slash command.
+        description: string,
+        /// The tooltip text to display for the run button.
+        tooltip-text: string,
+        /// Whether this slash command requires an argument.
+        requires-argument: bool,
+    }
+
+    /// The output of a slash command.
+    record slash-command-output {
+        /// The text produced by the slash command.
+        text: string,
+        /// The list of sections to show in the slash command placeholder.
+        sections: list<slash-command-output-section>,
+    }
+
+    /// A section in the slash command output.
+    record slash-command-output-section {
+        /// The range this section occupies.
+        range: range,
+        /// The label to display in the placeholder for this section.
+        label: string,
+    }
+
+    /// A completion for a slash command argument.
+    record slash-command-argument-completion {
+        /// The label to display for this completion.
+        label: string,
+        /// The new text that should be inserted into the command when this completion is accepted.
+        new-text: string,
+        /// Whether the command should be run when accepting this completion.
+        run-command: bool,
+    }
+}

crates/extension_cli/src/main.rs 🔗

@@ -6,7 +6,7 @@ use std::process::Command;
 use std::sync::Arc;
 
 use ::fs::{CopyOptions, Fs, RealFs, copy_recursive};
-use anyhow::{Context, Result, anyhow, bail};
+use anyhow::{Context as _, Result, bail};
 use clap::Parser;
 use extension::ExtensionManifest;
 use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder};
@@ -107,7 +107,7 @@ async fn main() -> Result<()> {
         schema_version: Some(manifest.schema_version.0),
         repository: manifest
             .repository
-            .ok_or_else(|| anyhow!("missing repository in extension manifest"))?,
+            .context("missing repository in extension manifest")?,
         wasm_api_version: manifest.lib.version.map(|version| version.to_string()),
         provides: extension_provides,
     })?;
@@ -152,6 +152,10 @@ fn extension_provides(manifest: &ExtensionManifest) -> BTreeSet<ExtensionProvide
         provides.insert(ExtensionProvides::Snippets);
     }
 
+    if !manifest.debug_adapters.is_empty() {
+        provides.insert(ExtensionProvides::DebugAdapters);
+    }
+
     provides
 }
 
@@ -196,11 +200,7 @@ async fn copy_extension_resources(
         for theme_path in &manifest.themes {
             fs::copy(
                 extension_path.join(theme_path),
-                output_themes_dir.join(
-                    theme_path
-                        .file_name()
-                        .ok_or_else(|| anyhow!("invalid theme path"))?,
-                ),
+                output_themes_dir.join(theme_path.file_name().context("invalid theme path")?),
             )
             .with_context(|| format!("failed to copy theme '{}'", theme_path.display()))?;
         }
@@ -215,7 +215,7 @@ async fn copy_extension_resources(
                 output_icon_themes_dir.join(
                     icon_theme_path
                         .file_name()
-                        .ok_or_else(|| anyhow!("invalid icon theme path"))?,
+                        .context("invalid icon theme path")?,
                 ),
             )
             .with_context(|| {
@@ -245,11 +245,8 @@ async fn copy_extension_resources(
             copy_recursive(
                 fs.as_ref(),
                 &extension_path.join(language_path),
-                &output_languages_dir.join(
-                    language_path
-                        .file_name()
-                        .ok_or_else(|| anyhow!("invalid language path"))?,
-                ),
+                &output_languages_dir
+                    .join(language_path.file_name().context("invalid language path")?),
                 CopyOptions {
                     overwrite: true,
                     ignore_if_exists: false,
@@ -262,6 +259,36 @@ async fn copy_extension_resources(
         }
     }
 
+    if !manifest.debug_adapters.is_empty() {
+        for (debug_adapter, entry) in &manifest.debug_adapters {
+            let schema_path = entry.schema_path.clone().unwrap_or_else(|| {
+                PathBuf::from("debug_adapter_schemas".to_owned())
+                    .join(debug_adapter.as_ref())
+                    .with_extension("json")
+            });
+            let parent = schema_path
+                .parent()
+                .with_context(|| format!("invalid empty schema path for {debug_adapter}"))?;
+            fs::create_dir_all(output_dir.join(parent))?;
+            copy_recursive(
+                fs.as_ref(),
+                &extension_path.join(&schema_path),
+                &output_dir.join(&schema_path),
+                CopyOptions {
+                    overwrite: true,
+                    ignore_if_exists: false,
+                },
+            )
+            .await
+            .with_context(|| {
+                format!(
+                    "failed to copy debug adapter schema '{}'",
+                    schema_path.display()
+                )
+            })?;
+        }
+    }
+
     Ok(())
 }
 
@@ -300,7 +327,7 @@ fn test_languages(
             Some(
                 grammars
                     .get(name.as_ref())
-                    .ok_or_else(|| anyhow!("grammar not found: '{name}'"))?,
+                    .with_context(|| format!("grammar not found: '{name}'"))?,
             )
         } else {
             None
@@ -311,12 +338,12 @@ fn test_languages(
             let entry = entry?;
             let query_path = entry.path();
             if query_path.extension() == Some("scm".as_ref()) {
-                let grammar = grammar.ok_or_else(|| {
-                    anyhow!(
+                let grammar = grammar.with_context(|| {
+                    format! {
                         "language {} provides query {} but no grammar",
                         config.name,
                         query_path.display()
-                    )
+                    }
                 })?;
 
                 let query_source = fs::read_to_string(&query_path)?;

crates/extension_host/Cargo.toml 🔗

@@ -22,6 +22,7 @@ async-tar.workspace = true
 async-trait.workspace = true
 client.workspace = true
 collections.workspace = true
+dap.workspace = true
 extension.workspace = true
 fs.workspace = true
 futures.workspace = true
@@ -30,6 +31,7 @@ http_client.workspace = true
 language.workspace = true
 log.workspace = true
 lsp.workspace = true
+moka.workspace = true
 node_runtime.workspace = true
 paths.workspace = true
 project.workspace = true
@@ -53,14 +55,20 @@ wasmtime.workspace = true
 workspace-hack.workspace = true
 
 [dev-dependencies]
+criterion.workspace = true
 ctor.workspace = true
-env_logger.workspace = true
 fs = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
 language_extension.workspace = true
 parking_lot.workspace = true
 project = { workspace = true, features = ["test-support"] }
+rand.workspace = true
 reqwest_client.workspace = true
 theme = { workspace = true, features = ["test-support"] }
 theme_extension.workspace = true
+zlog.workspace = true
+
+[[bench]]
+name = "extension_compilation_benchmark"
+harness = false

crates/extension_host/benches/extension_compilation_benchmark.rs 🔗

@@ -0,0 +1,147 @@
+use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
+
+use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main};
+use extension::{
+    ExtensionCapability, ExtensionHostProxy, ExtensionLibraryKind, ExtensionManifest,
+    LanguageServerManifestEntry, LibManifestEntry, SchemaVersion,
+    extension_builder::{CompileExtensionOptions, ExtensionBuilder},
+};
+use extension_host::wasm_host::WasmHost;
+use fs::RealFs;
+use gpui::{SemanticVersion, TestAppContext, TestDispatcher};
+use http_client::{FakeHttpClient, Response};
+use node_runtime::NodeRuntime;
+use rand::{SeedableRng, rngs::StdRng};
+use reqwest_client::ReqwestClient;
+use serde_json::json;
+use settings::SettingsStore;
+use util::test::TempTree;
+
+fn extension_benchmarks(c: &mut Criterion) {
+    let cx = init();
+
+    let mut group = c.benchmark_group("load");
+
+    let mut manifest = manifest();
+    let wasm_bytes = wasm_bytes(&cx, &mut manifest);
+    let manifest = Arc::new(manifest);
+    let extensions_dir = TempTree::new(json!({
+        "installed": {},
+        "work": {}
+    }));
+    let wasm_host = wasm_host(&cx, &extensions_dir);
+
+    group.bench_function(BenchmarkId::from_parameter(1), |b| {
+        b.iter_batched(
+            || wasm_bytes.clone(),
+            |wasm_bytes| {
+                let _extension = cx
+                    .executor()
+                    .block(wasm_host.load_extension(wasm_bytes, &manifest, cx.executor()))
+                    .unwrap();
+            },
+            BatchSize::SmallInput,
+        );
+    });
+}
+
+fn init() -> TestAppContext {
+    const SEED: u64 = 9999;
+    let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(SEED));
+    let cx = TestAppContext::build(dispatcher, None);
+    cx.executor().allow_parking();
+    cx.update(|cx| {
+        let store = SettingsStore::test(cx);
+        cx.set_global(store);
+        release_channel::init(SemanticVersion::default(), cx);
+    });
+
+    cx
+}
+
+fn wasm_bytes(cx: &TestAppContext, manifest: &mut ExtensionManifest) -> Vec<u8> {
+    let extension_builder = extension_builder();
+    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+        .parent()
+        .unwrap()
+        .parent()
+        .unwrap()
+        .join("extensions/test-extension");
+    cx.executor()
+        .block(extension_builder.compile_extension(
+            &path,
+            manifest,
+            CompileExtensionOptions { release: true },
+        ))
+        .unwrap();
+    std::fs::read(path.join("extension.wasm")).unwrap()
+}
+
+fn extension_builder() -> ExtensionBuilder {
+    let user_agent = format!(
+        "Zed Extension CLI/{} ({}; {})",
+        env!("CARGO_PKG_VERSION"),
+        std::env::consts::OS,
+        std::env::consts::ARCH
+    );
+    let http_client = Arc::new(ReqwestClient::user_agent(&user_agent).unwrap());
+    // Local dir so that we don't have to download it on every run
+    let build_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("benches/.build");
+    ExtensionBuilder::new(http_client, build_dir)
+}
+
+fn wasm_host(cx: &TestAppContext, extensions_dir: &TempTree) -> Arc<WasmHost> {
+    let http_client = FakeHttpClient::create(async |_| {
+        Ok(Response::builder().status(404).body("not found".into())?)
+    });
+    let extensions_dir = extensions_dir.path().canonicalize().unwrap();
+    let work_dir = extensions_dir.join("work");
+    let fs = Arc::new(RealFs::new(None, cx.executor()));
+
+    cx.update(|cx| {
+        WasmHost::new(
+            fs,
+            http_client,
+            NodeRuntime::unavailable(),
+            Arc::new(ExtensionHostProxy::new()),
+            work_dir,
+            cx,
+        )
+    })
+}
+
+fn manifest() -> ExtensionManifest {
+    ExtensionManifest {
+        id: "test-extension".into(),
+        name: "Test Extension".into(),
+        version: "0.1.0".into(),
+        schema_version: SchemaVersion(1),
+        description: Some("An extension for use in tests.".into()),
+        authors: Vec::new(),
+        repository: None,
+        themes: Default::default(),
+        icon_themes: Vec::new(),
+        lib: LibManifestEntry {
+            kind: Some(ExtensionLibraryKind::Rust),
+            version: Some(SemanticVersion::new(0, 1, 0)),
+        },
+        languages: Vec::new(),
+        grammars: BTreeMap::default(),
+        language_servers: [("gleam".into(), LanguageServerManifestEntry::default())]
+            .into_iter()
+            .collect(),
+        context_servers: BTreeMap::default(),
+        slash_commands: BTreeMap::default(),
+        indexed_docs_providers: BTreeMap::default(),
+        snippets: None,
+        capabilities: vec![ExtensionCapability::ProcessExec {
+            command: "echo".into(),
+            args: vec!["hello!".into()],
+        }],
+        debug_adapters: Default::default(),
+        debug_locators: Default::default(),
+    }
+}
+
+criterion_group!(benches, extension_benchmarks);
+criterion_main!(benches);

crates/extension_host/src/extension_host.rs 🔗

@@ -14,9 +14,10 @@ use collections::{BTreeMap, BTreeSet, HashMap, HashSet, btree_map};
 pub use extension::ExtensionManifest;
 use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder};
 use extension::{
-    ExtensionContextServerProxy, ExtensionEvents, ExtensionGrammarProxy, ExtensionHostProxy,
-    ExtensionIndexedDocsProviderProxy, ExtensionLanguageProxy, ExtensionLanguageServerProxy,
-    ExtensionSlashCommandProxy, ExtensionSnippetProxy, ExtensionThemeProxy,
+    ExtensionContextServerProxy, ExtensionDebugAdapterProviderProxy, ExtensionEvents,
+    ExtensionGrammarProxy, ExtensionHostProxy, ExtensionIndexedDocsProviderProxy,
+    ExtensionLanguageProxy, ExtensionLanguageServerProxy, ExtensionSlashCommandProxy,
+    ExtensionSnippetProxy, ExtensionThemeProxy,
 };
 use fs::{Fs, RemoveOptions};
 use futures::{
@@ -131,6 +132,7 @@ pub enum Event {
     ExtensionsUpdated,
     StartedReloading,
     ExtensionInstalled(Arc<str>),
+    ExtensionUninstalled(Arc<str>),
     ExtensionFailedToLoad(Arc<str>),
 }
 
@@ -716,7 +718,7 @@ impl ExtensionStore {
             let mut response = http_client
                 .get(url.as_ref(), Default::default(), true)
                 .await
-                .map_err(|err| anyhow!("error downloading extension: {}", err))?;
+                .context("downloading extension")?;
 
             fs.remove_dir(
                 &extension_dir,
@@ -836,13 +838,19 @@ impl ExtensionStore {
         self.install_or_upgrade_extension_at_endpoint(extension_id, url, operation, cx)
     }
 
-    pub fn uninstall_extension(&mut self, extension_id: Arc<str>, cx: &mut Context<Self>) {
+    pub fn uninstall_extension(
+        &mut self,
+        extension_id: Arc<str>,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
         let extension_dir = self.installed_dir.join(extension_id.as_ref());
         let work_dir = self.wasm_host.work_dir.join(extension_id.as_ref());
         let fs = self.fs.clone();
 
+        let extension_manifest = self.extension_manifest_for_id(&extension_id).cloned();
+
         match self.outstanding_operations.entry(extension_id.clone()) {
-            btree_map::Entry::Occupied(_) => return,
+            btree_map::Entry::Occupied(_) => return Task::ready(Ok(())),
             btree_map::Entry::Vacant(e) => e.insert(ExtensionOperation::Remove),
         };
 
@@ -877,9 +885,19 @@ impl ExtensionStore {
             )
             .await?;
 
+            this.update(cx, |_, cx| {
+                cx.emit(Event::ExtensionUninstalled(extension_id.clone()));
+                if let Some(events) = ExtensionEvents::try_global(cx) {
+                    if let Some(manifest) = extension_manifest {
+                        events.update(cx, |this, cx| {
+                            this.emit(extension::Event::ExtensionUninstalled(manifest.clone()), cx)
+                        });
+                    }
+                }
+            })?;
+
             anyhow::Ok(())
         })
-        .detach_and_log_err(cx)
     }
 
     pub fn install_dev_extension(
@@ -1134,6 +1152,12 @@ impl ExtensionStore {
             for (server_id, _) in extension.manifest.context_servers.iter() {
                 self.proxy.unregister_context_server(server_id.clone(), cx);
             }
+            for (adapter, _) in extension.manifest.debug_adapters.iter() {
+                self.proxy.unregister_debug_adapter(adapter.clone());
+            }
+            for (locator, _) in extension.manifest.debug_locators.iter() {
+                self.proxy.unregister_debug_locator(locator.clone());
+            }
         }
 
         self.wasm_extensions
@@ -1328,6 +1352,28 @@ impl ExtensionStore {
                         this.proxy
                             .register_indexed_docs_provider(extension.clone(), provider_id.clone());
                     }
+
+                    for (debug_adapter, meta) in &manifest.debug_adapters {
+                        let mut path = root_dir.clone();
+                        path.push(Path::new(manifest.id.as_ref()));
+                        if let Some(schema_path) = &meta.schema_path {
+                            path.push(schema_path);
+                        } else {
+                            path.push("debug_adapter_schemas");
+                            path.push(Path::new(debug_adapter.as_ref()).with_extension("json"));
+                        }
+
+                        this.proxy.register_debug_adapter(
+                            extension.clone(),
+                            debug_adapter.clone(),
+                            &path,
+                        );
+                    }
+
+                    for debug_adapter in manifest.debug_locators.keys() {
+                        this.proxy
+                            .register_debug_locator(extension.clone(), debug_adapter.clone());
+                    }
                 }
 
                 this.wasm_extensions.extend(wasm_extensions);
@@ -1409,7 +1455,7 @@ impl ExtensionStore {
         let is_dev = fs
             .metadata(&extension_dir)
             .await?
-            .ok_or_else(|| anyhow!("directory does not exist"))?
+            .context("directory does not exist")?
             .is_symlink;
 
         if let Ok(mut language_paths) = fs.read_dir(&extension_dir.join("languages")).await {
@@ -1676,12 +1722,15 @@ impl ExtensionStore {
 
     pub fn register_ssh_client(&mut self, client: Entity<SshRemoteClient>, cx: &mut Context<Self>) {
         let connection_options = client.read(cx).connection_options();
-        if self.ssh_clients.contains_key(&connection_options.ssh_url()) {
-            return;
+        let ssh_url = connection_options.ssh_url();
+
+        if let Some(existing_client) = self.ssh_clients.get(&ssh_url) {
+            if existing_client.upgrade().is_some() {
+                return;
+            }
         }
 
-        self.ssh_clients
-            .insert(connection_options.ssh_url(), client.downgrade());
+        self.ssh_clients.insert(ssh_url, client.downgrade());
         self.ssh_registered_tx.unbounded_send(()).ok();
     }
 }

crates/extension_host/src/extension_store_test.rs 🔗

@@ -4,11 +4,11 @@ use crate::{
     GrammarManifestEntry, RELOAD_DEBOUNCE_DURATION, SchemaVersion,
 };
 use async_compression::futures::bufread::GzipEncoder;
-use collections::BTreeMap;
+use collections::{BTreeMap, HashSet};
 use extension::ExtensionHostProxy;
 use fs::{FakeFs, Fs, RealFs};
 use futures::{AsyncReadExt, StreamExt, io::BufReader};
-use gpui::{AppContext as _, SemanticVersion, SharedString, TestAppContext};
+use gpui::{AppContext as _, SemanticVersion, TestAppContext};
 use http_client::{FakeHttpClient, Response};
 use language::{BinaryStatus, LanguageMatcher, LanguageRegistry};
 use lsp::LanguageServerName;
@@ -30,9 +30,7 @@ use util::test::TempTree;
 #[cfg(test)]
 #[ctor::ctor]
 fn init_logger() {
-    if std::env::var("RUST_LOG").is_ok() {
-        env_logger::init();
-    }
+    zlog::init_test();
 }
 
 #[gpui::test]
@@ -164,6 +162,8 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                         indexed_docs_providers: BTreeMap::default(),
                         snippets: None,
                         capabilities: Vec::new(),
+                        debug_adapters: Default::default(),
+                        debug_locators: Default::default(),
                     }),
                     dev: false,
                 },
@@ -193,6 +193,8 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                         indexed_docs_providers: BTreeMap::default(),
                         snippets: None,
                         capabilities: Vec::new(),
+                        debug_adapters: Default::default(),
+                        debug_locators: Default::default(),
                     }),
                     dev: false,
                 },
@@ -367,6 +369,8 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                 indexed_docs_providers: BTreeMap::default(),
                 snippets: None,
                 capabilities: Vec::new(),
+                debug_adapters: Default::default(),
+                debug_locators: Default::default(),
             }),
             dev: false,
         },
@@ -478,7 +482,9 @@ async fn test_extension_store(cx: &mut TestAppContext) {
     });
 
     store.update(cx, |store, cx| {
-        store.uninstall_extension("zed-ruby".into(), cx)
+        store
+            .uninstall_extension("zed-ruby".into(), cx)
+            .detach_and_log_err(cx);
     });
 
     cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
@@ -714,11 +720,22 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
             status_updates.next().await.unwrap(),
             status_updates.next().await.unwrap(),
             status_updates.next().await.unwrap(),
+            status_updates.next().await.unwrap(),
         ],
         [
-            (SharedString::new("gleam"), BinaryStatus::CheckingForUpdate),
-            (SharedString::new("gleam"), BinaryStatus::Downloading),
-            (SharedString::new("gleam"), BinaryStatus::None)
+            (
+                LanguageServerName::new_static("gleam"),
+                BinaryStatus::Starting
+            ),
+            (
+                LanguageServerName::new_static("gleam"),
+                BinaryStatus::CheckingForUpdate
+            ),
+            (
+                LanguageServerName::new_static("gleam"),
+                BinaryStatus::Downloading
+            ),
+            (LanguageServerName::new_static("gleam"), BinaryStatus::None)
         ]
     );
 
@@ -758,8 +775,8 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
         })
         .await
         .unwrap()
-        .unwrap()
         .into_iter()
+        .flat_map(|response| response.completions)
         .map(|c| c.label.text)
         .collect::<Vec<_>>();
     assert_eq!(
@@ -779,7 +796,7 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
 
     // Start a new instance of the language server.
     project.update(cx, |project, cx| {
-        project.restart_language_servers_for_buffers(vec![buffer.clone()], cx)
+        project.restart_language_servers_for_buffers(vec![buffer.clone()], HashSet::default(), cx)
     });
     cx.executor().run_until_parked();
 
@@ -801,7 +818,7 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
 
     cx.executor().run_until_parked();
     project.update(cx, |project, cx| {
-        project.restart_language_servers_for_buffers(vec![buffer.clone()], cx)
+        project.restart_language_servers_for_buffers(vec![buffer.clone()], HashSet::default(), cx)
     });
 
     // The extension re-fetches the latest version of the language server.

crates/extension_host/src/headless_host.rs 🔗

@@ -1,6 +1,6 @@
 use std::{path::PathBuf, sync::Arc};
 
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use client::{TypedEnvelope, proto};
 use collections::{HashMap, HashSet};
 use extension::{
@@ -295,7 +295,7 @@ impl HeadlessExtensionStore {
         let extension = envelope
             .payload
             .extension
-            .with_context(|| anyhow!("Invalid InstallExtension request"))?;
+            .context("Invalid InstallExtension request")?;
 
         extensions
             .update(&mut cx, |extensions, cx| {

crates/extension_host/src/wasm_host.rs 🔗

@@ -3,10 +3,11 @@ pub mod wit;
 use crate::ExtensionManifest;
 use anyhow::{Context as _, Result, anyhow, bail};
 use async_trait::async_trait;
+use dap::{DebugRequest, StartDebuggingRequestArgumentsRequest};
 use extension::{
-    CodeLabel, Command, Completion, ContextServerConfiguration, ExtensionHostProxy,
-    KeyValueStoreDelegate, ProjectDelegate, SlashCommand, SlashCommandArgumentCompletion,
-    SlashCommandOutput, Symbol, WorktreeDelegate,
+    CodeLabel, Command, Completion, ContextServerConfiguration, DebugAdapterBinary,
+    DebugTaskDefinition, ExtensionHostProxy, KeyValueStoreDelegate, ProjectDelegate, SlashCommand,
+    SlashCommandArgumentCompletion, SlashCommandOutput, Symbol, WorktreeDelegate,
 };
 use fs::{Fs, normalize_path};
 use futures::future::LocalBoxFuture;
@@ -18,19 +19,24 @@ use futures::{
     },
     future::BoxFuture,
 };
-use gpui::{App, AsyncApp, BackgroundExecutor, Task};
+use gpui::{App, AsyncApp, BackgroundExecutor, Task, Timer};
 use http_client::HttpClient;
 use language::LanguageName;
 use lsp::LanguageServerName;
+use moka::sync::Cache;
 use node_runtime::NodeRuntime;
 use release_channel::ReleaseChannel;
 use semantic_version::SemanticVersion;
+use std::borrow::Cow;
+use std::sync::{LazyLock, OnceLock};
+use std::time::Duration;
 use std::{
     path::{Path, PathBuf},
-    sync::{Arc, OnceLock},
+    sync::Arc,
 };
+use task::{DebugScenario, SpawnInTerminal, TaskTemplate, ZedDebugConfig};
 use wasmtime::{
-    Engine, Store,
+    CacheStore, Engine, Store,
     component::{Component, ResourceTable},
 };
 use wasmtime_wasi::{self as wasi, WasiView};
@@ -84,7 +90,7 @@ impl extension::Extension for WasmExtension {
                         resource,
                     )
                     .await?
-                    .map_err(|err| anyhow!("{err}"))?;
+                    .map_err(|err| store.data().extension_error(err))?;
 
                 Ok(command.into())
             }
@@ -110,7 +116,7 @@ impl extension::Extension for WasmExtension {
                         resource,
                     )
                     .await?
-                    .map_err(|err| anyhow!("{err}"))?;
+                    .map_err(|err| store.data().extension_error(err))?;
                 anyhow::Ok(options)
             }
             .boxed()
@@ -133,7 +139,7 @@ impl extension::Extension for WasmExtension {
                         resource,
                     )
                     .await?
-                    .map_err(|err| anyhow!("{err}"))?;
+                    .map_err(|err| store.data().extension_error(err))?;
                 anyhow::Ok(options)
             }
             .boxed()
@@ -158,7 +164,7 @@ impl extension::Extension for WasmExtension {
                         resource,
                     )
                     .await?
-                    .map_err(|err| anyhow!("{err}"))?;
+                    .map_err(|err| store.data().extension_error(err))?;
                 anyhow::Ok(options)
             }
             .boxed()
@@ -183,7 +189,7 @@ impl extension::Extension for WasmExtension {
                         resource,
                     )
                     .await?
-                    .map_err(|err| anyhow!("{err}"))?;
+                    .map_err(|err| store.data().extension_error(err))?;
                 anyhow::Ok(options)
             }
             .boxed()
@@ -205,7 +211,7 @@ impl extension::Extension for WasmExtension {
                         completions.into_iter().map(Into::into).collect(),
                     )
                     .await?
-                    .map_err(|err| anyhow!("{err}"))?;
+                    .map_err(|err| store.data().extension_error(err))?;
 
                 Ok(labels
                     .into_iter()
@@ -231,7 +237,7 @@ impl extension::Extension for WasmExtension {
                         symbols.into_iter().map(Into::into).collect(),
                     )
                     .await?
-                    .map_err(|err| anyhow!("{err}"))?;
+                    .map_err(|err| store.data().extension_error(err))?;
 
                 Ok(labels
                     .into_iter()
@@ -253,7 +259,7 @@ impl extension::Extension for WasmExtension {
                 let completions = extension
                     .call_complete_slash_command_argument(store, &command.into(), &arguments)
                     .await?
-                    .map_err(|err| anyhow!("{err}"))?;
+                    .map_err(|err| store.data().extension_error(err))?;
 
                 Ok(completions.into_iter().map(Into::into).collect())
             }
@@ -279,7 +285,7 @@ impl extension::Extension for WasmExtension {
                 let output = extension
                     .call_run_slash_command(store, &command.into(), &arguments, resource)
                     .await?
-                    .map_err(|err| anyhow!("{err}"))?;
+                    .map_err(|err| store.data().extension_error(err))?;
 
                 Ok(output.into())
             }
@@ -299,7 +305,7 @@ impl extension::Extension for WasmExtension {
                 let command = extension
                     .call_context_server_command(store, context_server_id.clone(), project_resource)
                     .await?
-                    .map_err(|err| anyhow!("{err}"))?;
+                    .map_err(|err| store.data().extension_error(err))?;
                 anyhow::Ok(command.into())
             }
             .boxed()
@@ -322,7 +328,7 @@ impl extension::Extension for WasmExtension {
                         project_resource,
                     )
                     .await?
-                    .map_err(|err| anyhow!("{err}"))?
+                    .map_err(|err| store.data().extension_error(err))?
                 else {
                     return Ok(None);
                 };
@@ -340,7 +346,7 @@ impl extension::Extension for WasmExtension {
                 let packages = extension
                     .call_suggest_docs_packages(store, provider.as_ref())
                     .await?
-                    .map_err(|err| anyhow!("{err:?}"))?;
+                    .map_err(|err| store.data().extension_error(err))?;
 
                 Ok(packages)
             }
@@ -366,7 +372,7 @@ impl extension::Extension for WasmExtension {
                         kv_store_resource,
                     )
                     .await?
-                    .map_err(|err| anyhow!("{err:?}"))?;
+                    .map_err(|err| store.data().extension_error(err))?;
 
                 anyhow::Ok(())
             }
@@ -374,6 +380,99 @@ impl extension::Extension for WasmExtension {
         })
         .await
     }
+
+    async fn get_dap_binary(
+        &self,
+        dap_name: Arc<str>,
+        config: DebugTaskDefinition,
+        user_installed_path: Option<PathBuf>,
+        worktree: Arc<dyn WorktreeDelegate>,
+    ) -> Result<DebugAdapterBinary> {
+        self.call(|extension, store| {
+            async move {
+                let resource = store.data_mut().table().push(worktree)?;
+                let dap_binary = extension
+                    .call_get_dap_binary(store, dap_name, config, user_installed_path, resource)
+                    .await?
+                    .map_err(|err| store.data().extension_error(err))?;
+                let dap_binary = dap_binary.try_into()?;
+                Ok(dap_binary)
+            }
+            .boxed()
+        })
+        .await
+    }
+    async fn dap_request_kind(
+        &self,
+        dap_name: Arc<str>,
+        config: serde_json::Value,
+    ) -> Result<StartDebuggingRequestArgumentsRequest> {
+        self.call(|extension, store| {
+            async move {
+                let kind = extension
+                    .call_dap_request_kind(store, dap_name, config)
+                    .await?
+                    .map_err(|err| store.data().extension_error(err))?;
+                Ok(kind.into())
+            }
+            .boxed()
+        })
+        .await
+    }
+
+    async fn dap_config_to_scenario(&self, config: ZedDebugConfig) -> Result<DebugScenario> {
+        self.call(|extension, store| {
+            async move {
+                let kind = extension
+                    .call_dap_config_to_scenario(store, config)
+                    .await?
+                    .map_err(|err| store.data().extension_error(err))?;
+                Ok(kind)
+            }
+            .boxed()
+        })
+        .await
+    }
+
+    async fn dap_locator_create_scenario(
+        &self,
+        locator_name: String,
+        build_config_template: TaskTemplate,
+        resolved_label: String,
+        debug_adapter_name: String,
+    ) -> Result<Option<DebugScenario>> {
+        self.call(|extension, store| {
+            async move {
+                extension
+                    .call_dap_locator_create_scenario(
+                        store,
+                        locator_name,
+                        build_config_template,
+                        resolved_label,
+                        debug_adapter_name,
+                    )
+                    .await
+            }
+            .boxed()
+        })
+        .await
+    }
+    async fn run_dap_locator(
+        &self,
+        locator_name: String,
+        config: SpawnInTerminal,
+    ) -> Result<DebugRequest> {
+        self.call(|extension, store| {
+            async move {
+                extension
+                    .call_run_dap_locator(store, locator_name, config)
+                    .await?
+                    .map_err(|err| store.data().extension_error(err))
+            }
+            .boxed()
+        })
+        .await
+    }
 }
 
 pub struct WasmState {
@@ -389,19 +488,60 @@ type ExtensionCall = Box<
     dyn Send + for<'a> FnOnce(&'a mut Extension, &'a mut Store<WasmState>) -> BoxFuture<'a, ()>,
 >;
 
-fn wasm_engine() -> wasmtime::Engine {
+fn wasm_engine(executor: &BackgroundExecutor) -> wasmtime::Engine {
     static WASM_ENGINE: OnceLock<wasmtime::Engine> = OnceLock::new();
-
     WASM_ENGINE
         .get_or_init(|| {
             let mut config = wasmtime::Config::new();
             config.wasm_component_model(true);
             config.async_support(true);
-            wasmtime::Engine::new(&config).unwrap()
+            config
+                .enable_incremental_compilation(cache_store())
+                .unwrap();
+            // Async support introduces the issue that extension execution happens during `Future::poll`,
+            // which could block an async thread.
+            // https://docs.rs/wasmtime/latest/wasmtime/struct.Config.html#execution-in-poll
+            //
+            // Epoch interruption is a lightweight mechanism to allow the extensions to yield control
+            // back to the executor at regular intervals.
+            config.epoch_interruption(true);
+
+            let engine = wasmtime::Engine::new(&config).unwrap();
+
+            // It might be safer to do this on a non-async thread to make sure it makes progress
+            // regardless of if extensions are blocking.
+            // However, due to our current setup, this isn't a likely occurrence and we'd rather
+            // not have a dedicated thread just for this. If it becomes an issue, we can consider
+            // creating a separate thread for epoch interruption.
+            let engine_ref = engine.weak();
+            executor
+                .spawn(async move {
+                    // Somewhat arbitrary interval, as it isn't a guaranteed interval.
+                    // But this is a rough upper bound for how long the extension execution can block on
+                    // `Future::poll`.
+                    const EPOCH_INTERVAL: Duration = Duration::from_millis(100);
+                    let mut timer = Timer::interval(EPOCH_INTERVAL);
+                    while let Some(_) = timer.next().await {
+                        // Exit the loop and thread once the engine is dropped.
+                        let Some(engine) = engine_ref.upgrade() else {
+                            break;
+                        };
+                        engine.increment_epoch();
+                    }
+                })
+                .detach();
+
+            engine
         })
         .clone()
 }
 
+fn cache_store() -> Arc<IncrementalCompilationCache> {
+    static CACHE_STORE: LazyLock<Arc<IncrementalCompilationCache>> =
+        LazyLock::new(|| Arc::new(IncrementalCompilationCache::new()));
+    CACHE_STORE.clone()
+}
+
 impl WasmHost {
     pub fn new(
         fs: Arc<dyn Fs>,
@@ -418,7 +558,7 @@ impl WasmHost {
             }
         });
         Arc::new(Self {
-            engine: wasm_engine(),
+            engine: wasm_engine(cx.background_executor()),
             fs,
             work_dir,
             http_client,
@@ -453,8 +593,12 @@ impl WasmHost {
                     host: this.clone(),
                 },
             );
+            // Store will yield after 1 tick, and get a new deadline of 1 tick after each yield.
+            store.set_epoch_deadline(1);
+            store.epoch_deadline_async_yield_and_update(1);
 
             let mut extension = Extension::instantiate_async(
+                &executor,
                 &mut store,
                 this.release_channel,
                 zed_api_version,
@@ -512,11 +656,11 @@ impl WasmHost {
     pub fn writeable_path_from_extension(&self, id: &Arc<str>, path: &Path) -> Result<PathBuf> {
         let extension_work_dir = self.work_dir.join(id.as_ref());
         let path = normalize_path(&extension_work_dir.join(path));
-        if path.starts_with(&extension_work_dir) {
-            Ok(path)
-        } else {
-            Err(anyhow!("cannot write to path {}", path.display()))
-        }
+        anyhow::ensure!(
+            path.starts_with(&extension_work_dir),
+            "cannot write to path {path:?}",
+        );
+        Ok(path)
     }
 }
 
@@ -548,7 +692,7 @@ pub fn parse_wasm_extension_version(
     //
     // By parsing the entirety of the Wasm bytes before we return, we're able to detect this problem
     // earlier as an `Err` rather than as a panic.
-    version.ok_or_else(|| anyhow!("extension {} has no zed:api-version section", extension_id))
+    version.with_context(|| format!("extension {extension_id} has no zed:api-version section"))
 }
 
 fn parse_wasm_extension_version_custom_section(data: &[u8]) -> Option<SemanticVersion> {
@@ -635,6 +779,15 @@ impl WasmState {
     fn work_dir(&self) -> PathBuf {
         self.host.work_dir.join(self.manifest.id.as_ref())
     }
+
+    fn extension_error(&self, message: String) -> anyhow::Error {
+        anyhow!(
+            "from extension \"{}\" version {}: {}",
+            self.manifest.name,
+            self.manifest.version,
+            message
+        )
+    }
 }
 
 impl wasi::WasiView for WasmState {
@@ -646,3 +799,36 @@ impl wasi::WasiView for WasmState {
         &mut self.ctx
     }
 }
+
+/// Wrapper around a mini-moka bounded cache for storing incremental compilation artifacts.
+/// Since wasm modules have many similar elements, this can save us a lot of work at the
+/// cost of a small memory footprint. However, we don't want this to be unbounded, so we use
+/// a LFU/LRU cache to evict less used cache entries.
+#[derive(Debug)]
+struct IncrementalCompilationCache {
+    cache: Cache<Vec<u8>, Vec<u8>>,
+}
+
+impl IncrementalCompilationCache {
+    fn new() -> Self {
+        let cache = Cache::builder()
+            // Cap this at 32 MB for now. Our extensions turn into roughly 512kb in the cache,
+            // which means we could store 64 completely novel extensions in the cache, but in
+            // practice we will more than that, which is more than enough for our use case.
+            .max_capacity(32 * 1024 * 1024)
+            .weigher(|k: &Vec<u8>, v: &Vec<u8>| (k.len() + v.len()).try_into().unwrap_or(u32::MAX))
+            .build();
+        Self { cache }
+    }
+}
+
+impl CacheStore for IncrementalCompilationCache {
+    fn get(&self, key: &[u8]) -> Option<Cow<'_, [u8]>> {
+        self.cache.get(key).map(|v| v.into())
+    }
+
+    fn insert(&self, key: &[u8], value: Vec<u8>) -> bool {
+        self.cache.insert(key.to_vec(), value);
+        true
+    }
+}

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

@@ -6,16 +6,22 @@ mod since_v0_2_0;
 mod since_v0_3_0;
 mod since_v0_4_0;
 mod since_v0_5_0;
-use extension::{KeyValueStoreDelegate, WorktreeDelegate};
+mod since_v0_6_0;
+use dap::DebugRequest;
+use extension::{DebugTaskDefinition, KeyValueStoreDelegate, WorktreeDelegate};
+use gpui::BackgroundExecutor;
 use language::LanguageName;
 use lsp::LanguageServerName;
 use release_channel::ReleaseChannel;
-use since_v0_5_0 as latest;
+use task::{DebugScenario, SpawnInTerminal, TaskTemplate, ZedDebugConfig};
+
+use crate::wasm_host::wit::since_v0_6_0::dap::StartDebuggingRequestArgumentsRequest;
 
 use super::{WasmState, wasm_engine};
 use anyhow::{Context as _, Result, anyhow};
 use semantic_version::SemanticVersion;
-use std::{ops::RangeInclusive, sync::Arc};
+use since_v0_6_0 as latest;
+use std::{ops::RangeInclusive, path::PathBuf, sync::Arc};
 use wasmtime::{
     Store,
     component::{Component, Linker, Resource},
@@ -24,7 +30,7 @@ use wasmtime::{
 #[cfg(test)]
 pub use latest::CodeLabelSpanLiteral;
 pub use latest::{
-    CodeLabel, CodeLabelSpan, Command, ExtensionProject, Range, SlashCommand,
+    CodeLabel, CodeLabelSpan, Command, DebugAdapterBinary, ExtensionProject, Range, SlashCommand,
     zed::extension::context_server::ContextServerConfiguration,
     zed::extension::lsp::{
         Completion, CompletionKind, CompletionLabelDetails, InsertTextFormat, Symbol, SymbolKind,
@@ -34,9 +40,10 @@ pub use latest::{
 pub use since_v0_0_4::LanguageServerConfig;
 
 pub fn new_linker(
+    executor: &BackgroundExecutor,
     f: impl Fn(&mut Linker<WasmState>, fn(&mut WasmState) -> &mut WasmState) -> Result<()>,
 ) -> Linker<WasmState> {
-    let mut linker = Linker::new(&wasm_engine());
+    let mut linker = Linker::new(&wasm_engine(executor));
     wasmtime_wasi::add_to_linker_async(&mut linker).unwrap();
     f(&mut linker, wasi_view).unwrap();
     linker
@@ -82,16 +89,16 @@ pub fn authorize_access_to_unreleased_wasm_api_version(
         }
     };
 
-    if !allow_unreleased_version {
-        Err(anyhow!(
-            "unreleased versions of the extension API can only be used on development builds of Zed"
-        ))?;
-    }
+    anyhow::ensure!(
+        allow_unreleased_version,
+        "unreleased versions of the extension API can only be used on development builds of Zed"
+    );
 
     Ok(())
 }
 
 pub enum Extension {
+    V0_6_0(since_v0_6_0::Extension),
     V0_5_0(since_v0_5_0::Extension),
     V0_4_0(since_v0_4_0::Extension),
     V0_3_0(since_v0_3_0::Extension),
@@ -104,6 +111,7 @@ pub enum Extension {
 
 impl Extension {
     pub async fn instantiate_async(
+        executor: &BackgroundExecutor,
         store: &mut Store<WasmState>,
         release_channel: ReleaseChannel,
         version: SemanticVersion,
@@ -114,15 +122,24 @@ impl Extension {
 
         if version >= latest::MIN_VERSION {
             let extension =
-                latest::Extension::instantiate_async(store, component, latest::linker())
+                latest::Extension::instantiate_async(store, component, latest::linker(executor))
                     .await
                     .context("failed to instantiate wasm extension")?;
+            Ok(Self::V0_6_0(extension))
+        } else if version >= since_v0_5_0::MIN_VERSION {
+            let extension = since_v0_5_0::Extension::instantiate_async(
+                store,
+                component,
+                since_v0_5_0::linker(executor),
+            )
+            .await
+            .context("failed to instantiate wasm extension")?;
             Ok(Self::V0_5_0(extension))
         } else if version >= since_v0_4_0::MIN_VERSION {
             let extension = since_v0_4_0::Extension::instantiate_async(
                 store,
                 component,
-                since_v0_4_0::linker(),
+                since_v0_4_0::linker(executor),
             )
             .await
             .context("failed to instantiate wasm extension")?;
@@ -131,7 +148,7 @@ impl Extension {
             let extension = since_v0_3_0::Extension::instantiate_async(
                 store,
                 component,
-                since_v0_3_0::linker(),
+                since_v0_3_0::linker(executor),
             )
             .await
             .context("failed to instantiate wasm extension")?;
@@ -140,7 +157,7 @@ impl Extension {
             let extension = since_v0_2_0::Extension::instantiate_async(
                 store,
                 component,
-                since_v0_2_0::linker(),
+                since_v0_2_0::linker(executor),
             )
             .await
             .context("failed to instantiate wasm extension")?;
@@ -149,7 +166,7 @@ impl Extension {
             let extension = since_v0_1_0::Extension::instantiate_async(
                 store,
                 component,
-                since_v0_1_0::linker(),
+                since_v0_1_0::linker(executor),
             )
             .await
             .context("failed to instantiate wasm extension")?;
@@ -158,7 +175,7 @@ impl Extension {
             let extension = since_v0_0_6::Extension::instantiate_async(
                 store,
                 component,
-                since_v0_0_6::linker(),
+                since_v0_0_6::linker(executor),
             )
             .await
             .context("failed to instantiate wasm extension")?;
@@ -167,7 +184,7 @@ impl Extension {
             let extension = since_v0_0_4::Extension::instantiate_async(
                 store,
                 component,
-                since_v0_0_4::linker(),
+                since_v0_0_4::linker(executor),
             )
             .await
             .context("failed to instantiate wasm extension")?;
@@ -176,7 +193,7 @@ impl Extension {
             let extension = since_v0_0_1::Extension::instantiate_async(
                 store,
                 component,
-                since_v0_0_1::linker(),
+                since_v0_0_1::linker(executor),
             )
             .await
             .context("failed to instantiate wasm extension")?;
@@ -186,6 +203,7 @@ impl Extension {
 
     pub async fn call_init_extension(&self, store: &mut Store<WasmState>) -> Result<()> {
         match self {
+            Extension::V0_6_0(ext) => ext.call_init_extension(store).await,
             Extension::V0_5_0(ext) => ext.call_init_extension(store).await,
             Extension::V0_4_0(ext) => ext.call_init_extension(store).await,
             Extension::V0_3_0(ext) => ext.call_init_extension(store).await,
@@ -205,6 +223,10 @@ impl Extension {
         resource: Resource<Arc<dyn WorktreeDelegate>>,
     ) -> Result<Result<Command, String>> {
         match self {
+            Extension::V0_6_0(ext) => {
+                ext.call_language_server_command(store, &language_server_id.0, resource)
+                    .await
+            }
             Extension::V0_5_0(ext) => {
                 ext.call_language_server_command(store, &language_server_id.0, resource)
                     .await
@@ -263,6 +285,14 @@ impl Extension {
         resource: Resource<Arc<dyn WorktreeDelegate>>,
     ) -> Result<Result<Option<String>, String>> {
         match self {
+            Extension::V0_6_0(ext) => {
+                ext.call_language_server_initialization_options(
+                    store,
+                    &language_server_id.0,
+                    resource,
+                )
+                .await
+            }
             Extension::V0_5_0(ext) => {
                 ext.call_language_server_initialization_options(
                     store,
@@ -344,6 +374,14 @@ impl Extension {
         resource: Resource<Arc<dyn WorktreeDelegate>>,
     ) -> Result<Result<Option<String>, String>> {
         match self {
+            Extension::V0_6_0(ext) => {
+                ext.call_language_server_workspace_configuration(
+                    store,
+                    &language_server_id.0,
+                    resource,
+                )
+                .await
+            }
             Extension::V0_5_0(ext) => {
                 ext.call_language_server_workspace_configuration(
                     store,
@@ -404,6 +442,15 @@ impl Extension {
         resource: Resource<Arc<dyn WorktreeDelegate>>,
     ) -> Result<Result<Option<String>, String>> {
         match self {
+            Extension::V0_6_0(ext) => {
+                ext.call_language_server_additional_initialization_options(
+                    store,
+                    &language_server_id.0,
+                    &target_language_server_id.0,
+                    resource,
+                )
+                .await
+            }
             Extension::V0_5_0(ext) => {
                 ext.call_language_server_additional_initialization_options(
                     store,
@@ -439,6 +486,15 @@ impl Extension {
         resource: Resource<Arc<dyn WorktreeDelegate>>,
     ) -> Result<Result<Option<String>, String>> {
         match self {
+            Extension::V0_6_0(ext) => {
+                ext.call_language_server_additional_workspace_configuration(
+                    store,
+                    &language_server_id.0,
+                    &target_language_server_id.0,
+                    resource,
+                )
+                .await
+            }
             Extension::V0_5_0(ext) => {
                 ext.call_language_server_additional_workspace_configuration(
                     store,
@@ -473,10 +529,23 @@ impl Extension {
         completions: Vec<latest::Completion>,
     ) -> Result<Result<Vec<Option<CodeLabel>>, String>> {
         match self {
-            Extension::V0_5_0(ext) => {
+            Extension::V0_6_0(ext) => {
                 ext.call_labels_for_completions(store, &language_server_id.0, &completions)
                     .await
             }
+            Extension::V0_5_0(ext) => Ok(ext
+                .call_labels_for_completions(
+                    store,
+                    &language_server_id.0,
+                    &completions.into_iter().collect::<Vec<_>>(),
+                )
+                .await?
+                .map(|labels| {
+                    labels
+                        .into_iter()
+                        .map(|label| label.map(Into::into))
+                        .collect()
+                })),
             Extension::V0_4_0(ext) => Ok(ext
                 .call_labels_for_completions(
                     store,
@@ -553,10 +622,23 @@ impl Extension {
         symbols: Vec<latest::Symbol>,
     ) -> Result<Result<Vec<Option<CodeLabel>>, String>> {
         match self {
-            Extension::V0_5_0(ext) => {
+            Extension::V0_6_0(ext) => {
                 ext.call_labels_for_symbols(store, &language_server_id.0, &symbols)
                     .await
             }
+            Extension::V0_5_0(ext) => Ok(ext
+                .call_labels_for_symbols(
+                    store,
+                    &language_server_id.0,
+                    &symbols.into_iter().collect::<Vec<_>>(),
+                )
+                .await?
+                .map(|labels| {
+                    labels
+                        .into_iter()
+                        .map(|label| label.map(Into::into))
+                        .collect()
+                })),
             Extension::V0_4_0(ext) => Ok(ext
                 .call_labels_for_symbols(
                     store,
@@ -633,6 +715,10 @@ impl Extension {
         arguments: &[String],
     ) -> Result<Result<Vec<SlashCommandArgumentCompletion>, String>> {
         match self {
+            Extension::V0_6_0(ext) => {
+                ext.call_complete_slash_command_argument(store, command, arguments)
+                    .await
+            }
             Extension::V0_5_0(ext) => {
                 ext.call_complete_slash_command_argument(store, command, arguments)
                     .await
@@ -667,6 +753,10 @@ impl Extension {
         resource: Option<Resource<Arc<dyn WorktreeDelegate>>>,
     ) -> Result<Result<SlashCommandOutput, String>> {
         match self {
+            Extension::V0_6_0(ext) => {
+                ext.call_run_slash_command(store, command, arguments, resource)
+                    .await
+            }
             Extension::V0_5_0(ext) => {
                 ext.call_run_slash_command(store, command, arguments, resource)
                     .await
@@ -688,7 +778,7 @@ impl Extension {
                     .await
             }
             Extension::V0_0_1(_) | Extension::V0_0_4(_) | Extension::V0_0_6(_) => {
-                Err(anyhow!("`run_slash_command` not available prior to v0.1.0"))
+                anyhow::bail!("`run_slash_command` not available prior to v0.1.0");
             }
         }
     }
@@ -700,6 +790,10 @@ impl Extension {
         project: Resource<ExtensionProject>,
     ) -> Result<Result<Command, String>> {
         match self {
+            Extension::V0_6_0(ext) => {
+                ext.call_context_server_command(store, &context_server_id, project)
+                    .await
+            }
             Extension::V0_5_0(ext) => {
                 ext.call_context_server_command(store, &context_server_id, project)
                     .await
@@ -719,9 +813,9 @@ impl Extension {
             Extension::V0_0_1(_)
             | Extension::V0_0_4(_)
             | Extension::V0_0_6(_)
-            | Extension::V0_1_0(_) => Err(anyhow!(
-                "`context_server_command` not available prior to v0.2.0"
-            )),
+            | Extension::V0_1_0(_) => {
+                anyhow::bail!("`context_server_command` not available prior to v0.2.0");
+            }
         }
     }
 
@@ -732,6 +826,10 @@ impl Extension {
         project: Resource<ExtensionProject>,
     ) -> Result<Result<Option<ContextServerConfiguration>, String>> {
         match self {
+            Extension::V0_6_0(ext) => {
+                ext.call_context_server_configuration(store, &context_server_id, project)
+                    .await
+            }
             Extension::V0_5_0(ext) => {
                 ext.call_context_server_configuration(store, &context_server_id, project)
                     .await
@@ -742,9 +840,9 @@ impl Extension {
             | Extension::V0_1_0(_)
             | Extension::V0_2_0(_)
             | Extension::V0_3_0(_)
-            | Extension::V0_4_0(_) => Err(anyhow!(
-                "`context_server_configuration` not available prior to v0.5.0"
-            )),
+            | Extension::V0_4_0(_) => {
+                anyhow::bail!("`context_server_configuration` not available prior to v0.5.0");
+            }
         }
     }
 
@@ -754,14 +852,15 @@ impl Extension {
         provider: &str,
     ) -> Result<Result<Vec<String>, String>> {
         match self {
+            Extension::V0_6_0(ext) => ext.call_suggest_docs_packages(store, provider).await,
             Extension::V0_5_0(ext) => ext.call_suggest_docs_packages(store, provider).await,
             Extension::V0_4_0(ext) => ext.call_suggest_docs_packages(store, provider).await,
             Extension::V0_3_0(ext) => ext.call_suggest_docs_packages(store, provider).await,
             Extension::V0_2_0(ext) => ext.call_suggest_docs_packages(store, provider).await,
             Extension::V0_1_0(ext) => ext.call_suggest_docs_packages(store, provider).await,
-            Extension::V0_0_1(_) | Extension::V0_0_4(_) | Extension::V0_0_6(_) => Err(anyhow!(
-                "`suggest_docs_packages` not available prior to v0.1.0"
-            )),
+            Extension::V0_0_1(_) | Extension::V0_0_4(_) | Extension::V0_0_6(_) => {
+                anyhow::bail!("`suggest_docs_packages` not available prior to v0.1.0");
+            }
         }
     }
 
@@ -773,6 +872,10 @@ impl Extension {
         kv_store: Resource<Arc<dyn KeyValueStoreDelegate>>,
     ) -> Result<Result<(), String>> {
         match self {
+            Extension::V0_6_0(ext) => {
+                ext.call_index_docs(store, provider, package_name, kv_store)
+                    .await
+            }
             Extension::V0_5_0(ext) => {
                 ext.call_index_docs(store, provider, package_name, kv_store)
                     .await
@@ -794,8 +897,117 @@ impl Extension {
                     .await
             }
             Extension::V0_0_1(_) | Extension::V0_0_4(_) | Extension::V0_0_6(_) => {
-                Err(anyhow!("`index_docs` not available prior to v0.1.0"))
+                anyhow::bail!("`index_docs` not available prior to v0.1.0");
+            }
+        }
+    }
+    pub async fn call_get_dap_binary(
+        &self,
+        store: &mut Store<WasmState>,
+        adapter_name: Arc<str>,
+        task: DebugTaskDefinition,
+        user_installed_path: Option<PathBuf>,
+        resource: Resource<Arc<dyn WorktreeDelegate>>,
+    ) -> Result<Result<DebugAdapterBinary, String>> {
+        match self {
+            Extension::V0_6_0(ext) => {
+                let dap_binary = ext
+                    .call_get_dap_binary(
+                        store,
+                        &adapter_name,
+                        &task.try_into()?,
+                        user_installed_path.as_ref().and_then(|p| p.to_str()),
+                        resource,
+                    )
+                    .await?
+                    .map_err(|e| anyhow!("{e:?}"))?;
+
+                Ok(Ok(dap_binary))
+            }
+            _ => anyhow::bail!("`get_dap_binary` not available prior to v0.6.0"),
+        }
+    }
+    pub async fn call_dap_request_kind(
+        &self,
+        store: &mut Store<WasmState>,
+        adapter_name: Arc<str>,
+        config: serde_json::Value,
+    ) -> Result<Result<StartDebuggingRequestArgumentsRequest, String>> {
+        match self {
+            Extension::V0_6_0(ext) => {
+                let config =
+                    serde_json::to_string(&config).context("Adapter config is not a valid JSON")?;
+                let dap_binary = ext
+                    .call_dap_request_kind(store, &adapter_name, &config)
+                    .await?
+                    .map_err(|e| anyhow!("{e:?}"))?;
+
+                Ok(Ok(dap_binary))
+            }
+            _ => anyhow::bail!("`dap_request_kind` not available prior to v0.6.0"),
+        }
+    }
+    pub async fn call_dap_config_to_scenario(
+        &self,
+        store: &mut Store<WasmState>,
+        config: ZedDebugConfig,
+    ) -> Result<Result<DebugScenario, String>> {
+        match self {
+            Extension::V0_6_0(ext) => {
+                let config = config.into();
+                let dap_binary = ext
+                    .call_dap_config_to_scenario(store, &config)
+                    .await?
+                    .map_err(|e| anyhow!("{e:?}"))?;
+
+                Ok(Ok(dap_binary.try_into()?))
+            }
+            _ => anyhow::bail!("`dap_config_to_scenario` not available prior to v0.6.0"),
+        }
+    }
+    pub async fn call_dap_locator_create_scenario(
+        &self,
+        store: &mut Store<WasmState>,
+        locator_name: String,
+        build_config_template: TaskTemplate,
+        resolved_label: String,
+        debug_adapter_name: String,
+    ) -> Result<Option<DebugScenario>> {
+        match self {
+            Extension::V0_6_0(ext) => {
+                let build_config_template = build_config_template.into();
+                let dap_binary = ext
+                    .call_dap_locator_create_scenario(
+                        store,
+                        &locator_name,
+                        &build_config_template,
+                        &resolved_label,
+                        &debug_adapter_name,
+                    )
+                    .await?;
+
+                Ok(dap_binary.map(TryInto::try_into).transpose()?)
+            }
+            _ => anyhow::bail!("`dap_locator_create_scenario` not available prior to v0.6.0"),
+        }
+    }
+    pub async fn call_run_dap_locator(
+        &self,
+        store: &mut Store<WasmState>,
+        locator_name: String,
+        resolved_build_task: SpawnInTerminal,
+    ) -> Result<Result<DebugRequest, String>> {
+        match self {
+            Extension::V0_6_0(ext) => {
+                let build_config_template = resolved_build_task.into();
+                let dap_request = ext
+                    .call_run_dap_locator(store, &locator_name, &build_config_template)
+                    .await?
+                    .map_err(|e| anyhow!("{e:?}"))?;
+
+                Ok(Ok(dap_request.into()))
             }
+            _ => anyhow::bail!("`dap_locator_create_scenario` not available prior to v0.6.0"),
         }
     }
 }
@@ -806,6 +1018,6 @@ trait ToWasmtimeResult<T> {
 
 impl<T> ToWasmtimeResult<T> for Result<T> {
     fn to_wasmtime_result(self) -> wasmtime::Result<Result<T, String>> {
-        Ok(self.map_err(|error| error.to_string()))
+        Ok(self.map_err(|error| format!("{error:?}")))
     }
 }

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

@@ -3,6 +3,7 @@ use crate::wasm_host::WasmState;
 use crate::wasm_host::wit::since_v0_0_4;
 use anyhow::Result;
 use extension::{ExtensionLanguageServerProxy, WorktreeDelegate};
+use gpui::BackgroundExecutor;
 use language::BinaryStatus;
 use semantic_version::SemanticVersion;
 use std::sync::{Arc, OnceLock};
@@ -23,9 +24,9 @@ wasmtime::component::bindgen!({
 
 pub type ExtensionWorktree = Arc<dyn WorktreeDelegate>;
 
-pub fn linker() -> &'static Linker<WasmState> {
+pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(Extension::add_to_linker))
+    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
 }
 
 impl From<DownloadedFileType> for latest::DownloadedFileType {

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

@@ -2,6 +2,7 @@ use super::latest;
 use crate::wasm_host::WasmState;
 use anyhow::Result;
 use extension::WorktreeDelegate;
+use gpui::BackgroundExecutor;
 use semantic_version::SemanticVersion;
 use std::sync::{Arc, OnceLock};
 use wasmtime::component::{Linker, Resource};
@@ -21,9 +22,9 @@ wasmtime::component::bindgen!({
 
 pub type ExtensionWorktree = Arc<dyn WorktreeDelegate>;
 
-pub fn linker() -> &'static Linker<WasmState> {
+pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(Extension::add_to_linker))
+    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
 }
 
 impl From<DownloadedFileType> for latest::DownloadedFileType {

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

@@ -2,6 +2,7 @@ use super::{latest, since_v0_1_0};
 use crate::wasm_host::WasmState;
 use anyhow::Result;
 use extension::WorktreeDelegate;
+use gpui::BackgroundExecutor;
 use semantic_version::SemanticVersion;
 use std::sync::{Arc, OnceLock};
 use wasmtime::component::{Linker, Resource};
@@ -27,9 +28,9 @@ mod settings {
 
 pub type ExtensionWorktree = Arc<dyn WorktreeDelegate>;
 
-pub fn linker() -> &'static Linker<WasmState> {
+pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(Extension::add_to_linker))
+    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
 }
 
 impl From<Command> for latest::Command {

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

@@ -1,12 +1,13 @@
 use crate::wasm_host::{WasmState, wit::ToWasmtimeResult};
 use ::http_client::{AsyncBody, HttpRequestExt};
 use ::settings::{Settings, WorktreeId};
-use anyhow::{Context, Result, anyhow, bail};
+use anyhow::{Context as _, Result, bail};
 use async_compression::futures::bufread::GzipDecoder;
 use async_tar::Archive;
 use extension::{ExtensionLanguageServerProxy, KeyValueStoreDelegate, WorktreeDelegate};
 use futures::{AsyncReadExt, lock::Mutex};
 use futures::{FutureExt as _, io::BufReader};
+use gpui::BackgroundExecutor;
 use language::LanguageName;
 use language::{BinaryStatus, language_settings::AllLanguageSettings};
 use project::project_settings::ProjectSettings;
@@ -15,7 +16,7 @@ use std::{
     path::{Path, PathBuf},
     sync::{Arc, OnceLock},
 };
-use util::maybe;
+use util::{archive::extract_zip, fs::make_file_executable, maybe};
 use wasmtime::component::{Linker, Resource};
 
 use super::latest;
@@ -47,9 +48,9 @@ pub type ExtensionWorktree = Arc<dyn WorktreeDelegate>;
 pub type ExtensionKeyValueStore = Arc<dyn KeyValueStoreDelegate>;
 pub type ExtensionHttpResponseStream = Arc<Mutex<::http_client::Response<AsyncBody>>>;
 
-pub fn linker() -> &'static Linker<WasmState> {
+pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(Extension::add_to_linker))
+    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
 }
 
 impl From<Command> for latest::Command {
@@ -365,7 +366,7 @@ impl From<http_client::HttpMethod> for ::http_client::Method {
 
 fn convert_request(
     extension_request: &http_client::HttpRequest,
-) -> Result<::http_client::Request<AsyncBody>, anyhow::Error> {
+) -> anyhow::Result<::http_client::Request<AsyncBody>> {
     let mut request = ::http_client::Request::builder()
         .method(::http_client::Method::from(extension_request.method))
         .uri(&extension_request.url)
@@ -389,7 +390,7 @@ fn convert_request(
 
 async fn convert_response(
     response: &mut ::http_client::Response<AsyncBody>,
-) -> Result<http_client::HttpResponse, anyhow::Error> {
+) -> anyhow::Result<http_client::HttpResponse> {
     let mut extension_response = http_client::HttpResponse {
         body: Vec::new(),
         headers: Vec::new(),
@@ -508,14 +509,13 @@ impl ExtensionImports for WasmState {
                 .http_client
                 .get(&url, Default::default(), true)
                 .await
-                .map_err(|err| anyhow!("error downloading release: {}", err))?;
+                .context("downloading release")?;
 
-            if !response.status().is_success() {
-                Err(anyhow!(
-                    "download failed with status {}",
-                    response.status().to_string()
-                ))?;
-            }
+            anyhow::ensure!(
+                response.status().is_success(),
+                "download failed with status {}",
+                response.status().to_string()
+            );
             let body = BufReader::new(response.body_mut());
 
             match file_type {
@@ -544,9 +544,9 @@ impl ExtensionImports for WasmState {
                 }
                 DownloadedFileType::Zip => {
                     futures::pin_mut!(body);
-                    node_runtime::extract_zip(&destination_path, body)
+                    extract_zip(&destination_path, body)
                         .await
-                        .with_context(|| format!("failed to unzip {} archive", path.display()))?;
+                        .with_context(|| format!("unzipping {path:?} archive"))?;
                 }
             }
 
@@ -557,22 +557,13 @@ impl ExtensionImports for WasmState {
     }
 
     async fn make_file_executable(&mut self, path: String) -> wasmtime::Result<Result<(), String>> {
-        #[allow(unused)]
         let path = self
             .host
             .writeable_path_from_extension(&self.manifest.id, Path::new(&path))?;
 
-        #[cfg(unix)]
-        {
-            use std::fs::{self, Permissions};
-            use std::os::unix::fs::PermissionsExt;
-
-            return fs::set_permissions(&path, Permissions::from_mode(0o755))
-                .map_err(|error| anyhow!("failed to set permissions for path {path:?}: {error}"))
-                .to_wasmtime_result();
-        }
-
-        #[cfg(not(unix))]
-        Ok(Ok(()))
+        make_file_executable(&path)
+            .await
+            .with_context(|| format!("setting permissions for path {path:?}"))
+            .to_wasmtime_result()
     }
 }

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

@@ -1,6 +1,7 @@
 use crate::wasm_host::WasmState;
 use anyhow::Result;
 use extension::{KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate};
+use gpui::BackgroundExecutor;
 use semantic_version::SemanticVersion;
 use std::sync::{Arc, OnceLock};
 use wasmtime::component::{Linker, Resource};
@@ -36,9 +37,9 @@ pub type ExtensionWorktree = Arc<dyn WorktreeDelegate>;
 pub type ExtensionProject = Arc<dyn ProjectDelegate>;
 pub type ExtensionKeyValueStore = Arc<dyn KeyValueStoreDelegate>;
 
-pub fn linker() -> &'static Linker<WasmState> {
+pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(Extension::add_to_linker))
+    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
 }
 
 impl From<Command> for latest::Command {

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

@@ -1,6 +1,7 @@
 use crate::wasm_host::WasmState;
 use anyhow::Result;
 use extension::{KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate};
+use gpui::BackgroundExecutor;
 use semantic_version::SemanticVersion;
 use std::sync::{Arc, OnceLock};
 use wasmtime::component::{Linker, Resource};
@@ -36,9 +37,9 @@ pub type ExtensionWorktree = Arc<dyn WorktreeDelegate>;
 pub type ExtensionProject = Arc<dyn ProjectDelegate>;
 pub type ExtensionKeyValueStore = Arc<dyn KeyValueStoreDelegate>;
 
-pub fn linker() -> &'static Linker<WasmState> {
+pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(Extension::add_to_linker))
+    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
 }
 
 impl From<CodeLabel> for latest::CodeLabel {

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

@@ -1,6 +1,7 @@
 use crate::wasm_host::WasmState;
 use anyhow::Result;
 use extension::{KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate};
+use gpui::BackgroundExecutor;
 use semantic_version::SemanticVersion;
 use std::sync::{Arc, OnceLock};
 use wasmtime::component::{Linker, Resource};
@@ -36,9 +37,9 @@ pub type ExtensionWorktree = Arc<dyn WorktreeDelegate>;
 pub type ExtensionProject = Arc<dyn ProjectDelegate>;
 pub type ExtensionKeyValueStore = Arc<dyn KeyValueStoreDelegate>;
 
-pub fn linker() -> &'static Linker<WasmState> {
+pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(Extension::add_to_linker))
+    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
 }
 
 impl From<CodeLabel> for latest::CodeLabel {

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

@@ -1,45 +1,35 @@
-use crate::wasm_host::wit::since_v0_5_0::slash_command::SlashCommandOutputSection;
-use crate::wasm_host::wit::{CompletionKind, CompletionLabelDetails, InsertTextFormat, SymbolKind};
-use crate::wasm_host::{WasmState, wit::ToWasmtimeResult};
-use ::http_client::{AsyncBody, HttpRequestExt};
-use ::settings::{Settings, WorktreeId};
-use anyhow::{Context, Result, anyhow, bail};
-use async_compression::futures::bufread::GzipDecoder;
-use async_tar::Archive;
-use async_trait::async_trait;
-use extension::{
-    ExtensionLanguageServerProxy, KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate,
-};
-use futures::{AsyncReadExt, lock::Mutex};
-use futures::{FutureExt as _, io::BufReader};
-use language::{BinaryStatus, LanguageName, language_settings::AllLanguageSettings};
-use project::project_settings::ProjectSettings;
+use crate::wasm_host::WasmState;
+use anyhow::Result;
+use extension::{KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate};
+use gpui::BackgroundExecutor;
 use semantic_version::SemanticVersion;
-use std::{
-    env,
-    path::{Path, PathBuf},
-    sync::{Arc, OnceLock},
-};
-use util::maybe;
+use std::sync::{Arc, OnceLock};
 use wasmtime::component::{Linker, Resource};
 
+use super::latest;
+
 pub const MIN_VERSION: SemanticVersion = SemanticVersion::new(0, 5, 0);
-pub const MAX_VERSION: SemanticVersion = SemanticVersion::new(0, 5, 0);
 
 wasmtime::component::bindgen!({
     async: true,
     trappable_imports: true,
     path: "../extension_api/wit/since_v0.5.0",
     with: {
-         "worktree": ExtensionWorktree,
-         "project": ExtensionProject,
-         "key-value-store": ExtensionKeyValueStore,
-         "zed:extension/http-client/http-response-stream": ExtensionHttpResponseStream
+        "worktree": ExtensionWorktree,
+        "project": ExtensionProject,
+        "key-value-store": ExtensionKeyValueStore,
+        "zed:extension/common": latest::zed::extension::common,
+        "zed:extension/github": latest::zed::extension::github,
+        "zed:extension/http-client": latest::zed::extension::http_client,
+        "zed:extension/lsp": latest::zed::extension::lsp,
+        "zed:extension/nodejs": latest::zed::extension::nodejs,
+        "zed:extension/platform": latest::zed::extension::platform,
+        "zed:extension/process": latest::zed::extension::process,
+        "zed:extension/slash-command": latest::zed::extension::slash_command,
+        "zed:extension/context-server": latest::zed::extension::context_server,
     },
 });
 
-pub use self::zed::extension::*;
-
 mod settings {
     include!(concat!(env!("OUT_DIR"), "/since_v0.5.0/settings.rs"));
 }
@@ -47,51 +37,32 @@ mod settings {
 pub type ExtensionWorktree = Arc<dyn WorktreeDelegate>;
 pub type ExtensionProject = Arc<dyn ProjectDelegate>;
 pub type ExtensionKeyValueStore = Arc<dyn KeyValueStoreDelegate>;
-pub type ExtensionHttpResponseStream = Arc<Mutex<::http_client::Response<AsyncBody>>>;
 
-pub fn linker() -> &'static Linker<WasmState> {
+pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(Extension::add_to_linker))
-}
-
-impl From<Range> for std::ops::Range<usize> {
-    fn from(range: Range) -> Self {
-        let start = range.start as usize;
-        let end = range.end as usize;
-        start..end
-    }
-}
-
-impl From<Command> for extension::Command {
-    fn from(value: Command) -> Self {
-        Self {
-            command: value.command,
-            args: value.args,
-            env: value.env,
-        }
-    }
+    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
 }
 
-impl From<CodeLabel> for extension::CodeLabel {
+impl From<CodeLabel> for latest::CodeLabel {
     fn from(value: CodeLabel) -> Self {
         Self {
             code: value.code,
             spans: value.spans.into_iter().map(Into::into).collect(),
-            filter_range: value.filter_range.into(),
+            filter_range: value.filter_range,
         }
     }
 }
 
-impl From<CodeLabelSpan> for extension::CodeLabelSpan {
+impl From<CodeLabelSpan> for latest::CodeLabelSpan {
     fn from(value: CodeLabelSpan) -> Self {
         match value {
-            CodeLabelSpan::CodeRange(range) => Self::CodeRange(range.into()),
+            CodeLabelSpan::CodeRange(range) => Self::CodeRange(range),
             CodeLabelSpan::Literal(literal) => Self::Literal(literal.into()),
         }
     }
 }
 
-impl From<CodeLabelSpanLiteral> for extension::CodeLabelSpanLiteral {
+impl From<CodeLabelSpanLiteral> for latest::CodeLabelSpanLiteral {
     fn from(value: CodeLabelSpanLiteral) -> Self {
         Self {
             text: value.text,
@@ -100,167 +71,37 @@ impl From<CodeLabelSpanLiteral> for extension::CodeLabelSpanLiteral {
     }
 }
 
-impl From<extension::Completion> for Completion {
-    fn from(value: extension::Completion) -> Self {
+impl From<SettingsLocation> for latest::SettingsLocation {
+    fn from(value: SettingsLocation) -> Self {
         Self {
-            label: value.label,
-            label_details: value.label_details.map(Into::into),
-            detail: value.detail,
-            kind: value.kind.map(Into::into),
-            insert_text_format: value.insert_text_format.map(Into::into),
-        }
-    }
-}
-
-impl From<extension::CompletionLabelDetails> for CompletionLabelDetails {
-    fn from(value: extension::CompletionLabelDetails) -> Self {
-        Self {
-            detail: value.detail,
-            description: value.description,
-        }
-    }
-}
-
-impl From<extension::CompletionKind> for CompletionKind {
-    fn from(value: extension::CompletionKind) -> Self {
-        match value {
-            extension::CompletionKind::Text => Self::Text,
-            extension::CompletionKind::Method => Self::Method,
-            extension::CompletionKind::Function => Self::Function,
-            extension::CompletionKind::Constructor => Self::Constructor,
-            extension::CompletionKind::Field => Self::Field,
-            extension::CompletionKind::Variable => Self::Variable,
-            extension::CompletionKind::Class => Self::Class,
-            extension::CompletionKind::Interface => Self::Interface,
-            extension::CompletionKind::Module => Self::Module,
-            extension::CompletionKind::Property => Self::Property,
-            extension::CompletionKind::Unit => Self::Unit,
-            extension::CompletionKind::Value => Self::Value,
-            extension::CompletionKind::Enum => Self::Enum,
-            extension::CompletionKind::Keyword => Self::Keyword,
-            extension::CompletionKind::Snippet => Self::Snippet,
-            extension::CompletionKind::Color => Self::Color,
-            extension::CompletionKind::File => Self::File,
-            extension::CompletionKind::Reference => Self::Reference,
-            extension::CompletionKind::Folder => Self::Folder,
-            extension::CompletionKind::EnumMember => Self::EnumMember,
-            extension::CompletionKind::Constant => Self::Constant,
-            extension::CompletionKind::Struct => Self::Struct,
-            extension::CompletionKind::Event => Self::Event,
-            extension::CompletionKind::Operator => Self::Operator,
-            extension::CompletionKind::TypeParameter => Self::TypeParameter,
-            extension::CompletionKind::Other(value) => Self::Other(value),
+            worktree_id: value.worktree_id,
+            path: value.path,
         }
     }
 }
 
-impl From<extension::InsertTextFormat> for InsertTextFormat {
-    fn from(value: extension::InsertTextFormat) -> Self {
+impl From<LanguageServerInstallationStatus> for latest::LanguageServerInstallationStatus {
+    fn from(value: LanguageServerInstallationStatus) -> Self {
         match value {
-            extension::InsertTextFormat::PlainText => Self::PlainText,
-            extension::InsertTextFormat::Snippet => Self::Snippet,
-            extension::InsertTextFormat::Other(value) => Self::Other(value),
-        }
-    }
-}
-
-impl From<extension::Symbol> for Symbol {
-    fn from(value: extension::Symbol) -> Self {
-        Self {
-            kind: value.kind.into(),
-            name: value.name,
+            LanguageServerInstallationStatus::None => Self::None,
+            LanguageServerInstallationStatus::Downloading => Self::Downloading,
+            LanguageServerInstallationStatus::CheckingForUpdate => Self::CheckingForUpdate,
+            LanguageServerInstallationStatus::Failed(message) => Self::Failed(message),
         }
     }
 }
 
-impl From<extension::SymbolKind> for SymbolKind {
-    fn from(value: extension::SymbolKind) -> Self {
+impl From<DownloadedFileType> for latest::DownloadedFileType {
+    fn from(value: DownloadedFileType) -> Self {
         match value {
-            extension::SymbolKind::File => Self::File,
-            extension::SymbolKind::Module => Self::Module,
-            extension::SymbolKind::Namespace => Self::Namespace,
-            extension::SymbolKind::Package => Self::Package,
-            extension::SymbolKind::Class => Self::Class,
-            extension::SymbolKind::Method => Self::Method,
-            extension::SymbolKind::Property => Self::Property,
-            extension::SymbolKind::Field => Self::Field,
-            extension::SymbolKind::Constructor => Self::Constructor,
-            extension::SymbolKind::Enum => Self::Enum,
-            extension::SymbolKind::Interface => Self::Interface,
-            extension::SymbolKind::Function => Self::Function,
-            extension::SymbolKind::Variable => Self::Variable,
-            extension::SymbolKind::Constant => Self::Constant,
-            extension::SymbolKind::String => Self::String,
-            extension::SymbolKind::Number => Self::Number,
-            extension::SymbolKind::Boolean => Self::Boolean,
-            extension::SymbolKind::Array => Self::Array,
-            extension::SymbolKind::Object => Self::Object,
-            extension::SymbolKind::Key => Self::Key,
-            extension::SymbolKind::Null => Self::Null,
-            extension::SymbolKind::EnumMember => Self::EnumMember,
-            extension::SymbolKind::Struct => Self::Struct,
-            extension::SymbolKind::Event => Self::Event,
-            extension::SymbolKind::Operator => Self::Operator,
-            extension::SymbolKind::TypeParameter => Self::TypeParameter,
-            extension::SymbolKind::Other(value) => Self::Other(value),
-        }
-    }
-}
-
-impl From<extension::SlashCommand> for SlashCommand {
-    fn from(value: extension::SlashCommand) -> Self {
-        Self {
-            name: value.name,
-            description: value.description,
-            tooltip_text: value.tooltip_text,
-            requires_argument: value.requires_argument,
-        }
-    }
-}
-
-impl From<SlashCommandOutput> for extension::SlashCommandOutput {
-    fn from(value: SlashCommandOutput) -> Self {
-        Self {
-            text: value.text,
-            sections: value.sections.into_iter().map(Into::into).collect(),
-        }
-    }
-}
-
-impl From<SlashCommandOutputSection> for extension::SlashCommandOutputSection {
-    fn from(value: SlashCommandOutputSection) -> Self {
-        Self {
-            range: value.range.start as usize..value.range.end as usize,
-            label: value.label,
-        }
-    }
-}
-
-impl From<SlashCommandArgumentCompletion> for extension::SlashCommandArgumentCompletion {
-    fn from(value: SlashCommandArgumentCompletion) -> Self {
-        Self {
-            label: value.label,
-            new_text: value.new_text,
-            run_command: value.run_command,
+            DownloadedFileType::Gzip => Self::Gzip,
+            DownloadedFileType::GzipTar => Self::GzipTar,
+            DownloadedFileType::Zip => Self::Zip,
+            DownloadedFileType::Uncompressed => Self::Uncompressed,
         }
     }
 }
 
-impl TryFrom<ContextServerConfiguration> for extension::ContextServerConfiguration {
-    type Error = anyhow::Error;
-
-    fn try_from(value: ContextServerConfiguration) -> Result<Self, Self::Error> {
-        let settings_schema: serde_json::Value = serde_json::from_str(&value.settings_schema)
-            .context("Failed to parse settings_schema")?;
-
-        Ok(Self {
-            installation_instructions: value.installation_instructions,
-            default_settings: value.default_settings,
-            settings_schema,
-        })
-    }
-}
-
 impl HostKeyValueStore for WasmState {
     async fn insert(
         &mut self,
@@ -268,8 +109,7 @@ impl HostKeyValueStore for WasmState {
         key: String,
         value: String,
     ) -> wasmtime::Result<Result<(), String>> {
-        let kv_store = self.table.get(&kv_store)?;
-        kv_store.insert(key, value).await.to_wasmtime_result()
+        latest::HostKeyValueStore::insert(self, kv_store, key, value).await
     }
 
     async fn drop(&mut self, _worktree: Resource<ExtensionKeyValueStore>) -> Result<()> {
@@ -283,8 +123,7 @@ impl HostProject for WasmState {
         &mut self,
         project: Resource<ExtensionProject>,
     ) -> wasmtime::Result<Vec<u64>> {
-        let project = self.table.get(&project)?;
-        Ok(project.worktree_ids())
+        latest::HostProject::worktree_ids(self, project).await
     }
 
     async fn drop(&mut self, _project: Resource<Project>) -> Result<()> {
@@ -295,16 +134,14 @@ impl HostProject for WasmState {
 
 impl HostWorktree for WasmState {
     async fn id(&mut self, delegate: Resource<Arc<dyn WorktreeDelegate>>) -> wasmtime::Result<u64> {
-        let delegate = self.table.get(&delegate)?;
-        Ok(delegate.id())
+        latest::HostWorktree::id(self, delegate).await
     }
 
     async fn root_path(
         &mut self,
         delegate: Resource<Arc<dyn WorktreeDelegate>>,
     ) -> wasmtime::Result<String> {
-        let delegate = self.table.get(&delegate)?;
-        Ok(delegate.root_path())
+        latest::HostWorktree::root_path(self, delegate).await
     }
 
     async fn read_text_file(
@@ -312,19 +149,14 @@ impl HostWorktree for WasmState {
         delegate: Resource<Arc<dyn WorktreeDelegate>>,
         path: String,
     ) -> wasmtime::Result<Result<String, String>> {
-        let delegate = self.table.get(&delegate)?;
-        Ok(delegate
-            .read_text_file(path.into())
-            .await
-            .map_err(|error| error.to_string()))
+        latest::HostWorktree::read_text_file(self, delegate, path).await
     }
 
     async fn shell_env(
         &mut self,
         delegate: Resource<Arc<dyn WorktreeDelegate>>,
     ) -> wasmtime::Result<EnvVars> {
-        let delegate = self.table.get(&delegate)?;
-        Ok(delegate.shell_env().await.into_iter().collect())
+        latest::HostWorktree::shell_env(self, delegate).await
     }
 
     async fn which(
@@ -332,8 +164,7 @@ impl HostWorktree for WasmState {
         delegate: Resource<Arc<dyn WorktreeDelegate>>,
         binary_name: String,
     ) -> wasmtime::Result<Option<String>> {
-        let delegate = self.table.get(&delegate)?;
-        Ok(delegate.which(binary_name).await)
+        latest::HostWorktree::which(self, delegate, binary_name).await
     }
 
     async fn drop(&mut self, _worktree: Resource<Worktree>) -> Result<()> {
@@ -342,291 +173,6 @@ impl HostWorktree for WasmState {
     }
 }
 
-impl common::Host for WasmState {}
-
-impl http_client::Host for WasmState {
-    async fn fetch(
-        &mut self,
-        request: http_client::HttpRequest,
-    ) -> wasmtime::Result<Result<http_client::HttpResponse, String>> {
-        maybe!(async {
-            let url = &request.url;
-            let request = convert_request(&request)?;
-            let mut response = self.host.http_client.send(request).await?;
-
-            if response.status().is_client_error() || response.status().is_server_error() {
-                bail!("failed to fetch '{url}': status code {}", response.status())
-            }
-            convert_response(&mut response).await
-        })
-        .await
-        .to_wasmtime_result()
-    }
-
-    async fn fetch_stream(
-        &mut self,
-        request: http_client::HttpRequest,
-    ) -> wasmtime::Result<Result<Resource<ExtensionHttpResponseStream>, String>> {
-        let request = convert_request(&request)?;
-        let response = self.host.http_client.send(request);
-        maybe!(async {
-            let response = response.await?;
-            let stream = Arc::new(Mutex::new(response));
-            let resource = self.table.push(stream)?;
-            Ok(resource)
-        })
-        .await
-        .to_wasmtime_result()
-    }
-}
-
-impl http_client::HostHttpResponseStream for WasmState {
-    async fn next_chunk(
-        &mut self,
-        resource: Resource<ExtensionHttpResponseStream>,
-    ) -> wasmtime::Result<Result<Option<Vec<u8>>, String>> {
-        let stream = self.table.get(&resource)?.clone();
-        maybe!(async move {
-            let mut response = stream.lock().await;
-            let mut buffer = vec![0; 8192]; // 8KB buffer
-            let bytes_read = response.body_mut().read(&mut buffer).await?;
-            if bytes_read == 0 {
-                Ok(None)
-            } else {
-                buffer.truncate(bytes_read);
-                Ok(Some(buffer))
-            }
-        })
-        .await
-        .to_wasmtime_result()
-    }
-
-    async fn drop(&mut self, _resource: Resource<ExtensionHttpResponseStream>) -> Result<()> {
-        Ok(())
-    }
-}
-
-impl From<http_client::HttpMethod> for ::http_client::Method {
-    fn from(value: http_client::HttpMethod) -> Self {
-        match value {
-            http_client::HttpMethod::Get => Self::GET,
-            http_client::HttpMethod::Post => Self::POST,
-            http_client::HttpMethod::Put => Self::PUT,
-            http_client::HttpMethod::Delete => Self::DELETE,
-            http_client::HttpMethod::Head => Self::HEAD,
-            http_client::HttpMethod::Options => Self::OPTIONS,
-            http_client::HttpMethod::Patch => Self::PATCH,
-        }
-    }
-}
-
-fn convert_request(
-    extension_request: &http_client::HttpRequest,
-) -> Result<::http_client::Request<AsyncBody>, anyhow::Error> {
-    let mut request = ::http_client::Request::builder()
-        .method(::http_client::Method::from(extension_request.method))
-        .uri(&extension_request.url)
-        .follow_redirects(match extension_request.redirect_policy {
-            http_client::RedirectPolicy::NoFollow => ::http_client::RedirectPolicy::NoFollow,
-            http_client::RedirectPolicy::FollowLimit(limit) => {
-                ::http_client::RedirectPolicy::FollowLimit(limit)
-            }
-            http_client::RedirectPolicy::FollowAll => ::http_client::RedirectPolicy::FollowAll,
-        });
-    for (key, value) in &extension_request.headers {
-        request = request.header(key, value);
-    }
-    let body = extension_request
-        .body
-        .clone()
-        .map(AsyncBody::from)
-        .unwrap_or_default();
-    request.body(body).map_err(anyhow::Error::from)
-}
-
-async fn convert_response(
-    response: &mut ::http_client::Response<AsyncBody>,
-) -> Result<http_client::HttpResponse, anyhow::Error> {
-    let mut extension_response = http_client::HttpResponse {
-        body: Vec::new(),
-        headers: Vec::new(),
-    };
-
-    for (key, value) in response.headers() {
-        extension_response
-            .headers
-            .push((key.to_string(), value.to_str().unwrap_or("").to_string()));
-    }
-
-    response
-        .body_mut()
-        .read_to_end(&mut extension_response.body)
-        .await?;
-
-    Ok(extension_response)
-}
-
-impl nodejs::Host for WasmState {
-    async fn node_binary_path(&mut self) -> wasmtime::Result<Result<String, String>> {
-        self.host
-            .node_runtime
-            .binary_path()
-            .await
-            .map(|path| path.to_string_lossy().to_string())
-            .to_wasmtime_result()
-    }
-
-    async fn npm_package_latest_version(
-        &mut self,
-        package_name: String,
-    ) -> wasmtime::Result<Result<String, String>> {
-        self.host
-            .node_runtime
-            .npm_package_latest_version(&package_name)
-            .await
-            .to_wasmtime_result()
-    }
-
-    async fn npm_package_installed_version(
-        &mut self,
-        package_name: String,
-    ) -> wasmtime::Result<Result<Option<String>, String>> {
-        self.host
-            .node_runtime
-            .npm_package_installed_version(&self.work_dir(), &package_name)
-            .await
-            .to_wasmtime_result()
-    }
-
-    async fn npm_install_package(
-        &mut self,
-        package_name: String,
-        version: String,
-    ) -> wasmtime::Result<Result<(), String>> {
-        self.host
-            .node_runtime
-            .npm_install_packages(&self.work_dir(), &[(&package_name, &version)])
-            .await
-            .to_wasmtime_result()
-    }
-}
-
-#[async_trait]
-impl lsp::Host for WasmState {}
-
-impl From<::http_client::github::GithubRelease> for github::GithubRelease {
-    fn from(value: ::http_client::github::GithubRelease) -> Self {
-        Self {
-            version: value.tag_name,
-            assets: value.assets.into_iter().map(Into::into).collect(),
-        }
-    }
-}
-
-impl From<::http_client::github::GithubReleaseAsset> for github::GithubReleaseAsset {
-    fn from(value: ::http_client::github::GithubReleaseAsset) -> Self {
-        Self {
-            name: value.name,
-            download_url: value.browser_download_url,
-        }
-    }
-}
-
-impl github::Host for WasmState {
-    async fn latest_github_release(
-        &mut self,
-        repo: String,
-        options: github::GithubReleaseOptions,
-    ) -> wasmtime::Result<Result<github::GithubRelease, String>> {
-        maybe!(async {
-            let release = ::http_client::github::latest_github_release(
-                &repo,
-                options.require_assets,
-                options.pre_release,
-                self.host.http_client.clone(),
-            )
-            .await?;
-            Ok(release.into())
-        })
-        .await
-        .to_wasmtime_result()
-    }
-
-    async fn github_release_by_tag_name(
-        &mut self,
-        repo: String,
-        tag: String,
-    ) -> wasmtime::Result<Result<github::GithubRelease, String>> {
-        maybe!(async {
-            let release = ::http_client::github::get_release_by_tag_name(
-                &repo,
-                &tag,
-                self.host.http_client.clone(),
-            )
-            .await?;
-            Ok(release.into())
-        })
-        .await
-        .to_wasmtime_result()
-    }
-}
-
-impl platform::Host for WasmState {
-    async fn current_platform(&mut self) -> Result<(platform::Os, platform::Architecture)> {
-        Ok((
-            match env::consts::OS {
-                "macos" => platform::Os::Mac,
-                "linux" => platform::Os::Linux,
-                "windows" => platform::Os::Windows,
-                _ => panic!("unsupported os"),
-            },
-            match env::consts::ARCH {
-                "aarch64" => platform::Architecture::Aarch64,
-                "x86" => platform::Architecture::X86,
-                "x86_64" => platform::Architecture::X8664,
-                _ => panic!("unsupported architecture"),
-            },
-        ))
-    }
-}
-
-impl From<std::process::Output> for process::Output {
-    fn from(output: std::process::Output) -> Self {
-        Self {
-            status: output.status.code(),
-            stdout: output.stdout,
-            stderr: output.stderr,
-        }
-    }
-}
-
-impl process::Host for WasmState {
-    async fn run_command(
-        &mut self,
-        command: process::Command,
-    ) -> wasmtime::Result<Result<process::Output, String>> {
-        maybe!(async {
-            self.manifest.allow_exec(&command.command, &command.args)?;
-
-            let output = util::command::new_smol_command(command.command.as_str())
-                .args(&command.args)
-                .envs(command.env)
-                .output()
-                .await?;
-
-            Ok(output.into())
-        })
-        .await
-        .to_wasmtime_result()
-    }
-}
-
-#[async_trait]
-impl slash_command::Host for WasmState {}
-
-#[async_trait]
-impl context_server::Host for WasmState {}
-
 impl ExtensionImports for WasmState {
     async fn get_settings(
         &mut self,
@@ -634,75 +180,13 @@ impl ExtensionImports for WasmState {
         category: String,
         key: Option<String>,
     ) -> wasmtime::Result<Result<String, String>> {
-        self.on_main_thread(|cx| {
-            async move {
-                let location = location
-                    .as_ref()
-                    .map(|location| ::settings::SettingsLocation {
-                        worktree_id: WorktreeId::from_proto(location.worktree_id),
-                        path: Path::new(&location.path),
-                    });
-
-                cx.update(|cx| match category.as_str() {
-                    "language" => {
-                        let key = key.map(|k| LanguageName::new(&k));
-                        let settings = AllLanguageSettings::get(location, cx).language(
-                            location,
-                            key.as_ref(),
-                            cx,
-                        );
-                        Ok(serde_json::to_string(&settings::LanguageSettings {
-                            tab_size: settings.tab_size,
-                        })?)
-                    }
-                    "lsp" => {
-                        let settings = key
-                            .and_then(|key| {
-                                ProjectSettings::get(location, cx)
-                                    .lsp
-                                    .get(&::lsp::LanguageServerName::from_proto(key))
-                            })
-                            .cloned()
-                            .unwrap_or_default();
-                        Ok(serde_json::to_string(&settings::LspSettings {
-                            binary: settings.binary.map(|binary| settings::CommandSettings {
-                                path: binary.path,
-                                arguments: binary.arguments,
-                                env: binary.env,
-                            }),
-                            settings: settings.settings,
-                            initialization_options: settings.initialization_options,
-                        })?)
-                    }
-                    "context_servers" => {
-                        let configuration = key
-                            .and_then(|key| {
-                                ProjectSettings::get(location, cx)
-                                    .context_servers
-                                    .get(key.as_str())
-                            })
-                            .cloned()
-                            .unwrap_or_default();
-                        Ok(serde_json::to_string(&settings::ContextServerSettings {
-                            command: configuration.command.map(|command| {
-                                settings::CommandSettings {
-                                    path: Some(command.path),
-                                    arguments: Some(command.args),
-                                    env: command.env.map(|env| env.into_iter().collect()),
-                                }
-                            }),
-                            settings: configuration.settings,
-                        })?)
-                    }
-                    _ => {
-                        bail!("Unknown settings category: {}", category);
-                    }
-                })
-            }
-            .boxed_local()
-        })
-        .await?
-        .to_wasmtime_result()
+        latest::ExtensionImports::get_settings(
+            self,
+            location.map(|location| location.into()),
+            category,
+            key,
+        )
+        .await
     }
 
     async fn set_language_server_installation_status(
@@ -710,18 +194,12 @@ impl ExtensionImports for WasmState {
         server_name: String,
         status: LanguageServerInstallationStatus,
     ) -> wasmtime::Result<()> {
-        let status = match status {
-            LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate,
-            LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading,
-            LanguageServerInstallationStatus::None => BinaryStatus::None,
-            LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error },
-        };
-
-        self.host
-            .proxy
-            .update_language_server_status(::lsp::LanguageServerName(server_name.into()), status);
-
-        Ok(())
+        latest::ExtensionImports::set_language_server_installation_status(
+            self,
+            server_name,
+            status.into(),
+        )
+        .await
     }
 
     async fn download_file(
@@ -730,86 +208,10 @@ impl ExtensionImports for WasmState {
         path: String,
         file_type: DownloadedFileType,
     ) -> wasmtime::Result<Result<(), String>> {
-        maybe!(async {
-            let path = PathBuf::from(path);
-            let extension_work_dir = self.host.work_dir.join(self.manifest.id.as_ref());
-
-            self.host.fs.create_dir(&extension_work_dir).await?;
-
-            let destination_path = self
-                .host
-                .writeable_path_from_extension(&self.manifest.id, &path)?;
-
-            let mut response = self
-                .host
-                .http_client
-                .get(&url, Default::default(), true)
-                .await
-                .map_err(|err| anyhow!("error downloading release: {}", err))?;
-
-            if !response.status().is_success() {
-                Err(anyhow!(
-                    "download failed with status {}",
-                    response.status().to_string()
-                ))?;
-            }
-            let body = BufReader::new(response.body_mut());
-
-            match file_type {
-                DownloadedFileType::Uncompressed => {
-                    futures::pin_mut!(body);
-                    self.host
-                        .fs
-                        .create_file_with(&destination_path, body)
-                        .await?;
-                }
-                DownloadedFileType::Gzip => {
-                    let body = GzipDecoder::new(body);
-                    futures::pin_mut!(body);
-                    self.host
-                        .fs
-                        .create_file_with(&destination_path, body)
-                        .await?;
-                }
-                DownloadedFileType::GzipTar => {
-                    let body = GzipDecoder::new(body);
-                    futures::pin_mut!(body);
-                    self.host
-                        .fs
-                        .extract_tar_file(&destination_path, Archive::new(body))
-                        .await?;
-                }
-                DownloadedFileType::Zip => {
-                    futures::pin_mut!(body);
-                    node_runtime::extract_zip(&destination_path, body)
-                        .await
-                        .with_context(|| format!("failed to unzip {} archive", path.display()))?;
-                }
-            }
-
-            Ok(())
-        })
-        .await
-        .to_wasmtime_result()
+        latest::ExtensionImports::download_file(self, url, path, file_type.into()).await
     }
 
     async fn make_file_executable(&mut self, path: String) -> wasmtime::Result<Result<(), String>> {
-        #[allow(unused)]
-        let path = self
-            .host
-            .writeable_path_from_extension(&self.manifest.id, Path::new(&path))?;
-
-        #[cfg(unix)]
-        {
-            use std::fs::{self, Permissions};
-            use std::os::unix::fs::PermissionsExt;
-
-            return fs::set_permissions(&path, Permissions::from_mode(0o755))
-                .map_err(|error| anyhow!("failed to set permissions for path {path:?}: {error}"))
-                .to_wasmtime_result();
-        }
-
-        #[cfg(not(unix))]
-        Ok(Ok(()))
+        latest::ExtensionImports::make_file_executable(self, path).await
     }
 }

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

@@ -0,0 +1,1082 @@
+use crate::wasm_host::wit::since_v0_6_0::{
+    dap::{
+        AttachRequest, BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, LaunchRequest,
+        StartDebuggingRequestArguments, TcpArguments, TcpArgumentsTemplate,
+    },
+    slash_command::SlashCommandOutputSection,
+};
+use crate::wasm_host::wit::{CompletionKind, CompletionLabelDetails, InsertTextFormat, SymbolKind};
+use crate::wasm_host::{WasmState, wit::ToWasmtimeResult};
+use ::http_client::{AsyncBody, HttpRequestExt};
+use ::settings::{Settings, WorktreeId};
+use anyhow::{Context as _, Result, bail};
+use async_compression::futures::bufread::GzipDecoder;
+use async_tar::Archive;
+use async_trait::async_trait;
+use extension::{
+    ExtensionLanguageServerProxy, KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate,
+};
+use futures::{AsyncReadExt, lock::Mutex};
+use futures::{FutureExt as _, io::BufReader};
+use gpui::{BackgroundExecutor, SharedString};
+use language::{BinaryStatus, LanguageName, language_settings::AllLanguageSettings};
+use project::project_settings::ProjectSettings;
+use semantic_version::SemanticVersion;
+use std::{
+    env,
+    net::Ipv4Addr,
+    path::{Path, PathBuf},
+    str::FromStr,
+    sync::{Arc, OnceLock},
+};
+use task::{SpawnInTerminal, ZedDebugConfig};
+use util::{archive::extract_zip, fs::make_file_executable, maybe};
+use wasmtime::component::{Linker, Resource};
+
+pub const MIN_VERSION: SemanticVersion = SemanticVersion::new(0, 6, 0);
+pub const MAX_VERSION: SemanticVersion = SemanticVersion::new(0, 6, 0);
+
+wasmtime::component::bindgen!({
+    async: true,
+    trappable_imports: true,
+    path: "../extension_api/wit/since_v0.6.0",
+    with: {
+         "worktree": ExtensionWorktree,
+         "project": ExtensionProject,
+         "key-value-store": ExtensionKeyValueStore,
+         "zed:extension/http-client/http-response-stream": ExtensionHttpResponseStream
+    },
+});
+
+pub use self::zed::extension::*;
+
+mod settings {
+    include!(concat!(env!("OUT_DIR"), "/since_v0.6.0/settings.rs"));
+}
+
+pub type ExtensionWorktree = Arc<dyn WorktreeDelegate>;
+pub type ExtensionProject = Arc<dyn ProjectDelegate>;
+pub type ExtensionKeyValueStore = Arc<dyn KeyValueStoreDelegate>;
+pub type ExtensionHttpResponseStream = Arc<Mutex<::http_client::Response<AsyncBody>>>;
+
+pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
+    static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
+    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
+}
+
+impl From<Range> for std::ops::Range<usize> {
+    fn from(range: Range) -> Self {
+        let start = range.start as usize;
+        let end = range.end as usize;
+        start..end
+    }
+}
+
+impl From<Command> for extension::Command {
+    fn from(value: Command) -> Self {
+        Self {
+            command: value.command,
+            args: value.args,
+            env: value.env,
+        }
+    }
+}
+
+impl From<StartDebuggingRequestArgumentsRequest>
+    for extension::StartDebuggingRequestArgumentsRequest
+{
+    fn from(value: StartDebuggingRequestArgumentsRequest) -> Self {
+        match value {
+            StartDebuggingRequestArgumentsRequest::Launch => Self::Launch,
+            StartDebuggingRequestArgumentsRequest::Attach => Self::Attach,
+        }
+    }
+}
+impl TryFrom<StartDebuggingRequestArguments> for extension::StartDebuggingRequestArguments {
+    type Error = anyhow::Error;
+
+    fn try_from(value: StartDebuggingRequestArguments) -> Result<Self, Self::Error> {
+        Ok(Self {
+            configuration: serde_json::from_str(&value.configuration)?,
+            request: value.request.into(),
+        })
+    }
+}
+impl From<TcpArguments> for extension::TcpArguments {
+    fn from(value: TcpArguments) -> Self {
+        Self {
+            host: value.host.into(),
+            port: value.port,
+            timeout: value.timeout,
+        }
+    }
+}
+
+impl From<extension::TcpArgumentsTemplate> for TcpArgumentsTemplate {
+    fn from(value: extension::TcpArgumentsTemplate) -> Self {
+        Self {
+            host: value.host.map(Ipv4Addr::to_bits),
+            port: value.port,
+            timeout: value.timeout,
+        }
+    }
+}
+
+impl From<TcpArgumentsTemplate> for extension::TcpArgumentsTemplate {
+    fn from(value: TcpArgumentsTemplate) -> Self {
+        Self {
+            host: value.host.map(Ipv4Addr::from_bits),
+            port: value.port,
+            timeout: value.timeout,
+        }
+    }
+}
+
+impl TryFrom<extension::DebugTaskDefinition> for DebugTaskDefinition {
+    type Error = anyhow::Error;
+    fn try_from(value: extension::DebugTaskDefinition) -> Result<Self, Self::Error> {
+        Ok(Self {
+            label: value.label.to_string(),
+            adapter: value.adapter.to_string(),
+            config: value.config.to_string(),
+            tcp_connection: value.tcp_connection.map(Into::into),
+        })
+    }
+}
+
+impl From<task::DebugRequest> for DebugRequest {
+    fn from(value: task::DebugRequest) -> Self {
+        match value {
+            task::DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()),
+            task::DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()),
+        }
+    }
+}
+
+impl From<DebugRequest> for task::DebugRequest {
+    fn from(value: DebugRequest) -> Self {
+        match value {
+            DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()),
+            DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()),
+        }
+    }
+}
+
+impl From<task::LaunchRequest> for LaunchRequest {
+    fn from(value: task::LaunchRequest) -> Self {
+        Self {
+            program: value.program,
+            cwd: value.cwd.map(|p| p.to_string_lossy().into_owned()),
+            args: value.args,
+            envs: value.env.into_iter().collect(),
+        }
+    }
+}
+
+impl From<task::AttachRequest> for AttachRequest {
+    fn from(value: task::AttachRequest) -> Self {
+        Self {
+            process_id: value.process_id,
+        }
+    }
+}
+
+impl From<LaunchRequest> for task::LaunchRequest {
+    fn from(value: LaunchRequest) -> Self {
+        Self {
+            program: value.program,
+            cwd: value.cwd.map(|p| p.into()),
+            args: value.args,
+            env: value.envs.into_iter().collect(),
+        }
+    }
+}
+impl From<AttachRequest> for task::AttachRequest {
+    fn from(value: AttachRequest) -> Self {
+        Self {
+            process_id: value.process_id,
+        }
+    }
+}
+
+impl From<ZedDebugConfig> for DebugConfig {
+    fn from(value: ZedDebugConfig) -> Self {
+        Self {
+            label: value.label.into(),
+            adapter: value.adapter.into(),
+            request: value.request.into(),
+            stop_on_entry: value.stop_on_entry,
+        }
+    }
+}
+impl TryFrom<DebugAdapterBinary> for extension::DebugAdapterBinary {
+    type Error = anyhow::Error;
+    fn try_from(value: DebugAdapterBinary) -> Result<Self, Self::Error> {
+        Ok(Self {
+            command: value.command,
+            arguments: value.arguments,
+            envs: value.envs.into_iter().collect(),
+            cwd: value.cwd.map(|s| s.into()),
+            connection: value.connection.map(Into::into),
+            request_args: value.request_args.try_into()?,
+        })
+    }
+}
+
+impl From<BuildTaskDefinition> for extension::BuildTaskDefinition {
+    fn from(value: BuildTaskDefinition) -> Self {
+        match value {
+            BuildTaskDefinition::ByName(name) => Self::ByName(name.into()),
+            BuildTaskDefinition::Template(build_task_template) => Self::Template {
+                task_template: build_task_template.template.into(),
+                locator_name: build_task_template.locator_name.map(SharedString::from),
+            },
+        }
+    }
+}
+
+impl From<extension::BuildTaskDefinition> for BuildTaskDefinition {
+    fn from(value: extension::BuildTaskDefinition) -> Self {
+        match value {
+            extension::BuildTaskDefinition::ByName(name) => Self::ByName(name.into()),
+            extension::BuildTaskDefinition::Template {
+                task_template,
+                locator_name,
+            } => Self::Template(BuildTaskDefinitionTemplatePayload {
+                template: task_template.into(),
+                locator_name: locator_name.map(String::from),
+            }),
+        }
+    }
+}
+impl From<BuildTaskTemplate> for extension::BuildTaskTemplate {
+    fn from(value: BuildTaskTemplate) -> Self {
+        Self {
+            label: value.label,
+            command: value.command,
+            args: value.args,
+            env: value.env.into_iter().collect(),
+            cwd: value.cwd,
+            ..Default::default()
+        }
+    }
+}
+impl From<extension::BuildTaskTemplate> for BuildTaskTemplate {
+    fn from(value: extension::BuildTaskTemplate) -> Self {
+        Self {
+            label: value.label,
+            command: value.command,
+            args: value.args,
+            env: value.env.into_iter().collect(),
+            cwd: value.cwd,
+        }
+    }
+}
+
+impl TryFrom<DebugScenario> for extension::DebugScenario {
+    type Error = anyhow::Error;
+
+    fn try_from(value: DebugScenario) -> std::result::Result<Self, Self::Error> {
+        Ok(Self {
+            adapter: value.adapter.into(),
+            label: value.label.into(),
+            build: value.build.map(Into::into),
+            config: serde_json::Value::from_str(&value.config)?,
+            tcp_connection: value.tcp_connection.map(Into::into),
+        })
+    }
+}
+
+impl From<extension::DebugScenario> for DebugScenario {
+    fn from(value: extension::DebugScenario) -> Self {
+        Self {
+            adapter: value.adapter.into(),
+            label: value.label.into(),
+            build: value.build.map(Into::into),
+            config: value.config.to_string(),
+            tcp_connection: value.tcp_connection.map(Into::into),
+        }
+    }
+}
+
+impl From<SpawnInTerminal> for ResolvedTask {
+    fn from(value: SpawnInTerminal) -> Self {
+        Self {
+            label: value.label,
+            command: value.command,
+            args: value.args,
+            env: value.env.into_iter().collect(),
+            cwd: value.cwd.map(|s| s.to_string_lossy().into_owned()),
+        }
+    }
+}
+
+impl From<CodeLabel> for extension::CodeLabel {
+    fn from(value: CodeLabel) -> Self {
+        Self {
+            code: value.code,
+            spans: value.spans.into_iter().map(Into::into).collect(),
+            filter_range: value.filter_range.into(),
+        }
+    }
+}
+
+impl From<CodeLabelSpan> for extension::CodeLabelSpan {
+    fn from(value: CodeLabelSpan) -> Self {
+        match value {
+            CodeLabelSpan::CodeRange(range) => Self::CodeRange(range.into()),
+            CodeLabelSpan::Literal(literal) => Self::Literal(literal.into()),
+        }
+    }
+}
+
+impl From<CodeLabelSpanLiteral> for extension::CodeLabelSpanLiteral {
+    fn from(value: CodeLabelSpanLiteral) -> Self {
+        Self {
+            text: value.text,
+            highlight_name: value.highlight_name,
+        }
+    }
+}
+
+impl From<extension::Completion> for Completion {
+    fn from(value: extension::Completion) -> Self {
+        Self {
+            label: value.label,
+            label_details: value.label_details.map(Into::into),
+            detail: value.detail,
+            kind: value.kind.map(Into::into),
+            insert_text_format: value.insert_text_format.map(Into::into),
+        }
+    }
+}
+
+impl From<extension::CompletionLabelDetails> for CompletionLabelDetails {
+    fn from(value: extension::CompletionLabelDetails) -> Self {
+        Self {
+            detail: value.detail,
+            description: value.description,
+        }
+    }
+}
+
+impl From<extension::CompletionKind> for CompletionKind {
+    fn from(value: extension::CompletionKind) -> Self {
+        match value {
+            extension::CompletionKind::Text => Self::Text,
+            extension::CompletionKind::Method => Self::Method,
+            extension::CompletionKind::Function => Self::Function,
+            extension::CompletionKind::Constructor => Self::Constructor,
+            extension::CompletionKind::Field => Self::Field,
+            extension::CompletionKind::Variable => Self::Variable,
+            extension::CompletionKind::Class => Self::Class,
+            extension::CompletionKind::Interface => Self::Interface,
+            extension::CompletionKind::Module => Self::Module,
+            extension::CompletionKind::Property => Self::Property,
+            extension::CompletionKind::Unit => Self::Unit,
+            extension::CompletionKind::Value => Self::Value,
+            extension::CompletionKind::Enum => Self::Enum,
+            extension::CompletionKind::Keyword => Self::Keyword,
+            extension::CompletionKind::Snippet => Self::Snippet,
+            extension::CompletionKind::Color => Self::Color,
+            extension::CompletionKind::File => Self::File,
+            extension::CompletionKind::Reference => Self::Reference,
+            extension::CompletionKind::Folder => Self::Folder,
+            extension::CompletionKind::EnumMember => Self::EnumMember,
+            extension::CompletionKind::Constant => Self::Constant,
+            extension::CompletionKind::Struct => Self::Struct,
+            extension::CompletionKind::Event => Self::Event,
+            extension::CompletionKind::Operator => Self::Operator,
+            extension::CompletionKind::TypeParameter => Self::TypeParameter,
+            extension::CompletionKind::Other(value) => Self::Other(value),
+        }
+    }
+}
+
+impl From<extension::InsertTextFormat> for InsertTextFormat {
+    fn from(value: extension::InsertTextFormat) -> Self {
+        match value {
+            extension::InsertTextFormat::PlainText => Self::PlainText,
+            extension::InsertTextFormat::Snippet => Self::Snippet,
+            extension::InsertTextFormat::Other(value) => Self::Other(value),
+        }
+    }
+}
+
+impl From<extension::Symbol> for Symbol {
+    fn from(value: extension::Symbol) -> Self {
+        Self {
+            kind: value.kind.into(),
+            name: value.name,
+        }
+    }
+}
+
+impl From<extension::SymbolKind> for SymbolKind {
+    fn from(value: extension::SymbolKind) -> Self {
+        match value {
+            extension::SymbolKind::File => Self::File,
+            extension::SymbolKind::Module => Self::Module,
+            extension::SymbolKind::Namespace => Self::Namespace,
+            extension::SymbolKind::Package => Self::Package,
+            extension::SymbolKind::Class => Self::Class,
+            extension::SymbolKind::Method => Self::Method,
+            extension::SymbolKind::Property => Self::Property,
+            extension::SymbolKind::Field => Self::Field,
+            extension::SymbolKind::Constructor => Self::Constructor,
+            extension::SymbolKind::Enum => Self::Enum,
+            extension::SymbolKind::Interface => Self::Interface,
+            extension::SymbolKind::Function => Self::Function,
+            extension::SymbolKind::Variable => Self::Variable,
+            extension::SymbolKind::Constant => Self::Constant,
+            extension::SymbolKind::String => Self::String,
+            extension::SymbolKind::Number => Self::Number,
+            extension::SymbolKind::Boolean => Self::Boolean,
+            extension::SymbolKind::Array => Self::Array,
+            extension::SymbolKind::Object => Self::Object,
+            extension::SymbolKind::Key => Self::Key,
+            extension::SymbolKind::Null => Self::Null,
+            extension::SymbolKind::EnumMember => Self::EnumMember,
+            extension::SymbolKind::Struct => Self::Struct,
+            extension::SymbolKind::Event => Self::Event,
+            extension::SymbolKind::Operator => Self::Operator,
+            extension::SymbolKind::TypeParameter => Self::TypeParameter,
+            extension::SymbolKind::Other(value) => Self::Other(value),
+        }
+    }
+}
+
+impl From<extension::SlashCommand> for SlashCommand {
+    fn from(value: extension::SlashCommand) -> Self {
+        Self {
+            name: value.name,
+            description: value.description,
+            tooltip_text: value.tooltip_text,
+            requires_argument: value.requires_argument,
+        }
+    }
+}
+
+impl From<SlashCommandOutput> for extension::SlashCommandOutput {
+    fn from(value: SlashCommandOutput) -> Self {
+        Self {
+            text: value.text,
+            sections: value.sections.into_iter().map(Into::into).collect(),
+        }
+    }
+}
+
+impl From<SlashCommandOutputSection> for extension::SlashCommandOutputSection {
+    fn from(value: SlashCommandOutputSection) -> Self {
+        Self {
+            range: value.range.start as usize..value.range.end as usize,
+            label: value.label,
+        }
+    }
+}
+
+impl From<SlashCommandArgumentCompletion> for extension::SlashCommandArgumentCompletion {
+    fn from(value: SlashCommandArgumentCompletion) -> Self {
+        Self {
+            label: value.label,
+            new_text: value.new_text,
+            run_command: value.run_command,
+        }
+    }
+}
+
+impl TryFrom<ContextServerConfiguration> for extension::ContextServerConfiguration {
+    type Error = anyhow::Error;
+
+    fn try_from(value: ContextServerConfiguration) -> Result<Self, Self::Error> {
+        let settings_schema: serde_json::Value = serde_json::from_str(&value.settings_schema)
+            .context("Failed to parse settings_schema")?;
+
+        Ok(Self {
+            installation_instructions: value.installation_instructions,
+            default_settings: value.default_settings,
+            settings_schema,
+        })
+    }
+}
+
+impl HostKeyValueStore for WasmState {
+    async fn insert(
+        &mut self,
+        kv_store: Resource<ExtensionKeyValueStore>,
+        key: String,
+        value: String,
+    ) -> wasmtime::Result<Result<(), String>> {
+        let kv_store = self.table.get(&kv_store)?;
+        kv_store.insert(key, value).await.to_wasmtime_result()
+    }
+
+    async fn drop(&mut self, _worktree: Resource<ExtensionKeyValueStore>) -> Result<()> {
+        // We only ever hand out borrows of key-value stores.
+        Ok(())
+    }
+}
+
+impl HostProject for WasmState {
+    async fn worktree_ids(
+        &mut self,
+        project: Resource<ExtensionProject>,
+    ) -> wasmtime::Result<Vec<u64>> {
+        let project = self.table.get(&project)?;
+        Ok(project.worktree_ids())
+    }
+
+    async fn drop(&mut self, _project: Resource<Project>) -> Result<()> {
+        // We only ever hand out borrows of projects.
+        Ok(())
+    }
+}
+
+impl HostWorktree for WasmState {
+    async fn id(&mut self, delegate: Resource<Arc<dyn WorktreeDelegate>>) -> wasmtime::Result<u64> {
+        let delegate = self.table.get(&delegate)?;
+        Ok(delegate.id())
+    }
+
+    async fn root_path(
+        &mut self,
+        delegate: Resource<Arc<dyn WorktreeDelegate>>,
+    ) -> wasmtime::Result<String> {
+        let delegate = self.table.get(&delegate)?;
+        Ok(delegate.root_path())
+    }
+
+    async fn read_text_file(
+        &mut self,
+        delegate: Resource<Arc<dyn WorktreeDelegate>>,
+        path: String,
+    ) -> wasmtime::Result<Result<String, String>> {
+        let delegate = self.table.get(&delegate)?;
+        Ok(delegate
+            .read_text_file(path.into())
+            .await
+            .map_err(|error| error.to_string()))
+    }
+
+    async fn shell_env(
+        &mut self,
+        delegate: Resource<Arc<dyn WorktreeDelegate>>,
+    ) -> wasmtime::Result<EnvVars> {
+        let delegate = self.table.get(&delegate)?;
+        Ok(delegate.shell_env().await.into_iter().collect())
+    }
+
+    async fn which(
+        &mut self,
+        delegate: Resource<Arc<dyn WorktreeDelegate>>,
+        binary_name: String,
+    ) -> wasmtime::Result<Option<String>> {
+        let delegate = self.table.get(&delegate)?;
+        Ok(delegate.which(binary_name).await)
+    }
+
+    async fn drop(&mut self, _worktree: Resource<Worktree>) -> Result<()> {
+        // We only ever hand out borrows of worktrees.
+        Ok(())
+    }
+}
+
+impl common::Host for WasmState {}
+
+impl http_client::Host for WasmState {
+    async fn fetch(
+        &mut self,
+        request: http_client::HttpRequest,
+    ) -> wasmtime::Result<Result<http_client::HttpResponse, String>> {
+        maybe!(async {
+            let url = &request.url;
+            let request = convert_request(&request)?;
+            let mut response = self.host.http_client.send(request).await?;
+
+            if response.status().is_client_error() || response.status().is_server_error() {
+                bail!("failed to fetch '{url}': status code {}", response.status())
+            }
+            convert_response(&mut response).await
+        })
+        .await
+        .to_wasmtime_result()
+    }
+
+    async fn fetch_stream(
+        &mut self,
+        request: http_client::HttpRequest,
+    ) -> wasmtime::Result<Result<Resource<ExtensionHttpResponseStream>, String>> {
+        let request = convert_request(&request)?;
+        let response = self.host.http_client.send(request);
+        maybe!(async {
+            let response = response.await?;
+            let stream = Arc::new(Mutex::new(response));
+            let resource = self.table.push(stream)?;
+            Ok(resource)
+        })
+        .await
+        .to_wasmtime_result()
+    }
+}
+
+impl http_client::HostHttpResponseStream for WasmState {
+    async fn next_chunk(
+        &mut self,
+        resource: Resource<ExtensionHttpResponseStream>,
+    ) -> wasmtime::Result<Result<Option<Vec<u8>>, String>> {
+        let stream = self.table.get(&resource)?.clone();
+        maybe!(async move {
+            let mut response = stream.lock().await;
+            let mut buffer = vec![0; 8192]; // 8KB buffer
+            let bytes_read = response.body_mut().read(&mut buffer).await?;
+            if bytes_read == 0 {
+                Ok(None)
+            } else {
+                buffer.truncate(bytes_read);
+                Ok(Some(buffer))
+            }
+        })
+        .await
+        .to_wasmtime_result()
+    }
+
+    async fn drop(&mut self, _resource: Resource<ExtensionHttpResponseStream>) -> Result<()> {
+        Ok(())
+    }
+}
+
+impl From<http_client::HttpMethod> for ::http_client::Method {
+    fn from(value: http_client::HttpMethod) -> Self {
+        match value {
+            http_client::HttpMethod::Get => Self::GET,
+            http_client::HttpMethod::Post => Self::POST,
+            http_client::HttpMethod::Put => Self::PUT,
+            http_client::HttpMethod::Delete => Self::DELETE,
+            http_client::HttpMethod::Head => Self::HEAD,
+            http_client::HttpMethod::Options => Self::OPTIONS,
+            http_client::HttpMethod::Patch => Self::PATCH,
+        }
+    }
+}
+
+fn convert_request(
+    extension_request: &http_client::HttpRequest,
+) -> anyhow::Result<::http_client::Request<AsyncBody>> {
+    let mut request = ::http_client::Request::builder()
+        .method(::http_client::Method::from(extension_request.method))
+        .uri(&extension_request.url)
+        .follow_redirects(match extension_request.redirect_policy {
+            http_client::RedirectPolicy::NoFollow => ::http_client::RedirectPolicy::NoFollow,
+            http_client::RedirectPolicy::FollowLimit(limit) => {
+                ::http_client::RedirectPolicy::FollowLimit(limit)
+            }
+            http_client::RedirectPolicy::FollowAll => ::http_client::RedirectPolicy::FollowAll,
+        });
+    for (key, value) in &extension_request.headers {
+        request = request.header(key, value);
+    }
+    let body = extension_request
+        .body
+        .clone()
+        .map(AsyncBody::from)
+        .unwrap_or_default();
+    request.body(body).map_err(anyhow::Error::from)
+}
+
+async fn convert_response(
+    response: &mut ::http_client::Response<AsyncBody>,
+) -> anyhow::Result<http_client::HttpResponse> {
+    let mut extension_response = http_client::HttpResponse {
+        body: Vec::new(),
+        headers: Vec::new(),
+    };
+
+    for (key, value) in response.headers() {
+        extension_response
+            .headers
+            .push((key.to_string(), value.to_str().unwrap_or("").to_string()));
+    }
+
+    response
+        .body_mut()
+        .read_to_end(&mut extension_response.body)
+        .await?;
+
+    Ok(extension_response)
+}
+
+impl nodejs::Host for WasmState {
+    async fn node_binary_path(&mut self) -> wasmtime::Result<Result<String, String>> {
+        self.host
+            .node_runtime
+            .binary_path()
+            .await
+            .map(|path| path.to_string_lossy().to_string())
+            .to_wasmtime_result()
+    }
+
+    async fn npm_package_latest_version(
+        &mut self,
+        package_name: String,
+    ) -> wasmtime::Result<Result<String, String>> {
+        self.host
+            .node_runtime
+            .npm_package_latest_version(&package_name)
+            .await
+            .to_wasmtime_result()
+    }
+
+    async fn npm_package_installed_version(
+        &mut self,
+        package_name: String,
+    ) -> wasmtime::Result<Result<Option<String>, String>> {
+        self.host
+            .node_runtime
+            .npm_package_installed_version(&self.work_dir(), &package_name)
+            .await
+            .to_wasmtime_result()
+    }
+
+    async fn npm_install_package(
+        &mut self,
+        package_name: String,
+        version: String,
+    ) -> wasmtime::Result<Result<(), String>> {
+        self.host
+            .node_runtime
+            .npm_install_packages(&self.work_dir(), &[(&package_name, &version)])
+            .await
+            .to_wasmtime_result()
+    }
+}
+
+#[async_trait]
+impl lsp::Host for WasmState {}
+
+impl From<::http_client::github::GithubRelease> for github::GithubRelease {
+    fn from(value: ::http_client::github::GithubRelease) -> Self {
+        Self {
+            version: value.tag_name,
+            assets: value.assets.into_iter().map(Into::into).collect(),
+        }
+    }
+}
+
+impl From<::http_client::github::GithubReleaseAsset> for github::GithubReleaseAsset {
+    fn from(value: ::http_client::github::GithubReleaseAsset) -> Self {
+        Self {
+            name: value.name,
+            download_url: value.browser_download_url,
+        }
+    }
+}
+
+impl github::Host for WasmState {
+    async fn latest_github_release(
+        &mut self,
+        repo: String,
+        options: github::GithubReleaseOptions,
+    ) -> wasmtime::Result<Result<github::GithubRelease, String>> {
+        maybe!(async {
+            let release = ::http_client::github::latest_github_release(
+                &repo,
+                options.require_assets,
+                options.pre_release,
+                self.host.http_client.clone(),
+            )
+            .await?;
+            Ok(release.into())
+        })
+        .await
+        .to_wasmtime_result()
+    }
+
+    async fn github_release_by_tag_name(
+        &mut self,
+        repo: String,
+        tag: String,
+    ) -> wasmtime::Result<Result<github::GithubRelease, String>> {
+        maybe!(async {
+            let release = ::http_client::github::get_release_by_tag_name(
+                &repo,
+                &tag,
+                self.host.http_client.clone(),
+            )
+            .await?;
+            Ok(release.into())
+        })
+        .await
+        .to_wasmtime_result()
+    }
+}
+
+impl platform::Host for WasmState {
+    async fn current_platform(&mut self) -> Result<(platform::Os, platform::Architecture)> {
+        Ok((
+            match env::consts::OS {
+                "macos" => platform::Os::Mac,
+                "linux" => platform::Os::Linux,
+                "windows" => platform::Os::Windows,
+                _ => panic!("unsupported os"),
+            },
+            match env::consts::ARCH {
+                "aarch64" => platform::Architecture::Aarch64,
+                "x86" => platform::Architecture::X86,
+                "x86_64" => platform::Architecture::X8664,
+                _ => panic!("unsupported architecture"),
+            },
+        ))
+    }
+}
+
+impl From<std::process::Output> for process::Output {
+    fn from(output: std::process::Output) -> Self {
+        Self {
+            status: output.status.code(),
+            stdout: output.stdout,
+            stderr: output.stderr,
+        }
+    }
+}
+
+impl process::Host for WasmState {
+    async fn run_command(
+        &mut self,
+        command: process::Command,
+    ) -> wasmtime::Result<Result<process::Output, String>> {
+        maybe!(async {
+            self.manifest.allow_exec(&command.command, &command.args)?;
+
+            let output = util::command::new_smol_command(command.command.as_str())
+                .args(&command.args)
+                .envs(command.env)
+                .output()
+                .await?;
+
+            Ok(output.into())
+        })
+        .await
+        .to_wasmtime_result()
+    }
+}
+
+#[async_trait]
+impl slash_command::Host for WasmState {}
+
+#[async_trait]
+impl context_server::Host for WasmState {}
+
+impl dap::Host for WasmState {
+    async fn resolve_tcp_template(
+        &mut self,
+        template: TcpArgumentsTemplate,
+    ) -> wasmtime::Result<Result<TcpArguments, String>> {
+        maybe!(async {
+            let (host, port, timeout) =
+                ::dap::configure_tcp_connection(task::TcpArgumentsTemplate {
+                    port: template.port,
+                    host: template.host.map(Ipv4Addr::from_bits),
+                    timeout: template.timeout,
+                })
+                .await?;
+            Ok(TcpArguments {
+                port,
+                host: host.to_bits(),
+                timeout,
+            })
+        })
+        .await
+        .to_wasmtime_result()
+    }
+}
+
+impl ExtensionImports for WasmState {
+    async fn get_settings(
+        &mut self,
+        location: Option<self::SettingsLocation>,
+        category: String,
+        key: Option<String>,
+    ) -> wasmtime::Result<Result<String, String>> {
+        self.on_main_thread(|cx| {
+            async move {
+                let location = location
+                    .as_ref()
+                    .map(|location| ::settings::SettingsLocation {
+                        worktree_id: WorktreeId::from_proto(location.worktree_id),
+                        path: Path::new(&location.path),
+                    });
+
+                cx.update(|cx| match category.as_str() {
+                    "language" => {
+                        let key = key.map(|k| LanguageName::new(&k));
+                        let settings = AllLanguageSettings::get(location, cx).language(
+                            location,
+                            key.as_ref(),
+                            cx,
+                        );
+                        Ok(serde_json::to_string(&settings::LanguageSettings {
+                            tab_size: settings.tab_size,
+                        })?)
+                    }
+                    "lsp" => {
+                        let settings = key
+                            .and_then(|key| {
+                                ProjectSettings::get(location, cx)
+                                    .lsp
+                                    .get(&::lsp::LanguageServerName::from_proto(key))
+                            })
+                            .cloned()
+                            .unwrap_or_default();
+                        Ok(serde_json::to_string(&settings::LspSettings {
+                            binary: settings.binary.map(|binary| settings::CommandSettings {
+                                path: binary.path,
+                                arguments: binary.arguments,
+                                env: binary.env,
+                            }),
+                            settings: settings.settings,
+                            initialization_options: settings.initialization_options,
+                        })?)
+                    }
+                    "context_servers" => {
+                        let settings = key
+                            .and_then(|key| {
+                                ProjectSettings::get(location, cx)
+                                    .context_servers
+                                    .get(key.as_str())
+                            })
+                            .cloned()
+                            .unwrap_or_else(|| {
+                                project::project_settings::ContextServerSettings::default_extension(
+                                )
+                            });
+
+                        match settings {
+                            project::project_settings::ContextServerSettings::Custom {
+                                enabled: _,
+                                command,
+                            } => Ok(serde_json::to_string(&settings::ContextServerSettings {
+                                command: Some(settings::CommandSettings {
+                                    path: Some(command.path),
+                                    arguments: Some(command.args),
+                                    env: command.env.map(|env| env.into_iter().collect()),
+                                }),
+                                settings: None,
+                            })?),
+                            project::project_settings::ContextServerSettings::Extension {
+                                enabled: _,
+                                settings,
+                            } => Ok(serde_json::to_string(&settings::ContextServerSettings {
+                                command: None,
+                                settings: Some(settings),
+                            })?),
+                        }
+                    }
+                    _ => {
+                        bail!("Unknown settings category: {}", category);
+                    }
+                })
+            }
+            .boxed_local()
+        })
+        .await?
+        .to_wasmtime_result()
+    }
+
+    async fn set_language_server_installation_status(
+        &mut self,
+        server_name: String,
+        status: LanguageServerInstallationStatus,
+    ) -> wasmtime::Result<()> {
+        let status = match status {
+            LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate,
+            LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading,
+            LanguageServerInstallationStatus::None => BinaryStatus::None,
+            LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error },
+        };
+
+        self.host
+            .proxy
+            .update_language_server_status(::lsp::LanguageServerName(server_name.into()), status);
+
+        Ok(())
+    }
+
+    async fn download_file(
+        &mut self,
+        url: String,
+        path: String,
+        file_type: DownloadedFileType,
+    ) -> wasmtime::Result<Result<(), String>> {
+        maybe!(async {
+            let path = PathBuf::from(path);
+            let extension_work_dir = self.host.work_dir.join(self.manifest.id.as_ref());
+
+            self.host.fs.create_dir(&extension_work_dir).await?;
+
+            let destination_path = self
+                .host
+                .writeable_path_from_extension(&self.manifest.id, &path)?;
+
+            let mut response = self
+                .host
+                .http_client
+                .get(&url, Default::default(), true)
+                .await
+                .context("downloading release")?;
+
+            anyhow::ensure!(
+                response.status().is_success(),
+                "download failed with status {}",
+                response.status().to_string()
+            );
+            let body = BufReader::new(response.body_mut());
+
+            match file_type {
+                DownloadedFileType::Uncompressed => {
+                    futures::pin_mut!(body);
+                    self.host
+                        .fs
+                        .create_file_with(&destination_path, body)
+                        .await?;
+                }
+                DownloadedFileType::Gzip => {
+                    let body = GzipDecoder::new(body);
+                    futures::pin_mut!(body);
+                    self.host
+                        .fs
+                        .create_file_with(&destination_path, body)
+                        .await?;
+                }
+                DownloadedFileType::GzipTar => {
+                    let body = GzipDecoder::new(body);
+                    futures::pin_mut!(body);
+                    self.host
+                        .fs
+                        .extract_tar_file(&destination_path, Archive::new(body))
+                        .await?;
+                }
+                DownloadedFileType::Zip => {
+                    futures::pin_mut!(body);
+                    extract_zip(&destination_path, body)
+                        .await
+                        .with_context(|| format!("unzipping {path:?} archive"))?;
+                }
+            }
+
+            Ok(())
+        })
+        .await
+        .to_wasmtime_result()
+    }
+
+    async fn make_file_executable(&mut self, path: String) -> wasmtime::Result<Result<(), String>> {
+        let path = self
+            .host
+            .writeable_path_from_extension(&self.manifest.id, Path::new(&path))?;
+
+        make_file_executable(&path)
+            .await
+            .with_context(|| format!("setting permissions for path {path:?}"))
+            .to_wasmtime_result()
+    }
+}

crates/extensions_ui/Cargo.toml 🔗

@@ -23,6 +23,7 @@ fs.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
 language.workspace = true
+log.workspace = true
 num-format.workspace = true
 picker.workspace = true
 project.workspace = true
@@ -37,9 +38,9 @@ theme.workspace = true
 ui.workspace = true
 util.workspace = true
 vim_mode_setting.workspace = true
+workspace-hack.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
-workspace-hack.workspace = true
 
 [dev-dependencies]
 editor = { workspace = true, features = ["test-support"] }

crates/extensions_ui/src/extension_suggest.rs 🔗

@@ -60,6 +60,7 @@ const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[
     ("r", &["r", "R"]),
     ("racket", &["rkt"]),
     ("rescript", &["res", "resi"]),
+    ("rst", &["rst"]),
     ("ruby", &["rb", "erb"]),
     ("scheme", &["scm"]),
     ("scss", &["scss"]),

crates/extensions_ui/src/extensions_ui.rs 🔗

@@ -64,6 +64,7 @@ pub fn init(cx: &mut App) {
                             ExtensionProvides::IndexedDocsProviders
                         }
                         ExtensionCategoryFilter::Snippets => ExtensionProvides::Snippets,
+                        ExtensionCategoryFilter::DebugAdapters => ExtensionProvides::DebugAdapters,
                     });
 
                     let existing = workspace
@@ -101,7 +102,10 @@ pub fn init(cx: &mut App) {
                         directories: true,
                         multiple: false,
                     },
-                    DirectoryLister::Local(workspace.app_state().fs.clone()),
+                    DirectoryLister::Local(
+                        workspace.project().clone(),
+                        workspace.app_state().fs.clone(),
+                    ),
                     window,
                     cx,
                 );
@@ -132,10 +136,13 @@ pub fn init(cx: &mut App) {
                         match install_task.await {
                             Ok(_) => {}
                             Err(err) => {
+                                log::error!("Failed to install dev extension: {:?}", err);
                                 workspace_handle
                                     .update(cx, |workspace, cx| {
                                         workspace.show_error(
-                                            &err.context("failed to install dev extension"),
+                                            // NOTE: using `anyhow::context` here ends up not printing
+                                            // the error
+                                            &format!("Failed to install dev extension: {}", err),
                                             cx,
                                         );
                                     })
@@ -169,6 +176,7 @@ fn extension_provides_label(provides: ExtensionProvides) -> &'static str {
         ExtensionProvides::SlashCommands => "Slash Commands",
         ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers",
         ExtensionProvides::Snippets => "Snippets",
+        ExtensionProvides::DebugAdapters => "Debug Adapters",
     }
 }
 
@@ -444,9 +452,11 @@ impl ExtensionsPage {
 
         let extension_store = ExtensionStore::global(cx);
 
-        let dev_extensions = extension_store.update(cx, |store, _| {
-            store.dev_extensions().cloned().collect::<Vec<_>>()
-        });
+        let dev_extensions = extension_store
+            .read(cx)
+            .dev_extensions()
+            .cloned()
+            .collect::<Vec<_>>();
 
         let remote_extensions = extension_store.update(cx, |store, cx| {
             store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx)
@@ -464,6 +474,7 @@ impl ExtensionsPage {
                     &match_candidates,
                     &search,
                     false,
+                    true,
                     match_candidates.len(),
                     &Default::default(),
                     cx.background_executor().clone(),
@@ -546,13 +557,15 @@ impl ExtensionsPage {
                     )
                     .child(
                         h_flex()
-                            .gap_2()
+                            .gap_1()
                             .justify_between()
                             .child(
                                 Button::new(
                                     SharedString::from(format!("rebuild-{}", extension.id)),
                                     "Rebuild",
                                 )
+                                .color(Color::Accent)
+                                .disabled(matches!(status, ExtensionStatus::Upgrading))
                                 .on_click({
                                     let extension_id = extension.id.clone();
                                     move |_, _, cx| {
@@ -560,22 +573,20 @@ impl ExtensionsPage {
                                             store.rebuild_dev_extension(extension_id.clone(), cx)
                                         });
                                     }
-                                })
-                                .color(Color::Accent)
-                                .disabled(matches!(status, ExtensionStatus::Upgrading)),
+                                }),
                             )
                             .child(
                                 Button::new(SharedString::from(extension.id.clone()), "Uninstall")
+                                    .color(Color::Accent)
+                                    .disabled(matches!(status, ExtensionStatus::Removing))
                                     .on_click({
                                         let extension_id = extension.id.clone();
                                         move |_, _, cx| {
                                             ExtensionStore::global(cx).update(cx, |store, cx| {
-                                                store.uninstall_extension(extension_id.clone(), cx)
+                                                store.uninstall_extension(extension_id.clone(), cx).detach_and_log_err(cx);
                                             });
                                         }
-                                    })
-                                    .color(Color::Accent)
-                                    .disabled(matches!(status, ExtensionStatus::Removing)),
+                                    }),
                             )
                             .when(can_configure, |this| {
                                 this.child(
@@ -583,8 +594,8 @@ impl ExtensionsPage {
                                         SharedString::from(format!("configure-{}", extension.id)),
                                         "Configure",
                                     )
-
-
+                                    .color(Color::Accent)
+                                    .disabled(matches!(status, ExtensionStatus::Installing))
                                     .on_click({
                                         let manifest = Arc::new(extension.clone());
                                         move |_, _, cx| {
@@ -601,9 +612,7 @@ impl ExtensionsPage {
                                                 });
                                             }
                                         }
-                                    })
-                                    .color(Color::Accent)
-                                    .disabled(matches!(status, ExtensionStatus::Installing)),
+                                    }),
                                 )
                             }),
                     ),
@@ -955,7 +964,7 @@ impl ExtensionsPage {
                 .disabled(true),
                 configure: is_configurable.then(|| {
                     Button::new(
-                        SharedString::from(format!("configure-{}", extension.id.clone())),
+                        SharedString::from(format!("configure-{}", extension.id)),
                         "Configure",
                     )
                     .disabled(true)
@@ -974,13 +983,15 @@ impl ExtensionsPage {
                     move |_, _, cx| {
                         telemetry::event!("Extension Uninstalled", extension_id);
                         ExtensionStore::global(cx).update(cx, |store, cx| {
-                            store.uninstall_extension(extension_id.clone(), cx)
+                            store
+                                .uninstall_extension(extension_id.clone(), cx)
+                                .detach_and_log_err(cx);
                         });
                     }
                 }),
                 configure: is_configurable.then(|| {
                     Button::new(
-                        SharedString::from(format!("configure-{}", extension.id.clone())),
+                        SharedString::from(format!("configure-{}", extension.id)),
                         "Configure",
                     )
                     .on_click({
@@ -1049,7 +1060,7 @@ impl ExtensionsPage {
                 .disabled(true),
                 configure: is_configurable.then(|| {
                     Button::new(
-                        SharedString::from(format!("configure-{}", extension.id.clone())),
+                        SharedString::from(format!("configure-{}", extension.id)),
                         "Configure",
                     )
                     .disabled(true)
@@ -1471,18 +1482,12 @@ impl Render for ExtensionsPage {
                             return this.py_4().child(self.render_empty_state(cx));
                         }
 
-                        let extensions_page = cx.entity().clone();
                         let scroll_handle = self.list.clone();
                         this.child(
-                            uniform_list(
-                                extensions_page,
-                                "entries",
-                                count,
-                                Self::render_extensions,
-                            )
-                            .flex_grow()
-                            .pb_4()
-                            .track_scroll(scroll_handle),
+                            uniform_list("entries", count, cx.processor(Self::render_extensions))
+                                .flex_grow()
+                                .pb_4()
+                                .track_scroll(scroll_handle),
                         )
                         .child(
                             div()

crates/feature_flags/src/feature_flags.rs 🔗

@@ -77,11 +77,6 @@ impl FeatureFlag for NotebookFeatureFlag {
     const NAME: &'static str = "notebooks";
 }
 
-pub struct DebuggerFeatureFlag {}
-impl FeatureFlag for DebuggerFeatureFlag {
-    const NAME: &'static str = "debugger";
-}
-
 pub struct ThreadAutoCaptureFeatureFlag {}
 impl FeatureFlag for ThreadAutoCaptureFeatureFlag {
     const NAME: &'static str = "thread-auto-capture";
@@ -91,6 +86,12 @@ impl FeatureFlag for ThreadAutoCaptureFeatureFlag {
     }
 }
 
+pub struct JjUiFeatureFlag {}
+
+impl FeatureFlag for JjUiFeatureFlag {
+    const NAME: &'static str = "jj-ui";
+}
+
 pub trait FeatureFlagViewExt<V: 'static> {
     fn observe_flag<T: FeatureFlag, F>(&mut self, window: &Window, callback: F) -> Subscription
     where

crates/feedback/src/system_specs.rs 🔗

@@ -15,6 +15,7 @@ pub struct SystemSpecs {
     memory: u64,
     architecture: &'static str,
     commit_sha: Option<String>,
+    bundle_type: Option<String>,
     gpu_specs: Option<String>,
 }
 
@@ -30,10 +31,11 @@ impl SystemSpecs {
         let architecture = env::consts::ARCH;
         let commit_sha = match release_channel {
             ReleaseChannel::Dev | ReleaseChannel::Nightly => {
-                AppCommitSha::try_global(cx).map(|sha| sha.0.clone())
+                AppCommitSha::try_global(cx).map(|sha| sha.full().clone())
             }
             _ => None,
         };
+        let bundle_type = bundle_type();
 
         let gpu_specs = window.gpu_specs().map(|specs| {
             format!(
@@ -47,6 +49,7 @@ impl SystemSpecs {
             SystemSpecs {
                 app_version,
                 release_channel: release_channel.display_name(),
+                bundle_type,
                 os_name,
                 os_version,
                 memory,
@@ -70,11 +73,10 @@ impl SystemSpecs {
         let memory = system.total_memory();
         let architecture = env::consts::ARCH;
         let commit_sha = match release_channel {
-            ReleaseChannel::Dev | ReleaseChannel::Nightly => {
-                app_commit_sha.map(|sha| sha.0.clone())
-            }
+            ReleaseChannel::Dev | ReleaseChannel::Nightly => app_commit_sha.map(|sha| sha.full()),
             _ => None,
         };
+        let bundle_type = bundle_type();
 
         Self {
             app_version: app_version.to_string(),
@@ -84,6 +86,7 @@ impl SystemSpecs {
             memory,
             architecture,
             commit_sha,
+            bundle_type,
             gpu_specs: try_determine_available_gpus(),
         }
     }
@@ -93,12 +96,17 @@ impl Display for SystemSpecs {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         let os_information = format!("OS: {} {}", self.os_name, self.os_version);
         let app_version_information = format!(
-            "Zed: v{} ({}) {}",
+            "Zed: v{} ({}) {}{}",
             self.app_version,
             match &self.commit_sha {
                 Some(commit_sha) => format!("{} {}", self.release_channel, commit_sha),
                 None => self.release_channel.to_string(),
             },
+            if let Some(bundle_type) = &self.bundle_type {
+                format!("({bundle_type})")
+            } else {
+                "".to_string()
+            },
             if cfg!(debug_assertions) {
                 "(Taylor's Version)"
             } else {
@@ -125,7 +133,7 @@ impl Display for SystemSpecs {
 }
 
 fn try_determine_available_gpus() -> Option<String> {
-    #[cfg(target_os = "linux")]
+    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
     {
         return std::process::Command::new("vulkaninfo")
             .args(&["--summary"])
@@ -144,8 +152,21 @@ fn try_determine_available_gpus() -> Option<String> {
             })
             .or(Some("Failed to run `vulkaninfo --summary`".to_string()));
     }
-    #[cfg(not(target_os = "linux"))]
+    #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
     {
         return None;
     }
 }
+
+/// Returns value of `ZED_BUNDLE_TYPE` set at compiletime or else at runtime.
+///
+/// The compiletime value is used by flatpak since it doesn't seem to have a way to provide a
+/// runtime environment variable.
+///
+/// The runtime value is used by snap since the Zed snaps use release binaries directly, and so
+/// cannot have this baked in.
+fn bundle_type() -> Option<String> {
+    option_env!("ZED_BUNDLE_TYPE")
+        .map(|bundle_type| bundle_type.to_string())
+        .or_else(|| env::var("ZED_BUNDLE_TYPE").ok())
+}

crates/file_finder/Cargo.toml 🔗

@@ -24,6 +24,7 @@ menu.workspace = true
 picker.workspace = true
 project.workspace = true
 schemars.workspace = true
+search.workspace = true
 settings.workspace = true
 serde.workspace = true
 serde_derive.workspace = true
@@ -37,10 +38,11 @@ workspace-hack.workspace = true
 [dev-dependencies]
 ctor.workspace = true
 editor = { workspace = true, features = ["test-support"] }
-env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
 picker = { workspace = true, features = ["test-support"] }
+pretty_assertions.workspace = true
 serde_json.workspace = true
 theme = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/file_finder/src/file_finder.rs 🔗

@@ -4,7 +4,6 @@ mod file_finder_tests;
 mod open_path_prompt_tests;
 
 pub mod file_finder_settings;
-mod new_path_prompt;
 mod open_path_prompt;
 
 use futures::future::join_all;
@@ -20,10 +19,10 @@ use gpui::{
     KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
     Window, actions,
 };
-use new_path_prompt::NewPathPrompt;
 use open_path_prompt::OpenPathPrompt;
 use picker::{Picker, PickerDelegate};
 use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
+use search::ToggleIncludeIgnored;
 use settings::Settings;
 use std::{
     borrow::Cow,
@@ -37,8 +36,8 @@ use std::{
 };
 use text::Point;
 use ui::{
-    ContextMenu, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle,
-    prelude::*,
+    ButtonLike, ContextMenu, HighlightedLabel, Indicator, KeyBinding, ListItem, ListItemSpacing,
+    PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, prelude::*,
 };
 use util::{ResultExt, maybe, paths::PathWithPosition, post_inc};
 use workspace::{
@@ -46,7 +45,10 @@ use workspace::{
     notifications::NotifyResultExt, pane,
 };
 
-actions!(file_finder, [SelectPrevious, ToggleMenu]);
+actions!(
+    file_finder,
+    [SelectPrevious, ToggleFilterMenu, ToggleSplitMenu]
+);
 
 impl ModalView for FileFinder {
     fn on_before_dismiss(
@@ -55,7 +57,14 @@ impl ModalView for FileFinder {
         cx: &mut Context<Self>,
     ) -> workspace::DismissDecision {
         let submenu_focused = self.picker.update(cx, |picker, cx| {
-            picker.delegate.popover_menu_handle.is_focused(window, cx)
+            picker
+                .delegate
+                .filter_popover_menu_handle
+                .is_focused(window, cx)
+                || picker
+                    .delegate
+                    .split_popover_menu_handle
+                    .is_focused(window, cx)
         });
         workspace::DismissDecision::Dismiss(!submenu_focused)
     }
@@ -74,8 +83,8 @@ pub fn init_settings(cx: &mut App) {
 pub fn init(cx: &mut App) {
     init_settings(cx);
     cx.observe_new(FileFinder::register).detach();
-    cx.observe_new(NewPathPrompt::register).detach();
     cx.observe_new(OpenPathPrompt::register).detach();
+    cx.observe_new(OpenPathPrompt::register_new_path).detach();
 }
 
 impl FileFinder {
@@ -211,9 +220,14 @@ impl FileFinder {
         window.dispatch_action(Box::new(menu::SelectPrevious), cx);
     }
 
-    fn handle_toggle_menu(&mut self, _: &ToggleMenu, window: &mut Window, cx: &mut Context<Self>) {
+    fn handle_filter_toggle_menu(
+        &mut self,
+        _: &ToggleFilterMenu,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         self.picker.update(cx, |picker, cx| {
-            let menu_handle = &picker.delegate.popover_menu_handle;
+            let menu_handle = &picker.delegate.filter_popover_menu_handle;
             if menu_handle.is_deployed() {
                 menu_handle.hide(cx);
             } else {
@@ -222,6 +236,42 @@ impl FileFinder {
         });
     }
 
+    fn handle_split_toggle_menu(
+        &mut self,
+        _: &ToggleSplitMenu,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.picker.update(cx, |picker, cx| {
+            let menu_handle = &picker.delegate.split_popover_menu_handle;
+            if menu_handle.is_deployed() {
+                menu_handle.hide(cx);
+            } else {
+                menu_handle.show(window, cx);
+            }
+        });
+    }
+
+    fn handle_toggle_ignored(
+        &mut self,
+        _: &ToggleIncludeIgnored,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.picker.update(cx, |picker, cx| {
+            picker.delegate.include_ignored = match picker.delegate.include_ignored {
+                Some(true) => match FileFinderSettings::get_global(cx).include_ignored {
+                    Some(_) => Some(false),
+                    None => None,
+                },
+                Some(false) => Some(true),
+                None => Some(true),
+            };
+            picker.delegate.include_ignored_refresh =
+                picker.delegate.update_matches(picker.query(cx), window, cx);
+        });
+    }
+
     fn go_to_file_split_left(
         &mut self,
         _: &pane::SplitLeft,
@@ -280,6 +330,7 @@ impl FileFinder {
                             worktree_id: WorktreeId::from_usize(m.0.worktree_id),
                             path: m.0.path.clone(),
                         },
+                        Match::CreateNew(p) => p.clone(),
                     };
                     let open_task = workspace.update(cx, move |workspace, cx| {
                         workspace.split_path_preview(path, false, Some(split_direction), window, cx)
@@ -324,7 +375,9 @@ impl Render for FileFinder {
             .w(modal_max_width)
             .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
             .on_action(cx.listener(Self::handle_select_prev))
-            .on_action(cx.listener(Self::handle_toggle_menu))
+            .on_action(cx.listener(Self::handle_filter_toggle_menu))
+            .on_action(cx.listener(Self::handle_split_toggle_menu))
+            .on_action(cx.listener(Self::handle_toggle_ignored))
             .on_action(cx.listener(Self::go_to_file_split_left))
             .on_action(cx.listener(Self::go_to_file_split_right))
             .on_action(cx.listener(Self::go_to_file_split_up))
@@ -349,8 +402,11 @@ pub struct FileFinderDelegate {
     history_items: Vec<FoundPath>,
     separate_history: bool,
     first_update: bool,
-    popover_menu_handle: PopoverMenuHandle<ContextMenu>,
+    filter_popover_menu_handle: PopoverMenuHandle<ContextMenu>,
+    split_popover_menu_handle: PopoverMenuHandle<ContextMenu>,
     focus_handle: FocusHandle,
+    include_ignored: Option<bool>,
+    include_ignored_refresh: Task<()>,
 }
 
 /// Use a custom ordering for file finder: the regular one
@@ -399,13 +455,35 @@ enum Match {
         panel_match: Option<ProjectPanelOrdMatch>,
     },
     Search(ProjectPanelOrdMatch),
+    CreateNew(ProjectPath),
 }
 
 impl Match {
-    fn path(&self) -> &Arc<Path> {
+    fn relative_path(&self) -> Option<&Arc<Path>> {
+        match self {
+            Match::History { path, .. } => Some(&path.project.path),
+            Match::Search(panel_match) => Some(&panel_match.0.path),
+            Match::CreateNew(_) => None,
+        }
+    }
+
+    fn abs_path(&self, project: &Entity<Project>, cx: &App) -> Option<PathBuf> {
         match self {
-            Match::History { path, .. } => &path.project.path,
-            Match::Search(panel_match) => &panel_match.0.path,
+            Match::History { path, .. } => path.absolute.clone().or_else(|| {
+                project
+                    .read(cx)
+                    .worktree_for_id(path.project.worktree_id, cx)?
+                    .read(cx)
+                    .absolutize(&path.project.path)
+                    .ok()
+            }),
+            Match::Search(ProjectPanelOrdMatch(path_match)) => project
+                .read(cx)
+                .worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx)?
+                .read(cx)
+                .absolutize(&path_match.path)
+                .ok(),
+            Match::CreateNew(_) => None,
         }
     }
 
@@ -413,6 +491,7 @@ impl Match {
         match self {
             Match::History { panel_match, .. } => panel_match.as_ref(),
             Match::Search(panel_match) => Some(&panel_match),
+            Match::CreateNew(_) => None,
         }
     }
 }
@@ -442,7 +521,10 @@ impl Matches {
             // reason for the matches set to change.
             self.matches
                 .iter()
-                .position(|m| path.project.path == *m.path())
+                .position(|m| match m.relative_path() {
+                    Some(p) => path.project.path == *p,
+                    None => false,
+                })
                 .ok_or(0)
         } else {
             self.matches.binary_search_by(|m| {
@@ -519,6 +601,12 @@ impl Matches {
         a: &Match,
         b: &Match,
     ) -> cmp::Ordering {
+        // Handle CreateNew variant - always put it at the end
+        match (a, b) {
+            (Match::CreateNew(_), _) => return cmp::Ordering::Less,
+            (_, Match::CreateNew(_)) => return cmp::Ordering::Greater,
+            _ => {}
+        }
         debug_assert!(a.panel_match().is_some() && b.panel_match().is_some());
 
         match (&a, &b) {
@@ -734,8 +822,11 @@ impl FileFinderDelegate {
             history_items,
             separate_history,
             first_update: true,
-            popover_menu_handle: PopoverMenuHandle::default(),
+            filter_popover_menu_handle: PopoverMenuHandle::default(),
+            split_popover_menu_handle: PopoverMenuHandle::default(),
             focus_handle: cx.focus_handle(),
+            include_ignored: FileFinderSettings::get_global(cx).include_ignored,
+            include_ignored_refresh: Task::ready(()),
         }
     }
 
@@ -779,9 +870,11 @@ impl FileFinderDelegate {
                 let worktree = worktree.read(cx);
                 PathMatchCandidateSet {
                     snapshot: worktree.snapshot(),
-                    include_ignored: worktree
-                        .root_entry()
-                        .map_or(false, |entry| entry.is_ignored),
+                    include_ignored: self.include_ignored.unwrap_or_else(|| {
+                        worktree
+                            .root_entry()
+                            .map_or(false, |entry| entry.is_ignored)
+                    }),
                     include_root_name,
                     candidates: project::Candidates::Files,
                 }
@@ -847,6 +940,50 @@ impl FileFinderDelegate {
                 extend_old_matches,
             );
 
+            let filename = &query.raw_query;
+            let mut query_path = Path::new(filename);
+            // add option of creating new file only if path is relative
+            let available_worktree = self
+                .project
+                .read(cx)
+                .visible_worktrees(cx)
+                .filter(|worktree| !worktree.read(cx).is_single_file())
+                .collect::<Vec<_>>();
+            let worktree_count = available_worktree.len();
+            let mut expect_worktree = available_worktree.first().cloned();
+            for worktree in available_worktree {
+                let worktree_root = worktree
+                    .read(cx)
+                    .abs_path()
+                    .file_name()
+                    .map_or(String::new(), |f| f.to_string_lossy().to_string());
+                if worktree_count > 1 && query_path.starts_with(&worktree_root) {
+                    query_path = query_path
+                        .strip_prefix(&worktree_root)
+                        .unwrap_or(query_path);
+                    expect_worktree = Some(worktree);
+                    break;
+                }
+            }
+
+            if let Some(FoundPath { ref project, .. }) = self.currently_opened_path {
+                let worktree_id = project.worktree_id;
+                expect_worktree = self.project.read(cx).worktree_for_id(worktree_id, cx);
+            }
+
+            if let Some(worktree) = expect_worktree {
+                let worktree = worktree.read(cx);
+                if query_path.is_relative()
+                    && worktree.entry_for_path(&query_path).is_none()
+                    && !filename.ends_with("/")
+                {
+                    self.matches.matches.push(Match::CreateNew(ProjectPath {
+                        worktree_id: worktree.id(),
+                        path: Arc::from(query_path),
+                    }));
+                }
+            }
+
             self.selected_index = selected_match.map_or_else(
                 || self.calculate_selected_index(cx),
                 |m| {
@@ -926,6 +1063,12 @@ impl FileFinderDelegate {
                     }
                 }
                 Match::Search(path_match) => self.labels_for_path_match(&path_match.0),
+                Match::CreateNew(project_path) => (
+                    format!("Create file: {}", project_path.path.display()),
+                    vec![],
+                    String::from(""),
+                    vec![],
+                ),
             };
 
         if file_name_positions.is_empty() {
@@ -1038,7 +1181,7 @@ impl FileFinderDelegate {
     ) -> Task<()> {
         cx.spawn_in(window, async move |picker, cx| {
             let Some(project) = picker
-                .update(cx, |picker, _| picker.delegate.project.clone())
+                .read_with(cx, |picker, _| picker.delegate.project.clone())
                 .log_err()
             else {
                 return;
@@ -1109,8 +1252,13 @@ impl FileFinderDelegate {
     fn key_context(&self, window: &Window, cx: &App) -> KeyContext {
         let mut key_context = KeyContext::new_with_defaults();
         key_context.add("FileFinder");
-        if self.popover_menu_handle.is_focused(window, cx) {
-            key_context.add("menu_open");
+
+        if self.filter_popover_menu_handle.is_focused(window, cx) {
+            key_context.add("filter_menu_open");
+        }
+
+        if self.split_popover_menu_handle.is_focused(window, cx) {
+            key_context.add("split_menu_open");
         }
         key_context
     }
@@ -1172,6 +1320,48 @@ impl PickerDelegate for FileFinderDelegate {
     ) -> Task<()> {
         let raw_query = raw_query.replace(' ', "");
         let raw_query = raw_query.trim();
+
+        let raw_query = match &raw_query.get(0..2) {
+            Some(".\\") | Some("./") => &raw_query[2..],
+            Some("a\\") | Some("a/") => {
+                if self
+                    .workspace
+                    .upgrade()
+                    .into_iter()
+                    .flat_map(|workspace| workspace.read(cx).worktrees(cx))
+                    .all(|worktree| {
+                        worktree
+                            .read(cx)
+                            .entry_for_path(Path::new("a"))
+                            .is_none_or(|entry| !entry.is_dir())
+                    })
+                {
+                    &raw_query[2..]
+                } else {
+                    raw_query
+                }
+            }
+            Some("b\\") | Some("b/") => {
+                if self
+                    .workspace
+                    .upgrade()
+                    .into_iter()
+                    .flat_map(|workspace| workspace.read(cx).worktrees(cx))
+                    .all(|worktree| {
+                        worktree
+                            .read(cx)
+                            .entry_for_path(Path::new("b"))
+                            .is_none_or(|entry| !entry.is_dir())
+                    })
+                {
+                    &raw_query[2..]
+                } else {
+                    raw_query
+                }
+            }
+            _ => raw_query,
+        };
+
         if raw_query.is_empty() {
             // if there was no query before, and we already have some (history) matches
             // there's no need to update anything, since nothing has changed.
@@ -1263,6 +1453,29 @@ impl PickerDelegate for FileFinderDelegate {
                             }
                         };
                     match &m {
+                        Match::CreateNew(project_path) => {
+                            // Create a new file with the given filename
+                            if secondary {
+                                workspace.split_path_preview(
+                                    project_path.clone(),
+                                    false,
+                                    None,
+                                    window,
+                                    cx,
+                                )
+                            } else {
+                                workspace.open_path_preview(
+                                    project_path.clone(),
+                                    None,
+                                    true,
+                                    false,
+                                    true,
+                                    window,
+                                    cx,
+                                )
+                            }
+                        }
+
                         Match::History { path, .. } => {
                             let worktree_id = path.project.worktree_id;
                             if workspace
@@ -1393,6 +1606,10 @@ impl PickerDelegate for FileFinderDelegate {
                 .flex_none()
                 .size(IconSize::Small.rems())
                 .into_any_element(),
+            Match::CreateNew(_) => Icon::new(IconName::Plus)
+                .color(Color::Muted)
+                .size(IconSize::Small)
+                .into_any_element(),
         };
         let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx, ix);
 
@@ -1400,7 +1617,8 @@ impl PickerDelegate for FileFinderDelegate {
             if !settings.file_icons {
                 return None;
             }
-            let file_name = path_match.path().file_name()?;
+            let abs_path = path_match.abs_path(&self.project, cx)?;
+            let file_name = abs_path.file_name()?;
             let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
             Some(Icon::from_path(icon).color(Color::Muted))
         });
@@ -1422,45 +1640,146 @@ impl PickerDelegate for FileFinderDelegate {
         )
     }
 
-    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
-        let context = self.focus_handle.clone();
+    fn render_footer(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<AnyElement> {
+        let focus_handle = self.focus_handle.clone();
+
         Some(
             h_flex()
                 .w_full()
-                .p_2()
-                .gap_2()
-                .justify_end()
+                .p_1p5()
+                .justify_between()
                 .border_t_1()
                 .border_color(cx.theme().colors().border_variant)
                 .child(
-                    Button::new("open-selection", "Open").on_click(|_, window, cx| {
-                        window.dispatch_action(menu::Confirm.boxed_clone(), cx)
-                    }),
-                )
-                .child(
-                    PopoverMenu::new("menu-popover")
-                        .with_handle(self.popover_menu_handle.clone())
-                        .attach(gpui::Corner::TopRight)
-                        .anchor(gpui::Corner::BottomRight)
-                        .trigger(
-                            Button::new("actions-trigger", "Split…")
-                                .selected_label_color(Color::Accent),
+                    PopoverMenu::new("filter-menu-popover")
+                        .with_handle(self.filter_popover_menu_handle.clone())
+                        .attach(gpui::Corner::BottomRight)
+                        .anchor(gpui::Corner::BottomLeft)
+                        .offset(gpui::Point {
+                            x: px(1.0),
+                            y: px(1.0),
+                        })
+                        .trigger_with_tooltip(
+                            IconButton::new("filter-trigger", IconName::Sliders)
+                                .icon_size(IconSize::Small)
+                                .icon_size(IconSize::Small)
+                                .toggle_state(self.include_ignored.unwrap_or(false))
+                                .when(self.include_ignored.is_some(), |this| {
+                                    this.indicator(Indicator::dot().color(Color::Info))
+                                }),
+                            {
+                                let focus_handle = focus_handle.clone();
+                                move |window, cx| {
+                                    Tooltip::for_action_in(
+                                        "Filter Options",
+                                        &ToggleFilterMenu,
+                                        &focus_handle,
+                                        window,
+                                        cx,
+                                    )
+                                }
+                            },
                         )
                         .menu({
+                            let focus_handle = focus_handle.clone();
+                            let include_ignored = self.include_ignored;
+
                             move |window, cx| {
                                 Some(ContextMenu::build(window, cx, {
-                                    let context = context.clone();
+                                    let focus_handle = focus_handle.clone();
                                     move |menu, _, _| {
-                                        menu.context(context)
-                                            .action("Split Left", pane::SplitLeft.boxed_clone())
-                                            .action("Split Right", pane::SplitRight.boxed_clone())
-                                            .action("Split Up", pane::SplitUp.boxed_clone())
-                                            .action("Split Down", pane::SplitDown.boxed_clone())
+                                        menu.context(focus_handle.clone())
+                                            .header("Filter Options")
+                                            .toggleable_entry(
+                                                "Include Ignored Files",
+                                                include_ignored.unwrap_or(false),
+                                                ui::IconPosition::End,
+                                                Some(ToggleIncludeIgnored.boxed_clone()),
+                                                move |window, cx| {
+                                                    window.focus(&focus_handle);
+                                                    window.dispatch_action(
+                                                        ToggleIncludeIgnored.boxed_clone(),
+                                                        cx,
+                                                    );
+                                                },
+                                            )
                                     }
                                 }))
                             }
                         }),
                 )
+                .child(
+                    h_flex()
+                        .gap_0p5()
+                        .child(
+                            PopoverMenu::new("split-menu-popover")
+                                .with_handle(self.split_popover_menu_handle.clone())
+                                .attach(gpui::Corner::BottomRight)
+                                .anchor(gpui::Corner::BottomLeft)
+                                .offset(gpui::Point {
+                                    x: px(1.0),
+                                    y: px(1.0),
+                                })
+                                .trigger(
+                                    ButtonLike::new("split-trigger")
+                                        .child(Label::new("Split…"))
+                                        .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                                        .children(
+                                            KeyBinding::for_action_in(
+                                                &ToggleSplitMenu,
+                                                &focus_handle,
+                                                window,
+                                                cx,
+                                            )
+                                            .map(|kb| kb.size(rems_from_px(12.))),
+                                        ),
+                                )
+                                .menu({
+                                    let focus_handle = focus_handle.clone();
+
+                                    move |window, cx| {
+                                        Some(ContextMenu::build(window, cx, {
+                                            let focus_handle = focus_handle.clone();
+                                            move |menu, _, _| {
+                                                menu.context(focus_handle.clone())
+                                                    .action(
+                                                        "Split Left",
+                                                        pane::SplitLeft.boxed_clone(),
+                                                    )
+                                                    .action(
+                                                        "Split Right",
+                                                        pane::SplitRight.boxed_clone(),
+                                                    )
+                                                    .action("Split Up", pane::SplitUp.boxed_clone())
+                                                    .action(
+                                                        "Split Down",
+                                                        pane::SplitDown.boxed_clone(),
+                                                    )
+                                            }
+                                        }))
+                                    }
+                                }),
+                        )
+                        .child(
+                            Button::new("open-selection", "Open")
+                                .key_binding(
+                                    KeyBinding::for_action_in(
+                                        &menu::Confirm,
+                                        &focus_handle,
+                                        window,
+                                        cx,
+                                    )
+                                    .map(|kb| kb.size(rems_from_px(12.))),
+                                )
+                                .on_click(|_, window, cx| {
+                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
+                                }),
+                        ),
+                )
                 .into_any(),
         )
     }

crates/file_finder/src/file_finder_settings.rs 🔗

@@ -8,6 +8,7 @@ pub struct FileFinderSettings {
     pub file_icons: bool,
     pub modal_max_width: Option<FileFinderWidth>,
     pub skip_focus_for_active_in_search: bool,
+    pub include_ignored: Option<bool>,
 }
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
@@ -24,6 +25,20 @@ pub struct FileFinderSettingsContent {
     ///
     /// Default: true
     pub skip_focus_for_active_in_search: Option<bool>,
+    /// Determines whether to show the git status in the file finder
+    ///
+    /// Default: true
+    pub git_status: Option<bool>,
+    /// Whether to use gitignored files when searching.
+    /// Only the file Zed had indexed will be used, not necessary all the gitignored files.
+    ///
+    /// Can accept 3 values:
+    /// * `Some(true)`: Use all gitignored files
+    /// * `Some(false)`: Use only the files Zed had indexed
+    /// * `None`: Be smart and search for ignored when called from a gitignored worktree
+    ///
+    /// Default: None
+    pub include_ignored: Option<Option<bool>>,
 }
 
 impl Settings for FileFinderSettings {

crates/file_finder/src/file_finder_tests.rs 🔗

@@ -1,19 +1,18 @@
-use std::{assert_eq, future::IntoFuture, path::Path, time::Duration};
+use std::{future::IntoFuture, path::Path, time::Duration};
 
 use super::*;
 use editor::Editor;
 use gpui::{Entity, TestAppContext, VisualTestContext};
 use menu::{Confirm, SelectNext, SelectPrevious};
+use pretty_assertions::assert_eq;
 use project::{FS_WATCH_LATENCY, RemoveOptions};
 use serde_json::json;
 use util::path;
-use workspace::{AppState, OpenOptions, ToggleFileFinder, Workspace};
+use workspace::{AppState, CloseActiveItem, OpenOptions, ToggleFileFinder, Workspace};
 
 #[ctor::ctor]
 fn init_logger() {
-    if std::env::var("RUST_LOG").is_ok() {
-        env_logger::init();
-    }
+    zlog::init_test();
 }
 
 #[test]
@@ -197,7 +196,7 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
 
     cx.simulate_input("bna");
     picker.update(cx, |picker, _| {
-        assert_eq!(picker.delegate.matches.len(), 2);
+        assert_eq!(picker.delegate.matches.len(), 3);
     });
     cx.dispatch_action(SelectNext);
     cx.dispatch_action(Confirm);
@@ -208,6 +207,11 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
 
     for bandana_query in [
         "bandana",
+        "./bandana",
+        ".\\bandana",
+        util::path!("a/bandana"),
+        "b/bandana",
+        "b\\bandana",
         " bandana",
         "bandana ",
         " bandana ",
@@ -225,8 +229,14 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
         picker.update(cx, |picker, _| {
             assert_eq!(
                 picker.delegate.matches.len(),
-                1,
-                "Wrong number of matches for bandana query '{bandana_query}'"
+                // existence of CreateNew option depends on whether path already exists
+                if bandana_query == util::path!("a/bandana") {
+                    1
+                } else {
+                    2
+                },
+                "Wrong number of matches for bandana query '{bandana_query}'. Matches: {:?}",
+                picker.delegate.matches
             );
         });
         cx.dispatch_action(SelectNext);
@@ -264,9 +274,9 @@ async fn test_unicode_paths(cx: &mut TestAppContext) {
 
     cx.simulate_input("g");
     picker.update(cx, |picker, _| {
-        assert_eq!(picker.delegate.matches.len(), 1);
+        assert_eq!(picker.delegate.matches.len(), 2);
+        assert_match_at_position(picker, 1, "g");
     });
-    cx.dispatch_action(SelectNext);
     cx.dispatch_action(Confirm);
     cx.read(|cx| {
         let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
@@ -360,13 +370,12 @@ async fn test_complex_path(cx: &mut TestAppContext) {
 
     cx.simulate_input("t");
     picker.update(cx, |picker, _| {
-        assert_eq!(picker.delegate.matches.len(), 1);
+        assert_eq!(picker.delegate.matches.len(), 2);
         assert_eq!(
             collect_search_matches(picker).search_paths_only(),
             vec![PathBuf::from("其他/S数据表格/task.xlsx")],
         )
     });
-    cx.dispatch_action(SelectNext);
     cx.dispatch_action(Confirm);
     cx.read(|cx| {
         let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
@@ -411,8 +420,9 @@ async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
         })
         .await;
     picker.update(cx, |finder, _| {
+        assert_match_at_position(finder, 1, &query_inside_file.to_string());
         let finder = &finder.delegate;
-        assert_eq!(finder.matches.len(), 1);
+        assert_eq!(finder.matches.len(), 2);
         let latest_search_query = finder
             .latest_search_query
             .as_ref()
@@ -426,7 +436,6 @@ async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
         );
     });
 
-    cx.dispatch_action(SelectNext);
     cx.dispatch_action(Confirm);
 
     let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
@@ -486,8 +495,9 @@ async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
         })
         .await;
     picker.update(cx, |finder, _| {
+        assert_match_at_position(finder, 1, &query_outside_file.to_string());
         let delegate = &finder.delegate;
-        assert_eq!(delegate.matches.len(), 1);
+        assert_eq!(delegate.matches.len(), 2);
         let latest_search_query = delegate
             .latest_search_query
             .as_ref()
@@ -501,7 +511,6 @@ async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
         );
     });
 
-    cx.dispatch_action(SelectNext);
     cx.dispatch_action(Confirm);
 
     let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
@@ -556,7 +565,8 @@ async fn test_matching_cancellation(cx: &mut TestAppContext) {
         .await;
 
     picker.update(cx, |picker, _cx| {
-        assert_eq!(picker.delegate.matches.len(), 5)
+        // CreateNew option not shown in this case since file already exists
+        assert_eq!(picker.delegate.matches.len(), 5);
     });
 
     picker.update_in(cx, |picker, window, cx| {
@@ -617,9 +627,13 @@ async fn test_ignored_root(cx: &mut TestAppContext) {
                     "hiccup": "",
                 },
                 "tracked-root": {
-                    ".gitignore": "height",
+                    ".gitignore": "height*",
                     "happiness": "",
                     "height": "",
+                    "heights": {
+                        "height_1": "",
+                        "height_2": "",
+                    },
                     "hi": "",
                     "hiccup": "",
                 },
@@ -630,23 +644,188 @@ async fn test_ignored_root(cx: &mut TestAppContext) {
     let project = Project::test(
         app_state.fs.clone(),
         [
-            "/ancestor/tracked-root".as_ref(),
-            "/ancestor/ignored-root".as_ref(),
+            Path::new(path!("/ancestor/tracked-root")),
+            Path::new(path!("/ancestor/ignored-root")),
         ],
         cx,
     )
     .await;
+    let (picker, workspace, cx) = build_find_picker(project, cx);
 
-    let (picker, _, cx) = build_find_picker(project, cx);
+    picker
+        .update_in(cx, |picker, window, cx| {
+            picker
+                .delegate
+                .spawn_search(test_path_position("hi"), window, cx)
+        })
+        .await;
+    picker.update(cx, |picker, _| {
+        let matches = collect_search_matches(picker);
+        assert_eq!(matches.history.len(), 0);
+        assert_eq!(
+            matches.search,
+            vec![
+                PathBuf::from("ignored-root/hi"),
+                PathBuf::from("tracked-root/hi"),
+                PathBuf::from("ignored-root/hiccup"),
+                PathBuf::from("tracked-root/hiccup"),
+                PathBuf::from("ignored-root/height"),
+                PathBuf::from("ignored-root/happiness"),
+                PathBuf::from("tracked-root/happiness"),
+            ],
+            "All ignored files that were indexed are found for default ignored mode"
+        );
+    });
+    cx.dispatch_action(ToggleIncludeIgnored);
+    picker
+        .update_in(cx, |picker, window, cx| {
+            picker
+                .delegate
+                .spawn_search(test_path_position("hi"), window, cx)
+        })
+        .await;
+    picker.update(cx, |picker, _| {
+        let matches = collect_search_matches(picker);
+        assert_eq!(matches.history.len(), 0);
+        assert_eq!(
+            matches.search,
+            vec![
+                PathBuf::from("ignored-root/hi"),
+                PathBuf::from("tracked-root/hi"),
+                PathBuf::from("ignored-root/hiccup"),
+                PathBuf::from("tracked-root/hiccup"),
+                PathBuf::from("ignored-root/height"),
+                PathBuf::from("tracked-root/height"),
+                PathBuf::from("ignored-root/happiness"),
+                PathBuf::from("tracked-root/happiness"),
+            ],
+            "All ignored files should be found, for the toggled on ignored mode"
+        );
+    });
+
+    picker
+        .update_in(cx, |picker, window, cx| {
+            picker.delegate.include_ignored = Some(false);
+            picker
+                .delegate
+                .spawn_search(test_path_position("hi"), window, cx)
+        })
+        .await;
+    picker.update(cx, |picker, _| {
+        let matches = collect_search_matches(picker);
+        assert_eq!(matches.history.len(), 0);
+        assert_eq!(
+            matches.search,
+            vec![
+                PathBuf::from("tracked-root/hi"),
+                PathBuf::from("tracked-root/hiccup"),
+                PathBuf::from("tracked-root/happiness"),
+            ],
+            "Only non-ignored files should be found for the turned off ignored mode"
+        );
+    });
+
+    workspace
+        .update_in(cx, |workspace, window, cx| {
+            workspace.open_abs_path(
+                PathBuf::from(path!("/ancestor/tracked-root/heights/height_1")),
+                OpenOptions {
+                    visible: Some(OpenVisible::None),
+                    ..OpenOptions::default()
+                },
+                window,
+                cx,
+            )
+        })
+        .await
+        .unwrap();
+    cx.run_until_parked();
+    workspace
+        .update_in(cx, |workspace, window, cx| {
+            workspace.active_pane().update(cx, |pane, cx| {
+                pane.close_active_item(&CloseActiveItem::default(), window, cx)
+            })
+        })
+        .await
+        .unwrap();
+    cx.run_until_parked();
+
+    picker
+        .update_in(cx, |picker, window, cx| {
+            picker.delegate.include_ignored = None;
+            picker
+                .delegate
+                .spawn_search(test_path_position("hi"), window, cx)
+        })
+        .await;
+    picker.update(cx, |picker, _| {
+        let matches = collect_search_matches(picker);
+        assert_eq!(matches.history.len(), 0);
+        assert_eq!(
+            matches.search,
+            vec![
+                PathBuf::from("ignored-root/hi"),
+                PathBuf::from("tracked-root/hi"),
+                PathBuf::from("ignored-root/hiccup"),
+                PathBuf::from("tracked-root/hiccup"),
+                PathBuf::from("ignored-root/height"),
+                PathBuf::from("ignored-root/happiness"),
+                PathBuf::from("tracked-root/happiness"),
+            ],
+            "Only for the worktree with the ignored root, all indexed ignored files are found in the auto ignored mode"
+        );
+    });
 
     picker
         .update_in(cx, |picker, window, cx| {
+            picker.delegate.include_ignored = Some(true);
             picker
                 .delegate
                 .spawn_search(test_path_position("hi"), window, cx)
         })
         .await;
-    picker.update(cx, |picker, _| assert_eq!(picker.delegate.matches.len(), 7));
+    picker.update(cx, |picker, _| {
+        let matches = collect_search_matches(picker);
+        assert_eq!(matches.history.len(), 0);
+        assert_eq!(
+            matches.search,
+            vec![
+                PathBuf::from("ignored-root/hi"),
+                PathBuf::from("tracked-root/hi"),
+                PathBuf::from("ignored-root/hiccup"),
+                PathBuf::from("tracked-root/hiccup"),
+                PathBuf::from("ignored-root/height"),
+                PathBuf::from("tracked-root/height"),
+                PathBuf::from("tracked-root/heights/height_1"),
+                PathBuf::from("tracked-root/heights/height_2"),
+                PathBuf::from("ignored-root/happiness"),
+                PathBuf::from("tracked-root/happiness"),
+            ],
+            "All ignored files that were indexed are found in the turned on ignored mode"
+        );
+    });
+
+    picker
+        .update_in(cx, |picker, window, cx| {
+            picker.delegate.include_ignored = Some(false);
+            picker
+                .delegate
+                .spawn_search(test_path_position("hi"), window, cx)
+        })
+        .await;
+    picker.update(cx, |picker, _| {
+        let matches = collect_search_matches(picker);
+        assert_eq!(matches.history.len(), 0);
+        assert_eq!(
+            matches.search,
+            vec![
+                PathBuf::from("tracked-root/hi"),
+                PathBuf::from("tracked-root/hiccup"),
+                PathBuf::from("tracked-root/happiness"),
+            ],
+            "Only non-ignored files should be found for the turned off ignored mode"
+        );
+    });
 }
 
 #[gpui::test]
@@ -702,6 +881,148 @@ async fn test_single_file_worktrees(cx: &mut TestAppContext) {
     picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0));
 }
 
+#[gpui::test]
+async fn test_create_file_for_multiple_worktrees(cx: &mut TestAppContext) {
+    let app_state = init_test(cx);
+    app_state
+        .fs
+        .as_fake()
+        .insert_tree(
+            path!("/roota"),
+            json!({ "the-parent-dira": { "filea": "" } }),
+        )
+        .await;
+
+    app_state
+        .fs
+        .as_fake()
+        .insert_tree(
+            path!("/rootb"),
+            json!({ "the-parent-dirb": { "fileb": "" } }),
+        )
+        .await;
+
+    let project = Project::test(
+        app_state.fs.clone(),
+        [path!("/roota").as_ref(), path!("/rootb").as_ref()],
+        cx,
+    )
+    .await;
+
+    let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+    let (_worktree_id1, worktree_id2) = cx.read(|cx| {
+        let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
+        (
+            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize),
+            WorktreeId::from_usize(worktrees[1].entity_id().as_u64() as usize),
+        )
+    });
+
+    let b_path = ProjectPath {
+        worktree_id: worktree_id2,
+        path: Arc::from(Path::new(path!("the-parent-dirb/fileb"))),
+    };
+    workspace
+        .update_in(cx, |workspace, window, cx| {
+            workspace.open_path(b_path, None, true, window, cx)
+        })
+        .await
+        .unwrap();
+
+    let finder = open_file_picker(&workspace, cx);
+
+    finder
+        .update_in(cx, |f, window, cx| {
+            f.delegate.spawn_search(
+                test_path_position(path!("the-parent-dirb/filec")),
+                window,
+                cx,
+            )
+        })
+        .await;
+    cx.run_until_parked();
+    finder.update_in(cx, |picker, window, cx| {
+        assert_eq!(picker.delegate.matches.len(), 1);
+        picker.delegate.confirm(false, window, cx)
+    });
+    cx.run_until_parked();
+    cx.read(|cx| {
+        let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
+        let project_path = active_editor.read(cx).project_path(cx);
+        assert_eq!(
+            project_path,
+            Some(ProjectPath {
+                worktree_id: worktree_id2,
+                path: Arc::from(Path::new(path!("the-parent-dirb/filec")))
+            })
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_create_file_no_focused_with_multiple_worktrees(cx: &mut TestAppContext) {
+    let app_state = init_test(cx);
+    app_state
+        .fs
+        .as_fake()
+        .insert_tree(
+            path!("/roota"),
+            json!({ "the-parent-dira": { "filea": "" } }),
+        )
+        .await;
+
+    app_state
+        .fs
+        .as_fake()
+        .insert_tree(
+            path!("/rootb"),
+            json!({ "the-parent-dirb": { "fileb": "" } }),
+        )
+        .await;
+
+    let project = Project::test(
+        app_state.fs.clone(),
+        [path!("/roota").as_ref(), path!("/rootb").as_ref()],
+        cx,
+    )
+    .await;
+
+    let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+    let (_worktree_id1, worktree_id2) = cx.read(|cx| {
+        let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
+        (
+            WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize),
+            WorktreeId::from_usize(worktrees[1].entity_id().as_u64() as usize),
+        )
+    });
+
+    let finder = open_file_picker(&workspace, cx);
+
+    finder
+        .update_in(cx, |f, window, cx| {
+            f.delegate
+                .spawn_search(test_path_position(path!("rootb/filec")), window, cx)
+        })
+        .await;
+    cx.run_until_parked();
+    finder.update_in(cx, |picker, window, cx| {
+        assert_eq!(picker.delegate.matches.len(), 1);
+        picker.delegate.confirm(false, window, cx)
+    });
+    cx.run_until_parked();
+    cx.read(|cx| {
+        let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
+        let project_path = active_editor.read(cx).project_path(cx);
+        assert_eq!(
+            project_path,
+            Some(ProjectPath {
+                worktree_id: worktree_id2,
+                path: Arc::from(Path::new("filec"))
+            })
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_path_distance_ordering(cx: &mut TestAppContext) {
     let app_state = init_test(cx);
@@ -785,7 +1106,8 @@ async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
         .await;
     cx.read(|cx| {
         let finder = picker.read(cx);
-        assert_eq!(finder.delegate.matches.len(), 0);
+        assert_eq!(finder.delegate.matches.len(), 1);
+        assert_match_at_position(finder, 0, "dir");
     });
 }
 
@@ -1344,12 +1666,13 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 5);
+        assert_eq!(finder.delegate.matches.len(), 6);
         assert_match_at_position(finder, 0, "main.rs");
         assert_match_selection(finder, 1, "bar.rs");
         assert_match_at_position(finder, 2, "lib.rs");
         assert_match_at_position(finder, 3, "moo.rs");
         assert_match_at_position(finder, 4, "maaa.rs");
+        assert_match_at_position(finder, 5, ".rs");
     });
 
     // main.rs is not among matches, select top item
@@ -1359,9 +1682,10 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 2);
+        assert_eq!(finder.delegate.matches.len(), 3);
         assert_match_at_position(finder, 0, "bar.rs");
         assert_match_at_position(finder, 1, "lib.rs");
+        assert_match_at_position(finder, 2, "b");
     });
 
     // main.rs is back, put it on top and select next item
@@ -1371,10 +1695,11 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 3);
+        assert_eq!(finder.delegate.matches.len(), 4);
         assert_match_at_position(finder, 0, "main.rs");
         assert_match_selection(finder, 1, "moo.rs");
         assert_match_at_position(finder, 2, "maaa.rs");
+        assert_match_at_position(finder, 3, "m");
     });
 
     // get back to the initial state
@@ -1449,12 +1774,13 @@ async fn test_setting_auto_select_first_and_select_active_file(cx: &mut TestAppC
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 5);
+        assert_eq!(finder.delegate.matches.len(), 6);
         assert_match_selection(finder, 0, "main.rs");
         assert_match_at_position(finder, 1, "bar.rs");
         assert_match_at_position(finder, 2, "lib.rs");
         assert_match_at_position(finder, 3, "moo.rs");
         assert_match_at_position(finder, 4, "maaa.rs");
+        assert_match_at_position(finder, 5, ".rs");
     });
 }
 
@@ -1505,12 +1831,13 @@ async fn test_non_separate_history_items(cx: &mut TestAppContext) {
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 5);
+        assert_eq!(finder.delegate.matches.len(), 6);
         assert_match_at_position(finder, 0, "main.rs");
         assert_match_selection(finder, 1, "moo.rs");
         assert_match_at_position(finder, 2, "bar.rs");
         assert_match_at_position(finder, 3, "lib.rs");
         assert_match_at_position(finder, 4, "maaa.rs");
+        assert_match_at_position(finder, 5, ".rs");
     });
 
     // main.rs is not among matches, select top item
@@ -1520,9 +1847,10 @@ async fn test_non_separate_history_items(cx: &mut TestAppContext) {
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 2);
+        assert_eq!(finder.delegate.matches.len(), 3);
         assert_match_at_position(finder, 0, "bar.rs");
         assert_match_at_position(finder, 1, "lib.rs");
+        assert_match_at_position(finder, 2, "b");
     });
 
     // main.rs is back, put it on top and select next item
@@ -1532,10 +1860,11 @@ async fn test_non_separate_history_items(cx: &mut TestAppContext) {
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 3);
+        assert_eq!(finder.delegate.matches.len(), 4);
         assert_match_at_position(finder, 0, "main.rs");
         assert_match_selection(finder, 1, "moo.rs");
         assert_match_at_position(finder, 2, "maaa.rs");
+        assert_match_at_position(finder, 3, "m");
     });
 
     // get back to the initial state
@@ -1791,9 +2120,10 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp
     let picker = open_file_picker(&workspace, cx);
     cx.simulate_input("rs");
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 2);
+        assert_eq!(finder.delegate.matches.len(), 3);
         assert_match_at_position(finder, 0, "lib.rs");
         assert_match_at_position(finder, 1, "main.rs");
+        assert_match_at_position(finder, 2, "rs");
     });
 
     // Delete main.rs
@@ -1806,8 +2136,9 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp
 
     // main.rs is in not among search results anymore
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 1);
+        assert_eq!(finder.delegate.matches.len(), 2);
         assert_match_at_position(finder, 0, "lib.rs");
+        assert_match_at_position(finder, 1, "rs");
     });
 
     // Create util.rs
@@ -1820,9 +2151,10 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp
 
     // util.rs is among search results
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 2);
+        assert_eq!(finder.delegate.matches.len(), 3);
         assert_match_at_position(finder, 0, "lib.rs");
         assert_match_at_position(finder, 1, "util.rs");
+        assert_match_at_position(finder, 2, "rs");
     });
 }
 
@@ -1862,9 +2194,10 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
     let picker = open_file_picker(&workspace, cx);
     cx.simulate_input("rs");
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 2);
+        assert_eq!(finder.delegate.matches.len(), 3);
         assert_match_at_position(finder, 0, "bar.rs");
         assert_match_at_position(finder, 1, "lib.rs");
+        assert_match_at_position(finder, 2, "rs");
     });
 
     // Add new worktree
@@ -1880,10 +2213,11 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
 
     // main.rs is among search results
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 3);
+        assert_eq!(finder.delegate.matches.len(), 4);
         assert_match_at_position(finder, 0, "bar.rs");
         assert_match_at_position(finder, 1, "lib.rs");
         assert_match_at_position(finder, 2, "main.rs");
+        assert_match_at_position(finder, 3, "rs");
     });
 
     // Remove the first worktree
@@ -1894,8 +2228,9 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
 
     // Files from the first worktree are not in the search results anymore
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 1);
+        assert_eq!(finder.delegate.matches.len(), 2);
         assert_match_at_position(finder, 0, "main.rs");
+        assert_match_at_position(finder, 1, "rs");
     });
 }
 
@@ -2240,7 +2575,7 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) {
     cx.run_until_parked();
 
     picker.update(cx, |picker, _| {
-        assert_eq!(picker.delegate.matches.len(), 6);
+        assert_eq!(picker.delegate.matches.len(), 7);
         assert_eq!(picker.delegate.selected_index, 0);
     });
 
@@ -2252,7 +2587,7 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) {
     cx.run_until_parked();
 
     picker.update(cx, |picker, _| {
-        assert_eq!(picker.delegate.matches.len(), 6);
+        assert_eq!(picker.delegate.matches.len(), 7);
         assert_eq!(picker.delegate.selected_index, 3);
     });
 }
@@ -2294,7 +2629,7 @@ async fn open_queried_buffer(
     let history_items = picker.update(cx, |finder, _| {
         assert_eq!(
             finder.delegate.matches.len(),
-            expected_matches,
+            expected_matches + 1, // +1 from CreateNew option
             "Unexpected number of matches found for query `{input}`, matches: {:?}",
             finder.delegate.matches
         );
@@ -2443,6 +2778,7 @@ fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries
                     .push(Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path));
                 search_entries.search_matches.push(path_match.0.clone());
             }
+            Match::CreateNew(_) => {}
         }
     }
     search_entries
@@ -2476,6 +2812,7 @@ fn assert_match_at_position(
     let match_file_name = match &match_item {
         Match::History { path, .. } => path.absolute.as_deref().unwrap().file_name(),
         Match::Search(path_match) => path_match.0.path.file_name(),
+        Match::CreateNew(project_path) => project_path.path.file_name(),
     }
     .unwrap()
     .to_string_lossy();

crates/file_finder/src/new_path_prompt.rs 🔗

@@ -1,525 +0,0 @@
-use futures::channel::oneshot;
-use fuzzy::PathMatch;
-use gpui::{Entity, HighlightStyle, StyledText};
-use picker::{Picker, PickerDelegate};
-use project::{Entry, PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
-use std::{
-    path::{Path, PathBuf},
-    sync::{
-        Arc,
-        atomic::{self, AtomicBool},
-    },
-};
-use ui::{Context, ListItem, Window};
-use ui::{LabelLike, ListItemSpacing, highlight_ranges, prelude::*};
-use util::ResultExt;
-use workspace::Workspace;
-
-pub(crate) struct NewPathPrompt;
-
-#[derive(Debug, Clone)]
-struct Match {
-    path_match: Option<PathMatch>,
-    suffix: Option<String>,
-}
-
-impl Match {
-    fn entry<'a>(&'a self, project: &'a Project, cx: &'a App) -> Option<&'a Entry> {
-        if let Some(suffix) = &self.suffix {
-            let (worktree, path) = if let Some(path_match) = &self.path_match {
-                (
-                    project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx),
-                    path_match.path.join(suffix),
-                )
-            } else {
-                (project.worktrees(cx).next(), PathBuf::from(suffix))
-            };
-
-            worktree.and_then(|worktree| worktree.read(cx).entry_for_path(path))
-        } else if let Some(path_match) = &self.path_match {
-            let worktree =
-                project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx)?;
-            worktree.read(cx).entry_for_path(path_match.path.as_ref())
-        } else {
-            None
-        }
-    }
-
-    fn is_dir(&self, project: &Project, cx: &App) -> bool {
-        self.entry(project, cx).is_some_and(|e| e.is_dir())
-            || self.suffix.as_ref().is_some_and(|s| s.ends_with('/'))
-    }
-
-    fn relative_path(&self) -> String {
-        if let Some(path_match) = &self.path_match {
-            if let Some(suffix) = &self.suffix {
-                format!(
-                    "{}/{}",
-                    path_match.path.to_string_lossy(),
-                    suffix.trim_end_matches('/')
-                )
-            } else {
-                path_match.path.to_string_lossy().to_string()
-            }
-        } else if let Some(suffix) = &self.suffix {
-            suffix.trim_end_matches('/').to_string()
-        } else {
-            "".to_string()
-        }
-    }
-
-    fn project_path(&self, project: &Project, cx: &App) -> Option<ProjectPath> {
-        let worktree_id = if let Some(path_match) = &self.path_match {
-            WorktreeId::from_usize(path_match.worktree_id)
-        } else if let Some(worktree) = project.visible_worktrees(cx).find(|worktree| {
-            worktree
-                .read(cx)
-                .root_entry()
-                .is_some_and(|entry| entry.is_dir())
-        }) {
-            worktree.read(cx).id()
-        } else {
-            // todo(): we should find_or_create a workspace.
-            return None;
-        };
-
-        let path = PathBuf::from(self.relative_path());
-
-        Some(ProjectPath {
-            worktree_id,
-            path: Arc::from(path),
-        })
-    }
-
-    fn existing_prefix(&self, project: &Project, cx: &App) -> Option<PathBuf> {
-        let worktree = project.worktrees(cx).next()?.read(cx);
-        let mut prefix = PathBuf::new();
-        let parts = self.suffix.as_ref()?.split('/');
-        for part in parts {
-            if worktree.entry_for_path(prefix.join(&part)).is_none() {
-                return Some(prefix);
-            }
-            prefix = prefix.join(part);
-        }
-
-        None
-    }
-
-    fn styled_text(&self, project: &Project, window: &Window, cx: &App) -> StyledText {
-        let mut text = "./".to_string();
-        let mut highlights = Vec::new();
-        let mut offset = text.len();
-
-        let separator = '/';
-        let dir_indicator = "[…]";
-
-        if let Some(path_match) = &self.path_match {
-            text.push_str(&path_match.path.to_string_lossy());
-            let mut whole_path = PathBuf::from(path_match.path_prefix.to_string());
-            whole_path = whole_path.join(path_match.path.clone());
-            for (range, style) in highlight_ranges(
-                &whole_path.to_string_lossy(),
-                &path_match.positions,
-                gpui::HighlightStyle::color(Color::Accent.color(cx)),
-            ) {
-                highlights.push((range.start + offset..range.end + offset, style))
-            }
-            text.push(separator);
-            offset = text.len();
-
-            if let Some(suffix) = &self.suffix {
-                text.push_str(suffix);
-                let entry = self.entry(project, cx);
-                let color = if let Some(entry) = entry {
-                    if entry.is_dir() {
-                        Color::Accent
-                    } else {
-                        Color::Conflict
-                    }
-                } else {
-                    Color::Created
-                };
-                highlights.push((
-                    offset..offset + suffix.len(),
-                    HighlightStyle::color(color.color(cx)),
-                ));
-                offset += suffix.len();
-                if entry.is_some_and(|e| e.is_dir()) {
-                    text.push(separator);
-                    offset += separator.len_utf8();
-
-                    text.push_str(dir_indicator);
-                    highlights.push((
-                        offset..offset + dir_indicator.len(),
-                        HighlightStyle::color(Color::Muted.color(cx)),
-                    ));
-                }
-            } else {
-                text.push_str(dir_indicator);
-                highlights.push((
-                    offset..offset + dir_indicator.len(),
-                    HighlightStyle::color(Color::Muted.color(cx)),
-                ))
-            }
-        } else if let Some(suffix) = &self.suffix {
-            text.push_str(suffix);
-            let existing_prefix_len = self
-                .existing_prefix(project, cx)
-                .map(|prefix| prefix.to_string_lossy().len())
-                .unwrap_or(0);
-
-            if existing_prefix_len > 0 {
-                highlights.push((
-                    offset..offset + existing_prefix_len,
-                    HighlightStyle::color(Color::Accent.color(cx)),
-                ));
-            }
-            highlights.push((
-                offset + existing_prefix_len..offset + suffix.len(),
-                HighlightStyle::color(if self.entry(project, cx).is_some() {
-                    Color::Conflict.color(cx)
-                } else {
-                    Color::Created.color(cx)
-                }),
-            ));
-            offset += suffix.len();
-            if suffix.ends_with('/') {
-                text.push_str(dir_indicator);
-                highlights.push((
-                    offset..offset + dir_indicator.len(),
-                    HighlightStyle::color(Color::Muted.color(cx)),
-                ));
-            }
-        }
-
-        StyledText::new(text).with_default_highlights(&window.text_style().clone(), highlights)
-    }
-}
-
-pub struct NewPathDelegate {
-    project: Entity<Project>,
-    tx: Option<oneshot::Sender<Option<ProjectPath>>>,
-    selected_index: usize,
-    matches: Vec<Match>,
-    last_selected_dir: Option<String>,
-    cancel_flag: Arc<AtomicBool>,
-    should_dismiss: bool,
-}
-
-impl NewPathPrompt {
-    pub(crate) fn register(
-        workspace: &mut Workspace,
-        _window: Option<&mut Window>,
-        _cx: &mut Context<Workspace>,
-    ) {
-        workspace.set_prompt_for_new_path(Box::new(|workspace, window, cx| {
-            let (tx, rx) = futures::channel::oneshot::channel();
-            Self::prompt_for_new_path(workspace, tx, window, cx);
-            rx
-        }));
-    }
-
-    fn prompt_for_new_path(
-        workspace: &mut Workspace,
-        tx: oneshot::Sender<Option<ProjectPath>>,
-        window: &mut Window,
-        cx: &mut Context<Workspace>,
-    ) {
-        let project = workspace.project().clone();
-        workspace.toggle_modal(window, cx, |window, cx| {
-            let delegate = NewPathDelegate {
-                project,
-                tx: Some(tx),
-                selected_index: 0,
-                matches: vec![],
-                cancel_flag: Arc::new(AtomicBool::new(false)),
-                last_selected_dir: None,
-                should_dismiss: true,
-            };
-
-            Picker::uniform_list(delegate, window, cx).width(rems(34.))
-        });
-    }
-}
-
-impl PickerDelegate for NewPathDelegate {
-    type ListItem = ui::ListItem;
-
-    fn match_count(&self) -> usize {
-        self.matches.len()
-    }
-
-    fn selected_index(&self) -> usize {
-        self.selected_index
-    }
-
-    fn set_selected_index(
-        &mut self,
-        ix: usize,
-        _: &mut Window,
-        cx: &mut Context<picker::Picker<Self>>,
-    ) {
-        self.selected_index = ix;
-        cx.notify();
-    }
-
-    fn update_matches(
-        &mut self,
-        query: String,
-        window: &mut Window,
-        cx: &mut Context<picker::Picker<Self>>,
-    ) -> gpui::Task<()> {
-        let query = query
-            .trim()
-            .trim_start_matches("./")
-            .trim_start_matches('/');
-
-        let (dir, suffix) = if let Some(index) = query.rfind('/') {
-            let suffix = if index + 1 < query.len() {
-                Some(query[index + 1..].to_string())
-            } else {
-                None
-            };
-            (query[0..index].to_string(), suffix)
-        } else {
-            (query.to_string(), None)
-        };
-
-        let worktrees = self
-            .project
-            .read(cx)
-            .visible_worktrees(cx)
-            .collect::<Vec<_>>();
-        let include_root_name = worktrees.len() > 1;
-        let candidate_sets = worktrees
-            .into_iter()
-            .map(|worktree| {
-                let worktree = worktree.read(cx);
-                PathMatchCandidateSet {
-                    snapshot: worktree.snapshot(),
-                    include_ignored: worktree
-                        .root_entry()
-                        .map_or(false, |entry| entry.is_ignored),
-                    include_root_name,
-                    candidates: project::Candidates::Directories,
-                }
-            })
-            .collect::<Vec<_>>();
-
-        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
-        self.cancel_flag = Arc::new(AtomicBool::new(false));
-
-        let cancel_flag = self.cancel_flag.clone();
-        let query = query.to_string();
-        let prefix = dir.clone();
-        cx.spawn_in(window, async move |picker, cx| {
-            let matches = fuzzy::match_path_sets(
-                candidate_sets.as_slice(),
-                &dir,
-                None,
-                false,
-                100,
-                &cancel_flag,
-                cx.background_executor().clone(),
-            )
-            .await;
-            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
-            if did_cancel {
-                return;
-            }
-            picker
-                .update(cx, |picker, cx| {
-                    picker
-                        .delegate
-                        .set_search_matches(query, prefix, suffix, matches, cx)
-                })
-                .log_err();
-        })
-    }
-
-    fn confirm_completion(
-        &mut self,
-        _: String,
-        window: &mut Window,
-        cx: &mut Context<Picker<Self>>,
-    ) -> Option<String> {
-        self.confirm_update_query(window, cx)
-    }
-
-    fn confirm_update_query(
-        &mut self,
-        _: &mut Window,
-        cx: &mut Context<Picker<Self>>,
-    ) -> Option<String> {
-        let m = self.matches.get(self.selected_index)?;
-        if m.is_dir(self.project.read(cx), cx) {
-            let path = m.relative_path();
-            self.last_selected_dir = Some(path.clone());
-            Some(format!("{}/", path))
-        } else {
-            None
-        }
-    }
-
-    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
-        let Some(m) = self.matches.get(self.selected_index) else {
-            return;
-        };
-
-        let exists = m.entry(self.project.read(cx), cx).is_some();
-        if exists {
-            self.should_dismiss = false;
-            let answer = window.prompt(
-                gpui::PromptLevel::Critical,
-                &format!("{} already exists. Do you want to replace it?", m.relative_path()),
-                Some(
-                    "A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
-                ),
-                &["Replace", "Cancel"],
-            cx);
-            let m = m.clone();
-            cx.spawn_in(window, async move |picker, cx| {
-                let answer = answer.await.ok();
-                picker
-                    .update(cx, |picker, cx| {
-                        picker.delegate.should_dismiss = true;
-                        if answer != Some(0) {
-                            return;
-                        }
-                        if let Some(path) = m.project_path(picker.delegate.project.read(cx), cx) {
-                            if let Some(tx) = picker.delegate.tx.take() {
-                                tx.send(Some(path)).ok();
-                            }
-                        }
-                        cx.emit(gpui::DismissEvent);
-                    })
-                    .ok();
-            })
-            .detach();
-            return;
-        }
-
-        if let Some(path) = m.project_path(self.project.read(cx), cx) {
-            if let Some(tx) = self.tx.take() {
-                tx.send(Some(path)).ok();
-            }
-        }
-        cx.emit(gpui::DismissEvent);
-    }
-
-    fn should_dismiss(&self) -> bool {
-        self.should_dismiss
-    }
-
-    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
-        if let Some(tx) = self.tx.take() {
-            tx.send(None).ok();
-        }
-        cx.emit(gpui::DismissEvent)
-    }
-
-    fn render_match(
-        &self,
-        ix: usize,
-        selected: bool,
-        window: &mut Window,
-        cx: &mut Context<picker::Picker<Self>>,
-    ) -> Option<Self::ListItem> {
-        let m = self.matches.get(ix)?;
-
-        Some(
-            ListItem::new(ix)
-                .spacing(ListItemSpacing::Sparse)
-                .inset(true)
-                .toggle_state(selected)
-                .child(LabelLike::new().child(m.styled_text(self.project.read(cx), window, cx))),
-        )
-    }
-
-    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
-        Some("Type a path...".into())
-    }
-
-    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
-        Arc::from("[directory/]filename.ext")
-    }
-}
-
-impl NewPathDelegate {
-    fn set_search_matches(
-        &mut self,
-        query: String,
-        prefix: String,
-        suffix: Option<String>,
-        matches: Vec<PathMatch>,
-        cx: &mut Context<Picker<Self>>,
-    ) {
-        cx.notify();
-        if query.is_empty() {
-            self.matches = self
-                .project
-                .read(cx)
-                .worktrees(cx)
-                .flat_map(|worktree| {
-                    let worktree_id = worktree.read(cx).id();
-                    worktree
-                        .read(cx)
-                        .child_entries(Path::new(""))
-                        .filter_map(move |entry| {
-                            entry.is_dir().then(|| Match {
-                                path_match: Some(PathMatch {
-                                    score: 1.0,
-                                    positions: Default::default(),
-                                    worktree_id: worktree_id.to_usize(),
-                                    path: entry.path.clone(),
-                                    path_prefix: "".into(),
-                                    is_dir: entry.is_dir(),
-                                    distance_to_relative_ancestor: 0,
-                                }),
-                                suffix: None,
-                            })
-                        })
-                })
-                .collect();
-
-            return;
-        }
-
-        let mut directory_exists = false;
-
-        self.matches = matches
-            .into_iter()
-            .map(|m| {
-                if m.path.as_ref().to_string_lossy() == prefix {
-                    directory_exists = true
-                }
-                Match {
-                    path_match: Some(m),
-                    suffix: suffix.clone(),
-                }
-            })
-            .collect();
-
-        if !directory_exists {
-            if suffix.is_none()
-                || self
-                    .last_selected_dir
-                    .as_ref()
-                    .is_some_and(|d| query.starts_with(d))
-            {
-                self.matches.insert(
-                    0,
-                    Match {
-                        path_match: None,
-                        suffix: Some(query.clone()),
-                    },
-                )
-            } else {
-                self.matches.push(Match {
-                    path_match: None,
-                    suffix: Some(query.clone()),
-                })
-            }
-        }
-    }
-}

crates/file_finder/src/open_path_prompt.rs 🔗

@@ -1,69 +1,148 @@
+use crate::file_finder_settings::FileFinderSettings;
+use file_icons::FileIcons;
 use futures::channel::oneshot;
 use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{HighlightStyle, StyledText, Task};
 use picker::{Picker, PickerDelegate};
 use project::{DirectoryItem, DirectoryLister};
+use settings::Settings;
 use std::{
-    path::{MAIN_SEPARATOR_STR, Path, PathBuf},
+    path::{self, MAIN_SEPARATOR_STR, Path, PathBuf},
     sync::{
         Arc,
         atomic::{self, AtomicBool},
     },
 };
-use ui::{Context, ListItem, Window};
+use ui::{Context, LabelLike, ListItem, Window};
 use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
 use util::{maybe, paths::compare_paths};
 use workspace::Workspace;
 
 pub(crate) struct OpenPathPrompt;
 
+#[cfg(target_os = "windows")]
+const PROMPT_ROOT: &str = "C:\\";
+#[cfg(not(target_os = "windows"))]
+const PROMPT_ROOT: &str = "/";
+
+#[derive(Debug)]
 pub struct OpenPathDelegate {
     tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
     lister: DirectoryLister,
     selected_index: usize,
-    directory_state: Option<DirectoryState>,
-    matches: Vec<usize>,
+    directory_state: DirectoryState,
     string_matches: Vec<StringMatch>,
     cancel_flag: Arc<AtomicBool>,
     should_dismiss: bool,
+    replace_prompt: Task<()>,
 }
 
 impl OpenPathDelegate {
-    pub fn new(tx: oneshot::Sender<Option<Vec<PathBuf>>>, lister: DirectoryLister) -> Self {
+    pub fn new(
+        tx: oneshot::Sender<Option<Vec<PathBuf>>>,
+        lister: DirectoryLister,
+        creating_path: bool,
+    ) -> Self {
         Self {
             tx: Some(tx),
             lister,
             selected_index: 0,
-            directory_state: None,
-            matches: Vec::new(),
+            directory_state: DirectoryState::None {
+                create: creating_path,
+            },
             string_matches: Vec::new(),
             cancel_flag: Arc::new(AtomicBool::new(false)),
             should_dismiss: true,
+            replace_prompt: Task::ready(()),
+        }
+    }
+
+    fn get_entry(&self, selected_match_index: usize) -> Option<CandidateInfo> {
+        match &self.directory_state {
+            DirectoryState::List { entries, .. } => {
+                let id = self.string_matches.get(selected_match_index)?.candidate_id;
+                entries.iter().find(|entry| entry.path.id == id).cloned()
+            }
+            DirectoryState::Create {
+                user_input,
+                entries,
+                ..
+            } => {
+                let mut i = selected_match_index;
+                if let Some(user_input) = user_input {
+                    if !user_input.exists || !user_input.is_dir {
+                        if i == 0 {
+                            return Some(CandidateInfo {
+                                path: user_input.file.clone(),
+                                is_dir: false,
+                            });
+                        } else {
+                            i -= 1;
+                        }
+                    }
+                }
+                let id = self.string_matches.get(i)?.candidate_id;
+                entries.iter().find(|entry| entry.path.id == id).cloned()
+            }
+            DirectoryState::None { .. } => None,
         }
     }
 
     #[cfg(any(test, feature = "test-support"))]
     pub fn collect_match_candidates(&self) -> Vec<String> {
-        if let Some(state) = self.directory_state.as_ref() {
-            self.matches
+        match &self.directory_state {
+            DirectoryState::List { entries, .. } => self
+                .string_matches
                 .iter()
-                .filter_map(|&index| {
-                    state
-                        .match_candidates
-                        .get(index)
+                .filter_map(|string_match| {
+                    entries
+                        .iter()
+                        .find(|entry| entry.path.id == string_match.candidate_id)
                         .map(|candidate| candidate.path.string.clone())
                 })
-                .collect()
-        } else {
-            Vec::new()
+                .collect(),
+            DirectoryState::Create {
+                user_input,
+                entries,
+                ..
+            } => user_input
+                .into_iter()
+                .filter(|user_input| !user_input.exists || !user_input.is_dir)
+                .map(|user_input| user_input.file.string.clone())
+                .chain(self.string_matches.iter().filter_map(|string_match| {
+                    entries
+                        .iter()
+                        .find(|entry| entry.path.id == string_match.candidate_id)
+                        .map(|candidate| candidate.path.string.clone())
+                }))
+                .collect(),
+            DirectoryState::None { .. } => Vec::new(),
         }
     }
 }
 
 #[derive(Debug)]
-struct DirectoryState {
-    path: String,
-    match_candidates: Vec<CandidateInfo>,
-    error: Option<SharedString>,
+enum DirectoryState {
+    List {
+        parent_path: String,
+        entries: Vec<CandidateInfo>,
+        error: Option<SharedString>,
+    },
+    Create {
+        parent_path: String,
+        user_input: Option<UserInput>,
+        entries: Vec<CandidateInfo>,
+    },
+    None {
+        create: bool,
+    },
+}
+
+#[derive(Debug, Clone)]
+struct UserInput {
+    file: StringMatchCandidate,
+    exists: bool,
+    is_dir: bool,
 }
 
 #[derive(Debug, Clone)]
@@ -80,7 +159,19 @@ impl OpenPathPrompt {
     ) {
         workspace.set_prompt_for_open_path(Box::new(|workspace, lister, window, cx| {
             let (tx, rx) = futures::channel::oneshot::channel();
-            Self::prompt_for_open_path(workspace, lister, tx, window, cx);
+            Self::prompt_for_open_path(workspace, lister, false, tx, window, cx);
+            rx
+        }));
+    }
+
+    pub(crate) fn register_new_path(
+        workspace: &mut Workspace,
+        _window: Option<&mut Window>,
+        _: &mut Context<Workspace>,
+    ) {
+        workspace.set_prompt_for_new_path(Box::new(|workspace, lister, window, cx| {
+            let (tx, rx) = futures::channel::oneshot::channel();
+            Self::prompt_for_open_path(workspace, lister, true, tx, window, cx);
             rx
         }));
     }
@@ -88,13 +179,13 @@ impl OpenPathPrompt {
     fn prompt_for_open_path(
         workspace: &mut Workspace,
         lister: DirectoryLister,
+        creating_path: bool,
         tx: oneshot::Sender<Option<Vec<PathBuf>>>,
         window: &mut Window,
         cx: &mut Context<Workspace>,
     ) {
         workspace.toggle_modal(window, cx, |window, cx| {
-            let delegate = OpenPathDelegate::new(tx, lister.clone());
-
+            let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
             let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
             let query = lister.default_query(cx);
             picker.set_query(query, window, cx);
@@ -107,7 +198,16 @@ impl PickerDelegate for OpenPathDelegate {
     type ListItem = ui::ListItem;
 
     fn match_count(&self) -> usize {
-        self.matches.len()
+        let user_input = if let DirectoryState::Create { user_input, .. } = &self.directory_state {
+            user_input
+                .as_ref()
+                .filter(|input| !input.exists || !input.is_dir)
+                .into_iter()
+                .count()
+        } else {
+            0
+        };
+        self.string_matches.len() + user_input
     }
 
     fn selected_index(&self) -> usize {
@@ -124,157 +224,257 @@ impl PickerDelegate for OpenPathDelegate {
         query: String,
         window: &mut Window,
         cx: &mut Context<Picker<Self>>,
-    ) -> gpui::Task<()> {
-        let lister = self.lister.clone();
-        let query_path = Path::new(&query);
-        let last_item = query_path
+    ) -> Task<()> {
+        let lister = &self.lister;
+        let last_item = Path::new(&query)
             .file_name()
             .unwrap_or_default()
-            .to_string_lossy()
-            .to_string();
-        let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(&last_item) {
-            (dir.to_string(), last_item)
+            .to_string_lossy();
+        let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
+            (dir.to_string(), last_item.into_owned())
         } else {
             (query, String::new())
         };
-
         if dir == "" {
-            #[cfg(not(target_os = "windows"))]
-            {
-                dir = "/".to_string();
-            }
-            #[cfg(target_os = "windows")]
-            {
-                dir = "C:\\".to_string();
-            }
+            dir = PROMPT_ROOT.to_string();
         }
 
-        let query = if self
-            .directory_state
-            .as_ref()
-            .map_or(false, |s| s.path == dir)
-        {
-            None
-        } else {
-            Some(lister.list_directory(dir.clone(), cx))
+        let query = match &self.directory_state {
+            DirectoryState::List { parent_path, .. } => {
+                if parent_path == &dir {
+                    None
+                } else {
+                    Some(lister.list_directory(dir.clone(), cx))
+                }
+            }
+            DirectoryState::Create {
+                parent_path,
+                user_input,
+                ..
+            } => {
+                if parent_path == &dir
+                    && user_input.as_ref().map(|input| &input.file.string) == Some(&suffix)
+                {
+                    None
+                } else {
+                    Some(lister.list_directory(dir.clone(), cx))
+                }
+            }
+            DirectoryState::None { .. } => Some(lister.list_directory(dir.clone(), cx)),
         };
-        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
+        self.cancel_flag.store(true, atomic::Ordering::Release);
         self.cancel_flag = Arc::new(AtomicBool::new(false));
         let cancel_flag = self.cancel_flag.clone();
 
         cx.spawn_in(window, async move |this, cx| {
             if let Some(query) = query {
                 let paths = query.await;
-                if cancel_flag.load(atomic::Ordering::Relaxed) {
+                if cancel_flag.load(atomic::Ordering::Acquire) {
                     return;
                 }
 
-                this.update(cx, |this, _| {
-                    this.delegate.directory_state = Some(match paths {
-                        Ok(mut paths) => {
-                            if dir == "/" {
-                                paths.push(DirectoryItem {
-                                    is_dir: true,
-                                    path: Default::default(),
-                                });
-                            }
-
-                            paths.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
-                            let match_candidates = paths
-                                .iter()
-                                .enumerate()
-                                .map(|(ix, item)| CandidateInfo {
-                                    path: StringMatchCandidate::new(
-                                        ix,
-                                        &item.path.to_string_lossy(),
-                                    ),
-                                    is_dir: item.is_dir,
-                                })
-                                .collect::<Vec<_>>();
-
-                            DirectoryState {
-                                match_candidates,
-                                path: dir,
-                                error: None,
-                            }
-                        }
-                        Err(err) => DirectoryState {
-                            match_candidates: vec![],
-                            path: dir,
-                            error: Some(err.to_string().into()),
-                        },
-                    });
-                })
-                .ok();
+                if this
+                    .update(cx, |this, _| {
+                        let new_state = match &this.delegate.directory_state {
+                            DirectoryState::None { create: false }
+                            | DirectoryState::List { .. } => match paths {
+                                Ok(paths) => DirectoryState::List {
+                                    entries: path_candidates(&dir, paths),
+                                    parent_path: dir.clone(),
+                                    error: None,
+                                },
+                                Err(e) => DirectoryState::List {
+                                    entries: Vec::new(),
+                                    parent_path: dir.clone(),
+                                    error: Some(SharedString::from(e.to_string())),
+                                },
+                            },
+                            DirectoryState::None { create: true }
+                            | DirectoryState::Create { .. } => match paths {
+                                Ok(paths) => {
+                                    let mut entries = path_candidates(&dir, paths);
+                                    let mut exists = false;
+                                    let mut is_dir = false;
+                                    let mut new_id = None;
+                                    entries.retain(|entry| {
+                                        new_id = new_id.max(Some(entry.path.id));
+                                        if entry.path.string == suffix {
+                                            exists = true;
+                                            is_dir = entry.is_dir;
+                                        }
+                                        !exists || is_dir
+                                    });
+
+                                    let new_id = new_id.map(|id| id + 1).unwrap_or(0);
+                                    let user_input = if suffix.is_empty() {
+                                        None
+                                    } else {
+                                        Some(UserInput {
+                                            file: StringMatchCandidate::new(new_id, &suffix),
+                                            exists,
+                                            is_dir,
+                                        })
+                                    };
+                                    DirectoryState::Create {
+                                        entries,
+                                        parent_path: dir.clone(),
+                                        user_input,
+                                    }
+                                }
+                                Err(_) => DirectoryState::Create {
+                                    entries: Vec::new(),
+                                    parent_path: dir.clone(),
+                                    user_input: Some(UserInput {
+                                        exists: false,
+                                        is_dir: false,
+                                        file: StringMatchCandidate::new(0, &suffix),
+                                    }),
+                                },
+                            },
+                        };
+                        this.delegate.directory_state = new_state;
+                    })
+                    .is_err()
+                {
+                    return;
+                }
             }
 
-            let match_candidates = this
-                .update(cx, |this, cx| {
-                    let directory_state = this.delegate.directory_state.as_ref()?;
-                    if directory_state.error.is_some() {
-                        this.delegate.matches.clear();
-                        this.delegate.selected_index = 0;
-                        cx.notify();
-                        return None;
+            let Ok(mut new_entries) =
+                this.update(cx, |this, _| match &this.delegate.directory_state {
+                    DirectoryState::List {
+                        entries,
+                        error: None,
+                        ..
+                    }
+                    | DirectoryState::Create { entries, .. } => entries.clone(),
+                    DirectoryState::List { error: Some(_), .. } | DirectoryState::None { .. } => {
+                        Vec::new()
                     }
-
-                    Some(directory_state.match_candidates.clone())
                 })
-                .unwrap_or(None);
-
-            let Some(mut match_candidates) = match_candidates else {
+            else {
                 return;
             };
 
             if !suffix.starts_with('.') {
-                match_candidates.retain(|m| !m.path.string.starts_with('.'));
+                new_entries.retain(|entry| !entry.path.string.starts_with('.'));
             }
-
-            if suffix == "" {
+            if suffix.is_empty() {
                 this.update(cx, |this, cx| {
-                    this.delegate.matches.clear();
-                    this.delegate.string_matches.clear();
-                    this.delegate
-                        .matches
-                        .extend(match_candidates.iter().map(|m| m.path.id));
-
+                    this.delegate.selected_index = 0;
+                    this.delegate.string_matches = new_entries
+                        .iter()
+                        .map(|m| StringMatch {
+                            candidate_id: m.path.id,
+                            score: 0.0,
+                            positions: Vec::new(),
+                            string: m.path.string.clone(),
+                        })
+                        .collect();
+                    this.delegate.directory_state =
+                        match &this.delegate.directory_state {
+                            DirectoryState::None { create: false }
+                            | DirectoryState::List { .. } => DirectoryState::List {
+                                parent_path: dir.clone(),
+                                entries: new_entries,
+                                error: None,
+                            },
+                            DirectoryState::None { create: true }
+                            | DirectoryState::Create { .. } => DirectoryState::Create {
+                                parent_path: dir.clone(),
+                                user_input: None,
+                                entries: new_entries,
+                            },
+                        };
                     cx.notify();
                 })
                 .ok();
                 return;
             }
 
-            let candidates = match_candidates.iter().map(|m| &m.path).collect::<Vec<_>>();
+            let Ok(is_create_state) =
+                this.update(cx, |this, _| match &this.delegate.directory_state {
+                    DirectoryState::Create { .. } => true,
+                    DirectoryState::List { .. } => false,
+                    DirectoryState::None { create } => *create,
+                })
+            else {
+                return;
+            };
+
+            let candidates = new_entries
+                .iter()
+                .filter_map(|entry| {
+                    if is_create_state && !entry.is_dir && Some(&suffix) == Some(&entry.path.string)
+                    {
+                        None
+                    } else {
+                        Some(&entry.path)
+                    }
+                })
+                .collect::<Vec<_>>();
+
             let matches = fuzzy::match_strings(
                 candidates.as_slice(),
                 &suffix,
                 false,
+                true,
                 100,
                 &cancel_flag,
                 cx.background_executor().clone(),
             )
             .await;
-            if cancel_flag.load(atomic::Ordering::Relaxed) {
+            if cancel_flag.load(atomic::Ordering::Acquire) {
                 return;
             }
 
             this.update(cx, |this, cx| {
-                this.delegate.matches.clear();
+                this.delegate.selected_index = 0;
                 this.delegate.string_matches = matches.clone();
-                this.delegate
-                    .matches
-                    .extend(matches.into_iter().map(|m| m.candidate_id));
-                this.delegate.matches.sort_by_key(|m| {
+                this.delegate.string_matches.sort_by_key(|m| {
                     (
-                        this.delegate.directory_state.as_ref().and_then(|d| {
-                            d.match_candidates
-                                .get(*m)
-                                .map(|c| !c.path.string.starts_with(&suffix))
-                        }),
-                        *m,
+                        new_entries
+                            .iter()
+                            .find(|entry| entry.path.id == m.candidate_id)
+                            .map(|entry| &entry.path)
+                            .map(|candidate| !candidate.string.starts_with(&suffix)),
+                        m.candidate_id,
                     )
                 });
-                this.delegate.selected_index = 0;
+                this.delegate.directory_state = match &this.delegate.directory_state {
+                    DirectoryState::None { create: false } | DirectoryState::List { .. } => {
+                        DirectoryState::List {
+                            entries: new_entries,
+                            parent_path: dir.clone(),
+                            error: None,
+                        }
+                    }
+                    DirectoryState::None { create: true } => DirectoryState::Create {
+                        entries: new_entries,
+                        parent_path: dir.clone(),
+                        user_input: Some(UserInput {
+                            file: StringMatchCandidate::new(0, &suffix),
+                            exists: false,
+                            is_dir: false,
+                        }),
+                    },
+                    DirectoryState::Create { user_input, .. } => {
+                        let (new_id, exists, is_dir) = user_input
+                            .as_ref()
+                            .map(|input| (input.file.id, input.exists, input.is_dir))
+                            .unwrap_or_else(|| (0, false, false));
+                        DirectoryState::Create {
+                            entries: new_entries,
+                            parent_path: dir.clone(),
+                            user_input: Some(UserInput {
+                                file: StringMatchCandidate::new(new_id, &suffix),
+                                exists,
+                                is_dir,
+                            }),
+                        }
+                    }
+                };
+
                 cx.notify();
             })
             .ok();
@@ -287,49 +487,107 @@ impl PickerDelegate for OpenPathDelegate {
         _window: &mut Window,
         _: &mut Context<Picker<Self>>,
     ) -> Option<String> {
+        let candidate = self.get_entry(self.selected_index)?;
         Some(
             maybe!({
-                let m = self.matches.get(self.selected_index)?;
-                let directory_state = self.directory_state.as_ref()?;
-                let candidate = directory_state.match_candidates.get(*m)?;
-                Some(format!(
-                    "{}{}{}",
-                    directory_state.path,
-                    candidate.path.string,
-                    if candidate.is_dir {
-                        MAIN_SEPARATOR_STR
-                    } else {
-                        ""
-                    }
-                ))
+                match &self.directory_state {
+                    DirectoryState::Create { parent_path, .. } => Some(format!(
+                        "{}{}{}",
+                        parent_path,
+                        candidate.path.string,
+                        if candidate.is_dir {
+                            MAIN_SEPARATOR_STR
+                        } else {
+                            ""
+                        }
+                    )),
+                    DirectoryState::List { parent_path, .. } => Some(format!(
+                        "{}{}{}",
+                        parent_path,
+                        candidate.path.string,
+                        if candidate.is_dir {
+                            MAIN_SEPARATOR_STR
+                        } else {
+                            ""
+                        }
+                    )),
+                    DirectoryState::None { .. } => return None,
+                }
             })
             .unwrap_or(query),
         )
     }
 
-    fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
-        let Some(m) = self.matches.get(self.selected_index) else {
-            return;
-        };
-        let Some(directory_state) = self.directory_state.as_ref() else {
-            return;
-        };
-        let Some(candidate) = directory_state.match_candidates.get(*m) else {
+    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        let Some(candidate) = self.get_entry(self.selected_index) else {
             return;
         };
-        let result = if directory_state.path == "/" && candidate.path.string.is_empty() {
-            PathBuf::from("/")
-        } else {
-            Path::new(
-                self.lister
-                    .resolve_tilde(&directory_state.path, cx)
-                    .as_ref(),
-            )
-            .join(&candidate.path.string)
-        };
-        if let Some(tx) = self.tx.take() {
-            tx.send(Some(vec![result])).ok();
+
+        match &self.directory_state {
+            DirectoryState::None { .. } => return,
+            DirectoryState::List { parent_path, .. } => {
+                let confirmed_path =
+                    if parent_path == PROMPT_ROOT && candidate.path.string.is_empty() {
+                        PathBuf::from(PROMPT_ROOT)
+                    } else {
+                        Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
+                            .join(&candidate.path.string)
+                    };
+                if let Some(tx) = self.tx.take() {
+                    tx.send(Some(vec![confirmed_path])).ok();
+                }
+            }
+            DirectoryState::Create {
+                parent_path,
+                user_input,
+                ..
+            } => match user_input {
+                None => return,
+                Some(user_input) => {
+                    if user_input.is_dir {
+                        return;
+                    }
+                    let prompted_path =
+                        if parent_path == PROMPT_ROOT && user_input.file.string.is_empty() {
+                            PathBuf::from(PROMPT_ROOT)
+                        } else {
+                            Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
+                                .join(&user_input.file.string)
+                        };
+                    if user_input.exists {
+                        self.should_dismiss = false;
+                        let answer = window.prompt(
+                            gpui::PromptLevel::Critical,
+                            &format!("{prompted_path:?} already exists. Do you want to replace it?"),
+                            Some(
+                                "A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
+                            ),
+                            &["Replace", "Cancel"],
+                            cx
+                        );
+                        self.replace_prompt = cx.spawn_in(window, async move |picker, cx| {
+                            let answer = answer.await.ok();
+                            picker
+                                .update(cx, |picker, cx| {
+                                    picker.delegate.should_dismiss = true;
+                                    if answer != Some(0) {
+                                        return;
+                                    }
+                                    if let Some(tx) = picker.delegate.tx.take() {
+                                        tx.send(Some(vec![prompted_path])).ok();
+                                    }
+                                    cx.emit(gpui::DismissEvent);
+                                })
+                                .ok();
+                        });
+                        return;
+                    } else if let Some(tx) = self.tx.take() {
+                        tx.send(Some(vec![prompted_path])).ok();
+                    }
+                }
+            },
         }
+
         cx.emit(gpui::DismissEvent);
     }
 
@@ -348,46 +606,166 @@ impl PickerDelegate for OpenPathDelegate {
         &self,
         ix: usize,
         selected: bool,
-        _window: &mut Window,
-        _: &mut Context<Picker<Self>>,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
-        let m = self.matches.get(ix)?;
-        let directory_state = self.directory_state.as_ref()?;
-        let candidate = directory_state.match_candidates.get(*m)?;
-        let highlight_positions = self
-            .string_matches
-            .iter()
-            .find(|string_match| string_match.candidate_id == *m)
-            .map(|string_match| string_match.positions.clone())
-            .unwrap_or_default();
-
-        Some(
-            ListItem::new(ix)
-                .spacing(ListItemSpacing::Sparse)
-                .inset(true)
-                .toggle_state(selected)
-                .child(HighlightedLabel::new(
-                    if directory_state.path == "/" {
-                        format!("/{}", candidate.path.string)
+        let settings = FileFinderSettings::get_global(cx);
+        let candidate = self.get_entry(ix)?;
+        let match_positions = match &self.directory_state {
+            DirectoryState::List { .. } => self.string_matches.get(ix)?.positions.clone(),
+            DirectoryState::Create { user_input, .. } => {
+                if let Some(user_input) = user_input {
+                    if !user_input.exists || !user_input.is_dir {
+                        if ix == 0 {
+                            Vec::new()
+                        } else {
+                            self.string_matches.get(ix - 1)?.positions.clone()
+                        }
                     } else {
-                        candidate.path.string.clone()
-                    },
-                    highlight_positions,
-                )),
-        )
+                        self.string_matches.get(ix)?.positions.clone()
+                    }
+                } else {
+                    self.string_matches.get(ix)?.positions.clone()
+                }
+            }
+            DirectoryState::None { .. } => Vec::new(),
+        };
+
+        let file_icon = maybe!({
+            if !settings.file_icons {
+                return None;
+            }
+            let icon = if candidate.is_dir {
+                FileIcons::get_folder_icon(false, cx)?
+            } else {
+                let path = path::Path::new(&candidate.path.string);
+                FileIcons::get_icon(&path, cx)?
+            };
+            Some(Icon::from_path(icon).color(Color::Muted))
+        });
+
+        match &self.directory_state {
+            DirectoryState::List { parent_path, .. } => Some(
+                ListItem::new(ix)
+                    .spacing(ListItemSpacing::Sparse)
+                    .start_slot::<Icon>(file_icon)
+                    .inset(true)
+                    .toggle_state(selected)
+                    .child(HighlightedLabel::new(
+                        if parent_path == PROMPT_ROOT {
+                            format!("{}{}", PROMPT_ROOT, candidate.path.string)
+                        } else {
+                            candidate.path.string.clone()
+                        },
+                        match_positions,
+                    )),
+            ),
+            DirectoryState::Create {
+                parent_path,
+                user_input,
+                ..
+            } => {
+                let (label, delta) = if parent_path == PROMPT_ROOT {
+                    (
+                        format!("{}{}", PROMPT_ROOT, candidate.path.string),
+                        PROMPT_ROOT.len(),
+                    )
+                } else {
+                    (candidate.path.string.clone(), 0)
+                };
+                let label_len = label.len();
+
+                let label_with_highlights = match user_input {
+                    Some(user_input) => {
+                        if user_input.file.string == candidate.path.string {
+                            if user_input.exists {
+                                let label = if user_input.is_dir {
+                                    label
+                                } else {
+                                    format!("{label} (replace)")
+                                };
+                                StyledText::new(label)
+                                    .with_default_highlights(
+                                        &window.text_style().clone(),
+                                        vec![(
+                                            delta..delta + label_len,
+                                            HighlightStyle::color(Color::Conflict.color(cx)),
+                                        )],
+                                    )
+                                    .into_any_element()
+                            } else {
+                                StyledText::new(format!("{label} (create)"))
+                                    .with_default_highlights(
+                                        &window.text_style().clone(),
+                                        vec![(
+                                            delta..delta + label_len,
+                                            HighlightStyle::color(Color::Created.color(cx)),
+                                        )],
+                                    )
+                                    .into_any_element()
+                            }
+                        } else {
+                            let mut highlight_positions = match_positions;
+                            highlight_positions.iter_mut().for_each(|position| {
+                                *position += delta;
+                            });
+                            HighlightedLabel::new(label, highlight_positions).into_any_element()
+                        }
+                    }
+                    None => {
+                        let mut highlight_positions = match_positions;
+                        highlight_positions.iter_mut().for_each(|position| {
+                            *position += delta;
+                        });
+                        HighlightedLabel::new(label, highlight_positions).into_any_element()
+                    }
+                };
+
+                Some(
+                    ListItem::new(ix)
+                        .spacing(ListItemSpacing::Sparse)
+                        .start_slot::<Icon>(file_icon)
+                        .inset(true)
+                        .toggle_state(selected)
+                        .child(LabelLike::new().child(label_with_highlights)),
+                )
+            }
+            DirectoryState::None { .. } => return None,
+        }
     }
 
     fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
-        let text = if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone())
-        {
-            error
-        } else {
-            "No such file or directory".into()
-        };
-        Some(text)
+        Some(match &self.directory_state {
+            DirectoryState::Create { .. } => SharedString::from("Type a path…"),
+            DirectoryState::List {
+                error: Some(error), ..
+            } => error.clone(),
+            DirectoryState::List { .. } | DirectoryState::None { .. } => {
+                SharedString::from("No such file or directory")
+            }
+        })
     }
 
     fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
         Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
     }
 }
+
+fn path_candidates(parent_path: &String, mut children: Vec<DirectoryItem>) -> Vec<CandidateInfo> {
+    if *parent_path == PROMPT_ROOT {
+        children.push(DirectoryItem {
+            is_dir: true,
+            path: PathBuf::default(),
+        });
+    }
+
+    children.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
+    children
+        .iter()
+        .enumerate()
+        .map(|(ix, item)| CandidateInfo {
+            path: StringMatchCandidate::new(ix, &item.path.to_string_lossy()),
+            is_dir: item.is_dir,
+        })
+        .collect()
+}

crates/file_finder/src/open_path_prompt_tests.rs 🔗

@@ -37,7 +37,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) {
 
     let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
 
-    let (picker, cx) = build_open_path_prompt(project, cx);
+    let (picker, cx) = build_open_path_prompt(project, false, cx);
 
     let query = path!("/root");
     insert_query(query, &picker, cx).await;
@@ -111,7 +111,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) {
 
     let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
 
-    let (picker, cx) = build_open_path_prompt(project, cx);
+    let (picker, cx) = build_open_path_prompt(project, false, cx);
 
     // Confirm completion for the query "/root", since it's a directory, it should add a trailing slash.
     let query = path!("/root");
@@ -204,7 +204,7 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
 
     let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
 
-    let (picker, cx) = build_open_path_prompt(project, cx);
+    let (picker, cx) = build_open_path_prompt(project, false, cx);
 
     // Support both forward and backward slashes.
     let query = "C:/root/";
@@ -251,6 +251,54 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_new_path_prompt(cx: &mut TestAppContext) {
+    let app_state = init_test(cx);
+    app_state
+        .fs
+        .as_fake()
+        .insert_tree(
+            path!("/root"),
+            json!({
+                "a1": "A1",
+                "a2": "A2",
+                "a3": "A3",
+                "dir1": {},
+                "dir2": {
+                    "c": "C",
+                    "d1": "D1",
+                    "d2": "D2",
+                    "d3": "D3",
+                    "dir3": {},
+                    "dir4": {}
+                }
+            }),
+        )
+        .await;
+
+    let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
+
+    let (picker, cx) = build_open_path_prompt(project, true, cx);
+
+    insert_query(path!("/root"), &picker, cx).await;
+    assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]);
+
+    insert_query(path!("/root/d"), &picker, cx).await;
+    assert_eq!(
+        collect_match_candidates(&picker, cx),
+        vec!["d", "dir1", "dir2"]
+    );
+
+    insert_query(path!("/root/dir1"), &picker, cx).await;
+    assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1"]);
+
+    insert_query(path!("/root/dir12"), &picker, cx).await;
+    assert_eq!(collect_match_candidates(&picker, cx), vec!["dir12"]);
+
+    insert_query(path!("/root/dir1"), &picker, cx).await;
+    assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1"]);
+}
+
 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
     cx.update(|cx| {
         let state = AppState::test(cx);
@@ -266,11 +314,12 @@ fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
 
 fn build_open_path_prompt(
     project: Entity<Project>,
+    creating_path: bool,
     cx: &mut TestAppContext,
 ) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) {
     let (tx, _) = futures::channel::oneshot::channel();
     let lister = project::DirectoryLister::Project(project.clone());
-    let delegate = OpenPathDelegate::new(tx, lister.clone());
+    let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
 
     let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
     (

crates/fs/src/fake_git_repo.rs 🔗

@@ -1,11 +1,11 @@
 use crate::FakeFs;
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use collections::{HashMap, HashSet};
 use futures::future::{self, BoxFuture};
 use git::{
     blame::Blame,
     repository::{
-        AskPassDelegate, Branch, CommitDetails, CommitOptions, GitRepository,
+        AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository,
         GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode,
     },
     status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus},
@@ -74,13 +74,13 @@ impl FakeGitRepository {
 impl GitRepository for FakeGitRepository {
     fn reload_index(&self) {}
 
-    fn load_index_text(&self, path: RepoPath) -> BoxFuture<Option<String>> {
+    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
         async {
             self.with_state_async(false, move |state| {
                 state
                     .index_contents
                     .get(path.as_ref())
-                    .ok_or_else(|| anyhow!("not present in index"))
+                    .context("not present in index")
                     .cloned()
             })
             .await
@@ -89,13 +89,13 @@ impl GitRepository for FakeGitRepository {
         .boxed()
     }
 
-    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<Option<String>> {
+    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
         async {
             self.with_state_async(false, move |state| {
                 state
                     .head_contents
                     .get(path.as_ref())
-                    .ok_or_else(|| anyhow!("not present in HEAD"))
+                    .context("not present in HEAD")
                     .cloned()
             })
             .await
@@ -108,7 +108,7 @@ impl GitRepository for FakeGitRepository {
         &self,
         _commit: String,
         _cx: AsyncApp,
-    ) -> BoxFuture<Result<git::repository::CommitDiff>> {
+    ) -> BoxFuture<'_, Result<git::repository::CommitDiff>> {
         unimplemented!()
     }
 
@@ -117,10 +117,10 @@ impl GitRepository for FakeGitRepository {
         path: RepoPath,
         content: Option<String>,
         _env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<anyhow::Result<()>> {
+    ) -> BoxFuture<'_, anyhow::Result<()>> {
         self.with_state_async(true, move |state| {
-            if let Some(message) = state.simulated_index_write_error_message.clone() {
-                return Err(anyhow!("{}", message));
+            if let Some(message) = &state.simulated_index_write_error_message {
+                anyhow::bail!("{message}");
             } else if let Some(content) = content {
                 state.index_contents.insert(path, content);
             } else {
@@ -134,7 +134,7 @@ impl GitRepository for FakeGitRepository {
         None
     }
 
-    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<Result<Vec<Option<String>>>> {
+    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
         self.with_state_async(false, |state| {
             Ok(revs
                 .into_iter()
@@ -143,7 +143,7 @@ impl GitRepository for FakeGitRepository {
         })
     }
 
-    fn show(&self, commit: String) -> BoxFuture<Result<CommitDetails>> {
+    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
         async {
             Ok(CommitDetails {
                 sha: commit.into(),
@@ -158,7 +158,7 @@ impl GitRepository for FakeGitRepository {
         _commit: String,
         _mode: ResetMode,
         _env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>> {
+    ) -> BoxFuture<'_, Result<()>> {
         unimplemented!()
     }
 
@@ -167,7 +167,7 @@ impl GitRepository for FakeGitRepository {
         _commit: String,
         _paths: Vec<RepoPath>,
         _env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>> {
+    ) -> BoxFuture<'_, Result<()>> {
         unimplemented!()
     }
 
@@ -179,11 +179,11 @@ impl GitRepository for FakeGitRepository {
         self.common_dir_path.clone()
     }
 
-    fn merge_message(&self) -> BoxFuture<Option<String>> {
+    fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
         async move { None }.boxed()
     }
 
-    fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<Result<GitStatus>> {
+    fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<'_, Result<GitStatus>> {
         let workdir_path = self.dot_git_path.parent().unwrap();
 
         // Load gitignores
@@ -314,7 +314,7 @@ impl GitRepository for FakeGitRepository {
         async move { result? }.boxed()
     }
 
-    fn branches(&self) -> BoxFuture<Result<Vec<Branch>>> {
+    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
         self.with_state_async(false, move |state| {
             let current_branch = &state.current_branch_name;
             Ok(state
@@ -330,21 +330,21 @@ impl GitRepository for FakeGitRepository {
         })
     }
 
-    fn change_branch(&self, name: String) -> BoxFuture<Result<()>> {
+    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
         self.with_state_async(true, |state| {
             state.current_branch_name = Some(name);
             Ok(())
         })
     }
 
-    fn create_branch(&self, name: String) -> BoxFuture<Result<()>> {
+    fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
         self.with_state_async(true, move |state| {
             state.branches.insert(name.to_owned());
             Ok(())
         })
     }
 
-    fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<Result<git::blame::Blame>> {
+    fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result<git::blame::Blame>> {
         self.with_state_async(false, move |state| {
             state
                 .blames
@@ -358,7 +358,7 @@ impl GitRepository for FakeGitRepository {
         &self,
         _paths: Vec<RepoPath>,
         _env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>> {
+    ) -> BoxFuture<'_, Result<()>> {
         unimplemented!()
     }
 
@@ -366,7 +366,7 @@ impl GitRepository for FakeGitRepository {
         &self,
         _paths: Vec<RepoPath>,
         _env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>> {
+    ) -> BoxFuture<'_, Result<()>> {
         unimplemented!()
     }
 
@@ -376,7 +376,7 @@ impl GitRepository for FakeGitRepository {
         _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
         _options: CommitOptions,
         _env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>> {
+    ) -> BoxFuture<'_, Result<()>> {
         unimplemented!()
     }
 
@@ -388,7 +388,7 @@ impl GitRepository for FakeGitRepository {
         _askpass: AskPassDelegate,
         _env: Arc<HashMap<String, String>>,
         _cx: AsyncApp,
-    ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
+    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
         unimplemented!()
     }
 
@@ -399,28 +399,29 @@ impl GitRepository for FakeGitRepository {
         _askpass: AskPassDelegate,
         _env: Arc<HashMap<String, String>>,
         _cx: AsyncApp,
-    ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
+    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
         unimplemented!()
     }
 
     fn fetch(
         &self,
+        _fetch_options: FetchOptions,
         _askpass: AskPassDelegate,
         _env: Arc<HashMap<String, String>>,
         _cx: AsyncApp,
-    ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
+    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
         unimplemented!()
     }
 
-    fn get_remotes(&self, _branch: Option<String>) -> BoxFuture<Result<Vec<Remote>>> {
+    fn get_remotes(&self, _branch: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
         unimplemented!()
     }
 
-    fn check_for_pushed_commit(&self) -> BoxFuture<Result<Vec<gpui::SharedString>>> {
+    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<gpui::SharedString>>> {
         future::ready(Ok(Vec::new())).boxed()
     }
 
-    fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<Result<String>> {
+    fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result<String>> {
         unimplemented!()
     }
 
@@ -428,7 +429,10 @@ impl GitRepository for FakeGitRepository {
         unimplemented!()
     }
 
-    fn restore_checkpoint(&self, _checkpoint: GitRepositoryCheckpoint) -> BoxFuture<Result<()>> {
+    fn restore_checkpoint(
+        &self,
+        _checkpoint: GitRepositoryCheckpoint,
+    ) -> BoxFuture<'_, Result<()>> {
         unimplemented!()
     }
 
@@ -436,7 +440,7 @@ impl GitRepository for FakeGitRepository {
         &self,
         _left: GitRepositoryCheckpoint,
         _right: GitRepositoryCheckpoint,
-    ) -> BoxFuture<Result<bool>> {
+    ) -> BoxFuture<'_, Result<bool>> {
         unimplemented!()
     }
 
@@ -444,7 +448,7 @@ impl GitRepository for FakeGitRepository {
         &self,
         _base_checkpoint: GitRepositoryCheckpoint,
         _target_checkpoint: GitRepositoryCheckpoint,
-    ) -> BoxFuture<Result<String>> {
+    ) -> BoxFuture<'_, Result<String>> {
         unimplemented!()
     }
 }

crates/fs/src/fs.rs 🔗

@@ -272,7 +272,7 @@ impl FileHandle for std::fs::File {
         Ok(path)
     }
 
-    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+    #[cfg(target_os = "linux")]
     fn current_path(&self, _: &Arc<dyn Fs>) -> Result<PathBuf> {
         let fd = self.as_fd();
         let fd_path = format!("/proc/self/fd/{}", fd.as_raw_fd());
@@ -287,6 +287,27 @@ impl FileHandle for std::fs::File {
         Ok(new_path)
     }
 
+    #[cfg(target_os = "freebsd")]
+    fn current_path(&self, _: &Arc<dyn Fs>) -> Result<PathBuf> {
+        use std::{
+            ffi::{CStr, OsStr},
+            os::unix::ffi::OsStrExt,
+        };
+
+        let fd = self.as_fd();
+        let mut kif: libc::kinfo_file = unsafe { std::mem::zeroed() };
+        kif.kf_structsize = libc::KINFO_FILE_SIZE;
+
+        let result = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_KINFO, &mut kif) };
+        if result == -1 {
+            anyhow::bail!("fcntl returned -1".to_string());
+        }
+
+        let c_str = unsafe { CStr::from_ptr(kif.kf_path.as_ptr()) };
+        let path = PathBuf::from(OsStr::from_bytes(c_str.to_bytes()));
+        Ok(path)
+    }
+
     #[cfg(target_os = "windows")]
     fn current_path(&self, _: &Arc<dyn Fs>) -> Result<PathBuf> {
         anyhow::bail!("unimplemented")
@@ -360,7 +381,7 @@ impl Fs for RealFs {
             if options.ignore_if_exists {
                 return Ok(());
             } else {
-                return Err(anyhow!("{target:?} already exists"));
+                anyhow::bail!("{target:?} already exists");
             }
         }
 
@@ -373,7 +394,7 @@ impl Fs for RealFs {
             if options.ignore_if_exists {
                 return Ok(());
             } else {
-                return Err(anyhow!("{target:?} already exists"));
+                anyhow::bail!("{target:?} already exists");
             }
         }
 
@@ -528,17 +549,14 @@ impl Fs for RealFs {
     #[cfg(not(target_os = "windows"))]
     async fn atomic_write(&self, path: PathBuf, data: String) -> Result<()> {
         smol::unblock(move || {
-            let mut tmp_file = if cfg!(any(target_os = "linux", target_os = "freebsd")) {
-                // Use the directory of the destination as temp dir to avoid
-                // invalid cross-device link error, and XDG_CACHE_DIR for fallback.
-                // See https://github.com/zed-industries/zed/pull/8437 for more details.
-                tempfile::NamedTempFile::new_in(path.parent().unwrap_or(paths::temp_dir()))
-            } else {
-                tempfile::NamedTempFile::new()
-            }?;
+            // Use the directory of the destination as temp dir to avoid
+            // invalid cross-device link error, and XDG_CACHE_DIR for fallback.
+            // See https://github.com/zed-industries/zed/pull/8437 for more details.
+            let mut tmp_file =
+                tempfile::NamedTempFile::new_in(path.parent().unwrap_or(paths::temp_dir()))?;
             tmp_file.write_all(data.as_bytes())?;
             tmp_file.persist(path)?;
-            Ok::<(), anyhow::Error>(())
+            anyhow::Ok(())
         })
         .await?;
 
@@ -568,7 +586,7 @@ impl Fs for RealFs {
                 temp_file_path
             };
             atomic_replace(path.as_path(), temp_file.as_path())?;
-            Ok::<(), anyhow::Error>(())
+            anyhow::Ok(())
         })
         .await?;
         Ok(())
@@ -597,7 +615,9 @@ impl Fs for RealFs {
     }
 
     async fn canonicalize(&self, path: &Path) -> Result<PathBuf> {
-        Ok(smol::fs::canonicalize(path).await?)
+        Ok(smol::fs::canonicalize(path)
+            .await
+            .with_context(|| format!("canonicalizing {path:?}"))?)
     }
 
     async fn is_file(&self, path: &Path) -> bool {
@@ -672,7 +692,7 @@ impl Fs for RealFs {
     ) -> Result<Pin<Box<dyn Send + Stream<Item = Result<PathBuf>>>>> {
         let result = smol::fs::read_dir(path).await?.map(|entry| match entry {
             Ok(entry) => Ok(entry.path()),
-            Err(error) => Err(anyhow!("failed to read dir entry {:?}", error)),
+            Err(error) => Err(anyhow!("failed to read dir entry {error:?}")),
         });
         Ok(Box::pin(result))
     }
@@ -895,6 +915,7 @@ struct FakeFsState {
     buffered_events: Vec<PathEvent>,
     metadata_call_count: usize,
     read_dir_call_count: usize,
+    path_write_counts: std::collections::HashMap<PathBuf, usize>,
     moves: std::collections::HashMap<u64, PathBuf>,
     home_dir: Option<PathBuf>,
 }
@@ -942,7 +963,7 @@ impl FakeFsState {
             .ok_or_else(|| {
                 anyhow!(io::Error::new(
                     io::ErrorKind::NotFound,
-                    format!("not found: {}", target.display())
+                    format!("not found: {target:?}")
                 ))
             })?
             .0)
@@ -1012,9 +1033,7 @@ impl FakeFsState {
         Fn: FnOnce(btree_map::Entry<String, Arc<Mutex<FakeFsEntry>>>) -> Result<T>,
     {
         let path = normalize_path(path);
-        let filename = path
-            .file_name()
-            .ok_or_else(|| anyhow!("cannot overwrite the root"))?;
+        let filename = path.file_name().context("cannot overwrite the root")?;
         let parent_path = path.parent().unwrap();
 
         let parent = self.read_path(parent_path)?;
@@ -1083,6 +1102,7 @@ impl FakeFs {
                 events_paused: false,
                 read_dir_call_count: 0,
                 metadata_call_count: 0,
+                path_write_counts: Default::default(),
                 moves: Default::default(),
                 home_dir: None,
             })),
@@ -1173,6 +1193,8 @@ impl FakeFs {
         recreate_inode: bool,
     ) -> Result<()> {
         let mut state = self.state.lock();
+        let path_buf = path.as_ref().to_path_buf();
+        *state.path_write_counts.entry(path_buf).or_insert(0) += 1;
         let new_inode = state.get_and_increment_inode();
         let new_mtime = state.get_and_increment_mtime();
         let new_len = new_content.len() as u64;
@@ -1352,7 +1374,7 @@ impl FakeFs {
                     let path = std::str::from_utf8(content)
                         .ok()
                         .and_then(|content| content.strip_prefix("gitdir:"))
-                        .ok_or_else(|| anyhow!("not a valid gitfile"))?
+                        .context("not a valid gitfile")?
                         .trim();
                     git_dir_path.insert(normalize_path(&dot_git.parent().unwrap().join(path)))
                 }
@@ -1394,7 +1416,7 @@ impl FakeFs {
 
             Ok(result)
         } else {
-            Err(anyhow!("not a valid git repository"))
+            anyhow::bail!("not a valid git repository");
         }
     }
 
@@ -1456,7 +1478,12 @@ impl FakeFs {
         .unwrap();
     }
 
-    pub fn set_head_for_repo(&self, dot_git: &Path, head_state: &[(RepoPath, String)]) {
+    pub fn set_head_for_repo(
+        &self,
+        dot_git: &Path,
+        head_state: &[(RepoPath, String)],
+        sha: impl Into<String>,
+    ) {
         self.with_git_state(dot_git, true, |state| {
             state.head_contents.clear();
             state.head_contents.extend(
@@ -1464,6 +1491,7 @@ impl FakeFs {
                     .iter()
                     .map(|(path, content)| (path.clone(), content.clone())),
             );
+            state.refs.insert("HEAD".into(), sha.into());
         })
         .unwrap();
     }
@@ -1721,6 +1749,17 @@ impl FakeFs {
         self.state.lock().metadata_call_count
     }
 
+    /// How many write operations have been issued for a specific path.
+    pub fn write_count_for_path(&self, path: impl AsRef<Path>) -> usize {
+        let path = path.as_ref().to_path_buf();
+        self.state
+            .lock()
+            .path_write_counts
+            .get(&path)
+            .copied()
+            .unwrap_or(0)
+    }
+
     fn simulate_random_delay(&self) -> impl futures::Future<Output = ()> {
         self.executor.simulate_random_delay()
     }
@@ -1744,7 +1783,7 @@ impl FakeFsEntry {
         if let Self::File { content, .. } = self {
             Ok(content)
         } else {
-            Err(anyhow!("not a file: {}", path.display()))
+            anyhow::bail!("not a file: {path:?}");
         }
     }
 
@@ -1755,7 +1794,7 @@ impl FakeFsEntry {
         if let Self::Dir { entries, .. } = self {
             Ok(entries)
         } else {
-            Err(anyhow!("not a directory: {}", path.display()))
+            anyhow::bail!("not a directory: {path:?}");
         }
     }
 }
@@ -1867,7 +1906,7 @@ impl Fs for FakeFs {
                         kind = Some(PathEventKind::Changed);
                         *e.get_mut() = file;
                     } else if !options.ignore_if_exists {
-                        return Err(anyhow!("path already exists: {}", path.display()));
+                        anyhow::bail!("path already exists: {path:?}");
                     }
                 }
                 btree_map::Entry::Vacant(e) => {
@@ -1941,7 +1980,7 @@ impl Fs for FakeFs {
             if let btree_map::Entry::Occupied(e) = e {
                 Ok(e.get().clone())
             } else {
-                Err(anyhow!("path does not exist: {}", &old_path.display()))
+                anyhow::bail!("path does not exist: {old_path:?}")
             }
         })?;
 
@@ -1959,7 +1998,7 @@ impl Fs for FakeFs {
                     if options.overwrite {
                         *e.get_mut() = moved_entry;
                     } else if !options.ignore_if_exists {
-                        return Err(anyhow!("path already exists: {}", new_path.display()));
+                        anyhow::bail!("path already exists: {new_path:?}");
                     }
                 }
                 btree_map::Entry::Vacant(e) => {
@@ -2003,7 +2042,7 @@ impl Fs for FakeFs {
                     kind = Some(PathEventKind::Changed);
                     Ok(Some(e.get().clone()))
                 } else if !options.ignore_if_exists {
-                    return Err(anyhow!("{target:?} already exists"));
+                    anyhow::bail!("{target:?} already exists");
                 } else {
                     Ok(None)
                 }
@@ -2027,10 +2066,8 @@ impl Fs for FakeFs {
         self.simulate_random_delay().await;
 
         let path = normalize_path(path);
-        let parent_path = path
-            .parent()
-            .ok_or_else(|| anyhow!("cannot remove the root"))?;
-        let base_name = path.file_name().unwrap();
+        let parent_path = path.parent().context("cannot remove the root")?;
+        let base_name = path.file_name().context("cannot remove the root")?;
 
         let mut state = self.state.lock();
         let parent_entry = state.read_path(parent_path)?;
@@ -2042,7 +2079,7 @@ impl Fs for FakeFs {
         match entry {
             btree_map::Entry::Vacant(_) => {
                 if !options.ignore_if_not_exists {
-                    return Err(anyhow!("{path:?} does not exist"));
+                    anyhow::bail!("{path:?} does not exist");
                 }
             }
             btree_map::Entry::Occupied(e) => {
@@ -2050,7 +2087,7 @@ impl Fs for FakeFs {
                     let mut entry = e.get().lock();
                     let children = entry.dir_entries(&path)?;
                     if !options.recursive && !children.is_empty() {
-                        return Err(anyhow!("{path:?} is not empty"));
+                        anyhow::bail!("{path:?} is not empty");
                     }
                 }
                 e.remove();
@@ -2064,9 +2101,7 @@ impl Fs for FakeFs {
         self.simulate_random_delay().await;
 
         let path = normalize_path(path);
-        let parent_path = path
-            .parent()
-            .ok_or_else(|| anyhow!("cannot remove the root"))?;
+        let parent_path = path.parent().context("cannot remove the root")?;
         let base_name = path.file_name().unwrap();
         let mut state = self.state.lock();
         let parent_entry = state.read_path(parent_path)?;
@@ -2077,7 +2112,7 @@ impl Fs for FakeFs {
         match entry {
             btree_map::Entry::Vacant(_) => {
                 if !options.ignore_if_not_exists {
-                    return Err(anyhow!("{path:?} does not exist"));
+                    anyhow::bail!("{path:?} does not exist");
                 }
             }
             btree_map::Entry::Occupied(e) => {
@@ -2148,11 +2183,10 @@ impl Fs for FakeFs {
         let path = normalize_path(path);
         self.simulate_random_delay().await;
         let state = self.state.lock();
-        if let Some((_, canonical_path)) = state.try_read_path(&path, true) {
-            Ok(canonical_path)
-        } else {
-            Err(anyhow!("path does not exist: {}", path.display()))
-        }
+        let (_, canonical_path) = state
+            .try_read_path(&path, true)
+            .with_context(|| format!("path does not exist: {path:?}"))?;
+        Ok(canonical_path)
     }
 
     async fn is_file(&self, path: &Path) -> bool {
@@ -2220,15 +2254,14 @@ impl Fs for FakeFs {
         self.simulate_random_delay().await;
         let path = normalize_path(path);
         let state = self.state.lock();
-        if let Some((entry, _)) = state.try_read_path(&path, false) {
-            let entry = entry.lock();
-            if let FakeFsEntry::Symlink { target } = &*entry {
-                Ok(target.clone())
-            } else {
-                Err(anyhow!("not a symlink: {}", path.display()))
-            }
+        let (entry, _) = state
+            .try_read_path(&path, false)
+            .with_context(|| format!("path does not exist: {path:?}"))?;
+        let entry = entry.lock();
+        if let FakeFsEntry::Symlink { target } = &*entry {
+            Ok(target.clone())
         } else {
-            Err(anyhow!("path does not exist: {}", path.display()))
+            anyhow::bail!("not a symlink: {path:?}")
         }
     }
 
@@ -2403,7 +2436,7 @@ pub async fn copy_recursive<'a>(
                 if options.ignore_if_exists {
                     continue;
                 } else {
-                    return Err(anyhow!("{target_item:?} already exists"));
+                    anyhow::bail!("{target_item:?} already exists");
                 }
             }
             let _ = fs
@@ -2443,7 +2476,7 @@ fn read_recursive<'a>(
         let metadata = fs
             .metadata(source)
             .await?
-            .ok_or_else(|| anyhow!("path does not exist: {}", source.display()))?;
+            .with_context(|| format!("path does not exist: {source:?}"))?;
 
         if metadata.is_dir {
             output.push((source.to_path_buf(), true));

crates/fs/src/fs_watcher.rs 🔗

@@ -23,7 +23,7 @@ impl FsWatcher {
 }
 
 impl Watcher for FsWatcher {
-    fn add(&self, path: &std::path::Path) -> gpui::Result<()> {
+    fn add(&self, path: &std::path::Path) -> anyhow::Result<()> {
         let root_path = SanitizedPath::from(path);
 
         let tx = self.tx.clone();
@@ -78,7 +78,7 @@ impl Watcher for FsWatcher {
         Ok(())
     }
 
-    fn remove(&self, path: &std::path::Path) -> gpui::Result<()> {
+    fn remove(&self, path: &std::path::Path) -> anyhow::Result<()> {
         use notify::Watcher;
         Ok(global(|w| w.watcher.lock().unwatch(path))??)
     }
@@ -130,6 +130,6 @@ pub fn global<T>(f: impl FnOnce(&GlobalWatcher) -> T) -> anyhow::Result<T> {
     });
     match result {
         Ok(g) => Ok(f(g)),
-        Err(e) => Err(anyhow::anyhow!("{}", e)),
+        Err(e) => Err(anyhow::anyhow!("{e}")),
     }
 }

crates/fs/src/mac_watcher.rs 🔗

@@ -57,7 +57,7 @@ impl Watcher for MacWatcher {
         Ok(())
     }
 
-    fn remove(&self, path: &Path) -> gpui::Result<()> {
+    fn remove(&self, path: &Path) -> anyhow::Result<()> {
         let handles = self
             .handles
             .upgrade()

crates/fuzzy/src/matcher.rs 🔗

@@ -17,6 +17,7 @@ pub struct Matcher<'a> {
     lowercase_query: &'a [char],
     query_char_bag: CharBag,
     smart_case: bool,
+    penalize_length: bool,
     min_score: f64,
     match_positions: Vec<usize>,
     last_positions: Vec<usize>,
@@ -35,6 +36,7 @@ impl<'a> Matcher<'a> {
         lowercase_query: &'a [char],
         query_char_bag: CharBag,
         smart_case: bool,
+        penalize_length: bool,
     ) -> Self {
         Self {
             query,
@@ -46,6 +48,7 @@ impl<'a> Matcher<'a> {
             score_matrix: Vec::new(),
             best_position_matrix: Vec::new(),
             smart_case,
+            penalize_length,
         }
     }
 
@@ -158,7 +161,6 @@ impl<'a> Matcher<'a> {
         if score <= 0.0 {
             return 0.0;
         }
-
         let path_len = prefix.len() + path.len();
         let mut cur_start = 0;
         let mut byte_ix = 0;
@@ -173,8 +175,17 @@ impl<'a> Matcher<'a> {
                 byte_ix += ch.len_utf8();
                 char_ix += 1;
             }
-            cur_start = match_char_ix + 1;
+
             self.match_positions[i] = byte_ix;
+
+            let matched_ch = prefix
+                .get(match_char_ix)
+                .or_else(|| path.get(match_char_ix - prefix.len()))
+                .unwrap();
+            byte_ix += matched_ch.len_utf8();
+
+            cur_start = match_char_ix + 1;
+            char_ix = match_char_ix + 1;
         }
 
         score
@@ -209,8 +220,11 @@ impl<'a> Matcher<'a> {
         let query_char = self.lowercase_query[query_idx];
         let limit = self.last_positions[query_idx];
 
+        let max_valid_index = (prefix.len() + path_lowercased.len()).saturating_sub(1);
+        let safe_limit = limit.min(max_valid_index);
+
         let mut last_slash = 0;
-        for j in path_idx..=limit {
+        for j in path_idx..=safe_limit {
             let extra_lowercase_chars_count = extra_lowercase_chars
                 .iter()
                 .take_while(|(i, _)| i < &&j)
@@ -218,10 +232,15 @@ impl<'a> Matcher<'a> {
                 .sum::<usize>();
             let j_regular = j - extra_lowercase_chars_count;
 
-            let path_char = if j_regular < prefix.len() {
+            let path_char = if j < prefix.len() {
                 lowercase_prefix[j]
             } else {
-                path_lowercased[j - prefix.len()]
+                let path_index = j - prefix.len();
+                if path_index < path_lowercased.len() {
+                    path_lowercased[path_index]
+                } else {
+                    continue;
+                }
             };
             let is_path_sep = path_char == MAIN_SEPARATOR;
 
@@ -278,7 +297,7 @@ impl<'a> Matcher<'a> {
                 let mut multiplier = char_score;
 
                 // Scale the score based on how deep within the path we found the match.
-                if query_idx == 0 {
+                if self.penalize_length && query_idx == 0 {
                     multiplier /= ((prefix.len() + path.len()) - last_slash) as f64;
                 }
 
@@ -339,18 +358,18 @@ mod tests {
     #[test]
     fn test_get_last_positions() {
         let mut query: &[char] = &['d', 'c'];
-        let mut matcher = Matcher::new(query, query, query.into(), false);
+        let mut matcher = Matcher::new(query, query, query.into(), false, true);
         let result = matcher.find_last_positions(&['a', 'b', 'c'], &['b', 'd', 'e', 'f']);
         assert!(!result);
 
         query = &['c', 'd'];
-        let mut matcher = Matcher::new(query, query, query.into(), false);
+        let mut matcher = Matcher::new(query, query, query.into(), false, true);
         let result = matcher.find_last_positions(&['a', 'b', 'c'], &['b', 'd', 'e', 'f']);
         assert!(result);
         assert_eq!(matcher.last_positions, vec![2, 4]);
 
         query = &['z', '/', 'z', 'f'];
-        let mut matcher = Matcher::new(query, query, query.into(), false);
+        let mut matcher = Matcher::new(query, query, query.into(), false, true);
         let result = matcher.find_last_positions(&['z', 'e', 'd', '/'], &['z', 'e', 'd', '/', 'f']);
         assert!(result);
         assert_eq!(matcher.last_positions, vec![0, 3, 4, 8]);
@@ -490,6 +509,89 @@ mod tests {
         );
     }
 
+    #[test]
+    fn match_unicode_path_entries() {
+        let mixed_unicode_paths = vec![
+            "İolu/oluş",
+            "İstanbul/code",
+            "Athens/Şanlıurfa",
+            "Çanakkale/scripts",
+            "paris/Düzce_İl",
+            "Berlin_Önemli_Ğündem",
+            "KİTAPLIK/london/dosya",
+            "tokyo/kyoto/fuji",
+            "new_york/san_francisco",
+        ];
+
+        assert_eq!(
+            match_single_path_query("İo/oluş", false, &mixed_unicode_paths),
+            vec![("İolu/oluş", vec![0, 2, 4, 6, 8, 10, 12])]
+        );
+
+        assert_eq!(
+            match_single_path_query("İst/code", false, &mixed_unicode_paths),
+            vec![("İstanbul/code", vec![0, 2, 4, 6, 8, 10, 12, 14])]
+        );
+
+        assert_eq!(
+            match_single_path_query("athens/şa", false, &mixed_unicode_paths),
+            vec![("Athens/Şanlıurfa", vec![0, 1, 2, 3, 4, 5, 6, 7, 9])]
+        );
+
+        assert_eq!(
+            match_single_path_query("BerlinÖĞ", false, &mixed_unicode_paths),
+            vec![("Berlin_Önemli_Ğündem", vec![0, 1, 2, 3, 4, 5, 7, 15])]
+        );
+
+        assert_eq!(
+            match_single_path_query("tokyo/fuji", false, &mixed_unicode_paths),
+            vec![("tokyo/kyoto/fuji", vec![0, 1, 2, 3, 4, 5, 12, 13, 14, 15])]
+        );
+
+        let mixed_script_paths = vec![
+            "résumé_Москва",
+            "naïve_київ_implementation",
+            "café_北京_app",
+            "東京_über_driver",
+            "déjà_vu_cairo",
+            "seoul_piñata_game",
+            "voilà_istanbul_result",
+        ];
+
+        assert_eq!(
+            match_single_path_query("résmé", false, &mixed_script_paths),
+            vec![("résumé_Москва", vec![0, 1, 3, 5, 6])]
+        );
+
+        assert_eq!(
+            match_single_path_query("café北京", false, &mixed_script_paths),
+            vec![("café_北京_app", vec![0, 1, 2, 3, 6, 9])]
+        );
+
+        assert_eq!(
+            match_single_path_query("ista", false, &mixed_script_paths),
+            vec![("voilà_istanbul_result", vec![7, 8, 9, 10])]
+        );
+
+        let complex_paths = vec![
+            "document_📚_library",
+            "project_👨‍👩‍👧‍👦_family",
+            "flags_🇯🇵🇺🇸🇪🇺_world",
+            "code_😀😃😄😁_happy",
+            "photo_👩‍👩‍👧‍👦_album",
+        ];
+
+        assert_eq!(
+            match_single_path_query("doc📚lib", false, &complex_paths),
+            vec![("document_📚_library", vec![0, 1, 2, 9, 14, 15, 16])]
+        );
+
+        assert_eq!(
+            match_single_path_query("codehappy", false, &complex_paths),
+            vec![("code_😀😃😄😁_happy", vec![0, 1, 2, 3, 22, 23, 24, 25, 26])]
+        );
+    }
+
     fn match_single_path_query<'a>(
         query: &str,
         smart_case: bool,
@@ -514,7 +616,7 @@ mod tests {
             });
         }
 
-        let mut matcher = Matcher::new(&query, &lowercase_query, query_chars, smart_case);
+        let mut matcher = Matcher::new(&query, &lowercase_query, query_chars, smart_case, true);
 
         let cancel_flag = AtomicBool::new(false);
         let mut results = Vec::new();

crates/fuzzy/src/paths.rs 🔗

@@ -95,7 +95,7 @@ pub fn match_fixed_path_set(
     let query = query.chars().collect::<Vec<_>>();
     let query_char_bag = CharBag::from(&lowercase_query[..]);
 
-    let mut matcher = Matcher::new(&query, &lowercase_query, query_char_bag, smart_case);
+    let mut matcher = Matcher::new(&query, &lowercase_query, query_char_bag, smart_case, true);
 
     let mut results = Vec::new();
     matcher.match_candidates(
@@ -153,7 +153,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
                     let segment_start = segment_idx * segment_size;
                     let segment_end = segment_start + segment_size;
                     let mut matcher =
-                        Matcher::new(query, lowercase_query, query_char_bag, smart_case);
+                        Matcher::new(query, lowercase_query, query_char_bag, smart_case, true);
 
                     let mut tree_start = 0;
                     for candidate_set in candidate_sets {

crates/fuzzy/src/strings.rs 🔗

@@ -117,6 +117,7 @@ pub async fn match_strings<T>(
     candidates: &[T],
     query: &str,
     smart_case: bool,
+    penalize_length: bool,
     max_results: usize,
     cancel_flag: &AtomicBool,
     executor: BackgroundExecutor,
@@ -160,8 +161,13 @@ where
                 scope.spawn(async move {
                     let segment_start = cmp::min(segment_idx * segment_size, candidates.len());
                     let segment_end = cmp::min(segment_start + segment_size, candidates.len());
-                    let mut matcher =
-                        Matcher::new(query, lowercase_query, query_char_bag, smart_case);
+                    let mut matcher = Matcher::new(
+                        query,
+                        lowercase_query,
+                        query_char_bag,
+                        smart_case,
+                        penalize_length,
+                    );
 
                     matcher.match_candidates(
                         &[],

crates/git/src/blame.rs 🔗

@@ -1,6 +1,6 @@
 use crate::commit::get_messages;
 use crate::{GitRemote, Oid};
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use collections::{HashMap, HashSet};
 use futures::AsyncWriteExt;
 use gpui::SharedString;
@@ -80,7 +80,7 @@ async fn run_git_blame(
         .stdout(Stdio::piped())
         .stderr(Stdio::piped())
         .spawn()
-        .map_err(|e| anyhow!("Failed to start git blame process: {}", e))?;
+        .context("starting git blame process")?;
 
     let stdin = child
         .stdin
@@ -92,10 +92,7 @@ async fn run_git_blame(
     }
     stdin.flush().await?;
 
-    let output = child
-        .output()
-        .await
-        .map_err(|e| anyhow!("Failed to read git blame output: {}", e))?;
+    let output = child.output().await.context("reading git blame output")?;
 
     if !output.status.success() {
         let stderr = String::from_utf8_lossy(&output.stderr);
@@ -103,7 +100,7 @@ async fn run_git_blame(
         if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
             return Ok(String::new());
         }
-        return Err(anyhow!("git blame process failed: {}", stderr));
+        anyhow::bail!("git blame process failed: {stderr}");
     }
 
     Ok(String::from_utf8(output.stdout)?)
@@ -144,21 +141,21 @@ impl BlameEntry {
         let sha = parts
             .next()
             .and_then(|line| line.parse::<Oid>().ok())
-            .ok_or_else(|| anyhow!("failed to parse sha"))?;
+            .context("parsing sha")?;
 
         let original_line_number = parts
             .next()
             .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse original line number"))?;
+            .context("parsing original line number")?;
         let final_line_number = parts
             .next()
             .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse final line number"))?;
+            .context("parsing final line number")?;
 
         let line_count = parts
             .next()
             .and_then(|line| line.parse::<u32>().ok())
-            .ok_or_else(|| anyhow!("Failed to parse final line number"))?;
+            .context("parsing line count")?;
 
         let start_line = final_line_number.saturating_sub(1);
         let end_line = start_line + line_count;

crates/git/src/checkpoint.gitignore 🔗

@@ -0,0 +1,91 @@
+# This lists files that we don't track in checkpoints
+
+# Compiled source and executables
+*.exe
+*.dll
+*.so
+*.dylib
+*.a
+*.lib
+*.o
+*.obj
+*.elf
+*.out
+*.app
+*.deb
+*.rpm
+*.dmg
+*.pkg
+*.msi
+
+# Archives and compressed files
+*.7z
+*.zip
+*.tar
+*.tar.gz
+*.tgz
+*.tar.bz2
+*.tbz2
+*.tar.xz
+*.txz
+*.rar
+*.jar
+*.war
+*.ear
+
+# Media files
+*.jpg
+*.jpeg
+*.png
+*.gif
+*.ico
+*.svg
+*.webp
+*.bmp
+*.tiff
+*.mp3
+*.mp4
+*.avi
+*.mov
+*.wmv
+*.flv
+*.mkv
+*.webm
+*.wav
+*.flac
+*.aac
+
+# Database files
+*.db
+*.sqlite
+*.sqlite3
+*.mdb
+
+# Documents (often binary)
+*.pdf
+*.doc
+*.docx
+*.xls
+*.xlsx
+*.ppt
+*.pptx
+
+# IDE and editor files
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+.DS_Store
+Thumbs.db
+
+# Language-specific files
+*.rlib
+*.rmeta
+*.pdb
+*.class
+*.egg
+*.egg-info/
+*.pyc
+*.pto
+__pycache__

crates/git/src/git.rs 🔗

@@ -7,11 +7,9 @@ pub mod status;
 
 pub use crate::hosting_provider::*;
 pub use crate::remote::*;
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 pub use git2 as libgit;
-use gpui::action_with_deprecated_aliases;
-use gpui::actions;
-use gpui::impl_action_with_deprecated_aliases;
+use gpui::{Action, actions};
 pub use repository::WORK_DIRECTORY_REPO_PATH;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
@@ -36,7 +34,11 @@ actions!(
         ToggleStaged,
         StageAndNext,
         UnstageAndNext,
+        #[action(deprecated_aliases = ["editor::RevertSelectedHunks"])]
+        Restore,
         // per-file
+        #[action(deprecated_aliases = ["editor::ToggleGitBlame"])]
+        Blame,
         StageFile,
         UnstageFile,
         // repo-wide
@@ -46,28 +48,28 @@ actions!(
         TrashUntrackedFiles,
         Uncommit,
         Push,
+        PushTo,
         ForcePush,
         Pull,
         Fetch,
+        FetchFrom,
         Commit,
         Amend,
         Cancel,
         ExpandCommitEditor,
         GenerateCommitMessage,
         Init,
+        OpenModifiedFiles,
     ]
 );
 
-#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)]
+#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
+#[action(namespace = git, deprecated_aliases = ["editor::RevertFile"])]
 pub struct RestoreFile {
     #[serde(default)]
     pub skip_prompt: bool,
 }
 
-impl_action_with_deprecated_aliases!(git, RestoreFile, ["editor::RevertFile"]);
-action_with_deprecated_aliases!(git, Restore, ["editor::RevertSelectedHunks"]);
-action_with_deprecated_aliases!(git, Blame, ["editor::ToggleGitBlame"]);
-
 /// The length of a Git short SHA.
 pub const SHORT_SHA_LENGTH: usize = 7;
 
@@ -99,7 +101,7 @@ impl FromStr for Oid {
 
     fn from_str(s: &str) -> std::prelude::v1::Result<Self, Self::Err> {
         libgit::Oid::from_str(s)
-            .map_err(|error| anyhow!("failed to parse git oid: {}", error))
+            .context("parsing git oid")
             .map(Self)
     }
 }

crates/git/src/hosting_provider.rs 🔗

@@ -2,7 +2,6 @@ use std::{ops::Range, sync::Arc};
 
 use anyhow::Result;
 use async_trait::async_trait;
-use collections::BTreeMap;
 use derive_more::{Deref, DerefMut};
 use gpui::{App, Global, SharedString};
 use http_client::HttpClient;
@@ -130,7 +129,8 @@ impl Global for GlobalGitHostingProviderRegistry {}
 
 #[derive(Default)]
 struct GitHostingProviderRegistryState {
-    providers: BTreeMap<String, Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
+    default_providers: Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
+    setting_providers: Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
 }
 
 #[derive(Default)]
@@ -140,6 +140,7 @@ pub struct GitHostingProviderRegistry {
 
 impl GitHostingProviderRegistry {
     /// Returns the global [`GitHostingProviderRegistry`].
+    #[track_caller]
     pub fn global(cx: &App) -> Arc<Self> {
         cx.global::<GlobalGitHostingProviderRegistry>().0.clone()
     }
@@ -168,7 +169,8 @@ impl GitHostingProviderRegistry {
     pub fn new() -> Self {
         Self {
             state: RwLock::new(GitHostingProviderRegistryState {
-                providers: BTreeMap::default(),
+                setting_providers: Vec::default(),
+                default_providers: Vec::default(),
             }),
         }
     }
@@ -177,7 +179,22 @@ impl GitHostingProviderRegistry {
     pub fn list_hosting_providers(
         &self,
     ) -> Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>> {
-        self.state.read().providers.values().cloned().collect()
+        let state = self.state.read();
+        state
+            .default_providers
+            .iter()
+            .cloned()
+            .chain(state.setting_providers.iter().cloned())
+            .collect()
+    }
+
+    pub fn set_setting_providers(
+        &self,
+        providers: impl IntoIterator<Item = Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
+    ) {
+        let mut state = self.state.write();
+        state.setting_providers.clear();
+        state.setting_providers.extend(providers);
     }
 
     /// Adds the provided [`GitHostingProvider`] to the registry.
@@ -185,10 +202,7 @@ impl GitHostingProviderRegistry {
         &self,
         provider: Arc<dyn GitHostingProvider + Send + Sync + 'static>,
     ) {
-        self.state
-            .write()
-            .providers
-            .insert(provider.name(), provider);
+        self.state.write().default_providers.push(provider);
     }
 }
 

crates/git/src/repository.rs 🔗

@@ -26,8 +26,8 @@ use std::{
 };
 use sum_tree::MapSeekTarget;
 use thiserror::Error;
-use util::ResultExt;
 use util::command::{new_smol_command, new_std_command};
+use util::{ResultExt, paths};
 use uuid::Uuid;
 
 pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
@@ -78,6 +78,10 @@ pub struct Upstream {
 }
 
 impl Upstream {
+    pub fn is_remote(&self) -> bool {
+        self.remote_name().is_some()
+    }
+
     pub fn remote_name(&self) -> Option<&str> {
         self.ref_name
             .strip_prefix("refs/remotes/")
@@ -189,33 +193,137 @@ pub enum ResetMode {
     Mixed,
 }
 
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub enum FetchOptions {
+    All,
+    Remote(Remote),
+}
+
+impl FetchOptions {
+    pub fn to_proto(&self) -> Option<String> {
+        match self {
+            FetchOptions::All => None,
+            FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
+        }
+    }
+
+    pub fn from_proto(remote_name: Option<String>) -> Self {
+        match remote_name {
+            Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
+            None => FetchOptions::All,
+        }
+    }
+
+    pub fn name(&self) -> SharedString {
+        match self {
+            Self::All => "Fetch all remotes".into(),
+            Self::Remote(remote) => remote.name.clone(),
+        }
+    }
+}
+
+impl std::fmt::Display for FetchOptions {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            FetchOptions::All => write!(f, "--all"),
+            FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
+        }
+    }
+}
+
+/// Modifies .git/info/exclude temporarily
+pub struct GitExcludeOverride {
+    git_exclude_path: PathBuf,
+    original_excludes: Option<String>,
+    added_excludes: Option<String>,
+}
+
+impl GitExcludeOverride {
+    pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
+        let original_excludes = smol::fs::read_to_string(&git_exclude_path).await.ok();
+
+        Ok(GitExcludeOverride {
+            git_exclude_path,
+            original_excludes,
+            added_excludes: None,
+        })
+    }
+
+    pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
+        self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
+            format!("{already_added}\n{excludes}")
+        } else {
+            excludes.to_string()
+        });
+
+        let mut content = self.original_excludes.clone().unwrap_or_default();
+        content.push_str("\n\n#  ====== Auto-added by Zed: =======\n");
+        content.push_str(self.added_excludes.as_ref().unwrap());
+        content.push('\n');
+
+        smol::fs::write(&self.git_exclude_path, content).await?;
+        Ok(())
+    }
+
+    pub async fn restore_original(&mut self) -> Result<()> {
+        if let Some(ref original) = self.original_excludes {
+            smol::fs::write(&self.git_exclude_path, original).await?;
+        } else {
+            if self.git_exclude_path.exists() {
+                smol::fs::remove_file(&self.git_exclude_path).await?;
+            }
+        }
+
+        self.added_excludes = None;
+
+        Ok(())
+    }
+}
+
+impl Drop for GitExcludeOverride {
+    fn drop(&mut self) {
+        if self.added_excludes.is_some() {
+            let git_exclude_path = self.git_exclude_path.clone();
+            let original_excludes = self.original_excludes.clone();
+            smol::spawn(async move {
+                if let Some(original) = original_excludes {
+                    smol::fs::write(&git_exclude_path, original).await
+                } else {
+                    smol::fs::remove_file(&git_exclude_path).await
+                }
+            })
+            .detach();
+        }
+    }
+}
+
 pub trait GitRepository: Send + Sync {
     fn reload_index(&self);
 
     /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
     ///
     /// Also returns `None` for symlinks.
-    fn load_index_text(&self, path: RepoPath) -> BoxFuture<Option<String>>;
+    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
 
     /// Returns the contents of an entry in the repository's HEAD, or None if HEAD does not exist or has no entry for the given path.
     ///
     /// Also returns `None` for symlinks.
-    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<Option<String>>;
+    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
 
     fn set_index_text(
         &self,
         path: RepoPath,
         content: Option<String>,
         env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<anyhow::Result<()>>;
+    ) -> BoxFuture<'_, anyhow::Result<()>>;
 
     /// Returns the URL of the remote with the given name.
     fn remote_url(&self, name: &str) -> Option<String>;
 
     /// Resolve a list of refs to SHAs.
-    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<Result<Vec<Option<String>>>>;
+    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
 
-    fn head_sha(&self) -> BoxFuture<Option<String>> {
+    fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
         async move {
             self.revparse_batch(vec!["HEAD".into()])
                 .await
@@ -227,33 +335,33 @@ pub trait GitRepository: Send + Sync {
         .boxed()
     }
 
-    fn merge_message(&self) -> BoxFuture<Option<String>>;
+    fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
 
-    fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<Result<GitStatus>>;
+    fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<'_, Result<GitStatus>>;
 
-    fn branches(&self) -> BoxFuture<Result<Vec<Branch>>>;
+    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
 
-    fn change_branch(&self, name: String) -> BoxFuture<Result<()>>;
-    fn create_branch(&self, name: String) -> BoxFuture<Result<()>>;
+    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
+    fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
 
     fn reset(
         &self,
         commit: String,
         mode: ResetMode,
         env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>>;
+    ) -> BoxFuture<'_, Result<()>>;
 
     fn checkout_files(
         &self,
         commit: String,
         paths: Vec<RepoPath>,
         env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>>;
+    ) -> BoxFuture<'_, Result<()>>;
 
-    fn show(&self, commit: String) -> BoxFuture<Result<CommitDetails>>;
+    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
 
-    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<Result<CommitDiff>>;
-    fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<Result<crate::blame::Blame>>;
+    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
+    fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>>;
 
     /// Returns the absolute path to the repository. For worktrees, this will be the path to the
     /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
@@ -268,7 +376,7 @@ pub trait GitRepository: Send + Sync {
         &self,
         paths: Vec<RepoPath>,
         env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>>;
+    ) -> BoxFuture<'_, Result<()>>;
     /// Updates the index to match HEAD at the given paths.
     ///
     /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
@@ -276,7 +384,7 @@ pub trait GitRepository: Send + Sync {
         &self,
         paths: Vec<RepoPath>,
         env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>>;
+    ) -> BoxFuture<'_, Result<()>>;
 
     fn commit(
         &self,
@@ -284,7 +392,7 @@ pub trait GitRepository: Send + Sync {
         name_and_email: Option<(SharedString, SharedString)>,
         options: CommitOptions,
         env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>>;
+    ) -> BoxFuture<'_, Result<()>>;
 
     fn push(
         &self,
@@ -296,7 +404,7 @@ pub trait GitRepository: Send + Sync {
         // This method takes an AsyncApp to ensure it's invoked on the main thread,
         // otherwise git-credentials-manager won't work.
         cx: AsyncApp,
-    ) -> BoxFuture<Result<RemoteCommandOutput>>;
+    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 
     fn pull(
         &self,
@@ -307,44 +415,45 @@ pub trait GitRepository: Send + Sync {
         // This method takes an AsyncApp to ensure it's invoked on the main thread,
         // otherwise git-credentials-manager won't work.
         cx: AsyncApp,
-    ) -> BoxFuture<Result<RemoteCommandOutput>>;
+    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 
     fn fetch(
         &self,
+        fetch_options: FetchOptions,
         askpass: AskPassDelegate,
         env: Arc<HashMap<String, String>>,
         // This method takes an AsyncApp to ensure it's invoked on the main thread,
         // otherwise git-credentials-manager won't work.
         cx: AsyncApp,
-    ) -> BoxFuture<Result<RemoteCommandOutput>>;
+    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 
-    fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<Result<Vec<Remote>>>;
+    fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>>;
 
     /// returns a list of remote branches that contain HEAD
-    fn check_for_pushed_commit(&self) -> BoxFuture<Result<Vec<SharedString>>>;
+    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
 
     /// Run git diff
-    fn diff(&self, diff: DiffType) -> BoxFuture<Result<String>>;
+    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
 
     /// Creates a checkpoint for the repository.
     fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
 
     /// Resets to a previously-created checkpoint.
-    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<Result<()>>;
+    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
 
     /// Compares two checkpoints, returning true if they are equal
     fn compare_checkpoints(
         &self,
         left: GitRepositoryCheckpoint,
         right: GitRepositoryCheckpoint,
-    ) -> BoxFuture<Result<bool>>;
+    ) -> BoxFuture<'_, Result<bool>>;
 
     /// Computes a diff between two checkpoints.
     fn diff_checkpoints(
         &self,
         base_checkpoint: GitRepositoryCheckpoint,
         target_checkpoint: GitRepositoryCheckpoint,
-    ) -> BoxFuture<Result<String>>;
+    ) -> BoxFuture<'_, Result<String>>;
 }
 
 pub enum DiffType {
@@ -399,6 +508,50 @@ pub struct GitRepositoryCheckpoint {
     pub commit_sha: Oid,
 }
 
+#[derive(Debug)]
+pub struct GitCommitter {
+    pub name: Option<String>,
+    pub email: Option<String>,
+}
+
+pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
+    if cfg!(any(feature = "test-support", test)) {
+        return GitCommitter {
+            name: None,
+            email: None,
+        };
+    }
+
+    let git_binary_path =
+        if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
+            cx.update(|cx| {
+                cx.path_for_auxiliary_executable("git")
+                    .context("could not find git binary path")
+                    .log_err()
+            })
+            .ok()
+            .flatten()
+        } else {
+            None
+        };
+
+    let git = GitBinary::new(
+        git_binary_path.unwrap_or(PathBuf::from("git")),
+        paths::home_dir().clone(),
+        cx.background_executor().clone(),
+    );
+
+    cx.background_spawn(async move {
+        let name = git.run(["config", "--global", "user.name"]).await.log_err();
+        let email = git
+            .run(["config", "--global", "user.email"])
+            .await
+            .log_err();
+        GitCommitter { name, email }
+    })
+    .await
+}
+
 impl GitRepository for RealGitRepository {
     fn reload_index(&self) {
         if let Ok(mut index) = self.repository.lock().index() {
@@ -416,7 +569,7 @@ impl GitRepository for RealGitRepository {
         repo.commondir().into()
     }
 
-    fn show(&self, commit: String) -> BoxFuture<Result<CommitDetails>> {
+    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
         let working_directory = self.working_directory();
         self.executor
             .spawn(async move {
@@ -452,7 +605,7 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
-    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<Result<CommitDiff>> {
+    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
         let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
         else {
             return future::ready(Err(anyhow!("no working directory"))).boxed();
@@ -473,7 +626,7 @@ impl GitRepository for RealGitRepository {
                 .stdout(Stdio::piped())
                 .stderr(Stdio::piped())
                 .output()
-                .map_err(|e| anyhow!("Failed to start git show process: {e}"))?;
+                .context("starting git show process")?;
 
             let show_stdout = String::from_utf8_lossy(&show_output.stdout);
             let mut lines = show_stdout.split('\n');
@@ -487,7 +640,7 @@ impl GitRepository for RealGitRepository {
                 .stdout(Stdio::piped())
                 .stderr(Stdio::piped())
                 .spawn()
-                .map_err(|e| anyhow!("Failed to start git cat-file process: {e}"))?;
+                .context("starting git cat-file process")?;
 
             use std::io::Write as _;
             let mut files = Vec::<CommitFile>::new();
@@ -559,7 +712,7 @@ impl GitRepository for RealGitRepository {
         commit: String,
         mode: ResetMode,
         env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>> {
+    ) -> BoxFuture<'_, Result<()>> {
         async move {
             let working_directory = self.working_directory();
 
@@ -574,12 +727,11 @@ impl GitRepository for RealGitRepository {
                 .args(["reset", mode_flag, &commit])
                 .output()
                 .await?;
-            if !output.status.success() {
-                return Err(anyhow!(
-                    "Failed to reset:\n{}",
-                    String::from_utf8_lossy(&output.stderr)
-                ));
-            }
+            anyhow::ensure!(
+                output.status.success(),
+                "Failed to reset:\n{}",
+                String::from_utf8_lossy(&output.stderr),
+            );
             Ok(())
         }
         .boxed()
@@ -590,7 +742,7 @@ impl GitRepository for RealGitRepository {
         commit: String,
         paths: Vec<RepoPath>,
         env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>> {
+    ) -> BoxFuture<'_, Result<()>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();
         async move {
@@ -605,18 +757,17 @@ impl GitRepository for RealGitRepository {
                 .args(paths.iter().map(|path| path.as_ref()))
                 .output()
                 .await?;
-            if !output.status.success() {
-                return Err(anyhow!(
-                    "Failed to checkout files:\n{}",
-                    String::from_utf8_lossy(&output.stderr)
-                ));
-            }
+            anyhow::ensure!(
+                output.status.success(),
+                "Failed to checkout files:\n{}",
+                String::from_utf8_lossy(&output.stderr),
+            );
             Ok(())
         }
         .boxed()
     }
 
-    fn load_index_text(&self, path: RepoPath) -> BoxFuture<Option<String>> {
+    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
         // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
         const GIT_MODE_SYMLINK: u32 = 0o120000;
 
@@ -649,7 +800,7 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
-    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<Option<String>> {
+    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
         let repo = self.repository.clone();
         self.executor
             .spawn(async move {
@@ -670,7 +821,7 @@ impl GitRepository for RealGitRepository {
         path: RepoPath,
         content: Option<String>,
         env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<anyhow::Result<()>> {
+    ) -> BoxFuture<'_, anyhow::Result<()>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();
         self.executor
@@ -703,12 +854,11 @@ impl GitRepository for RealGitRepository {
                         .output()
                         .await?;
 
-                    if !output.status.success() {
-                        return Err(anyhow!(
-                            "Failed to stage:\n{}",
-                            String::from_utf8_lossy(&output.stderr)
-                        ));
-                    }
+                    anyhow::ensure!(
+                        output.status.success(),
+                        "Failed to stage:\n{}",
+                        String::from_utf8_lossy(&output.stderr)
+                    );
                 } else {
                     let output = new_smol_command(&git_binary_path)
                         .current_dir(&working_directory)
@@ -717,13 +867,11 @@ impl GitRepository for RealGitRepository {
                         .arg(path.to_unix_style())
                         .output()
                         .await?;
-
-                    if !output.status.success() {
-                        return Err(anyhow!(
-                            "Failed to unstage:\n{}",
-                            String::from_utf8_lossy(&output.stderr)
-                        ));
-                    }
+                    anyhow::ensure!(
+                        output.status.success(),
+                        "Failed to unstage:\n{}",
+                        String::from_utf8_lossy(&output.stderr)
+                    );
                 }
 
                 Ok(())
@@ -737,7 +885,7 @@ impl GitRepository for RealGitRepository {
         remote.url().map(|url| url.to_string())
     }
 
-    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<Result<Vec<Option<String>>>> {
+    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
         let working_directory = self.working_directory();
         self.executor
             .spawn(async move {
@@ -748,7 +896,6 @@ impl GitRepository for RealGitRepository {
                         "--no-optional-locks",
                         "cat-file",
                         "--batch-check=%(objectname)",
-                        "-z",
                     ])
                     .stdin(Stdio::piped())
                     .stdout(Stdio::piped())
@@ -758,10 +905,10 @@ impl GitRepository for RealGitRepository {
                 let stdin = process
                     .stdin
                     .take()
-                    .ok_or_else(|| anyhow!("no stdin for git cat-file subprocess"))?;
+                    .context("no stdin for git cat-file subprocess")?;
                 let mut stdin = BufWriter::new(stdin);
                 for rev in &revs {
-                    write!(&mut stdin, "{rev}\0")?;
+                    write!(&mut stdin, "{rev}\n")?;
                 }
                 drop(stdin);
 
@@ -788,14 +935,14 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
-    fn merge_message(&self) -> BoxFuture<Option<String>> {
+    fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
         let path = self.path().join("MERGE_MSG");
         self.executor
             .spawn(async move { std::fs::read_to_string(&path).ok() })
             .boxed()
     }
 
-    fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<Result<GitStatus>> {
+    fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<'_, Result<GitStatus>> {
         let git_binary_path = self.git_binary_path.clone();
         let working_directory = self.working_directory();
         let path_prefixes = path_prefixes.to_owned();
@@ -810,13 +957,13 @@ impl GitRepository for RealGitRepository {
                     stdout.parse()
                 } else {
                     let stderr = String::from_utf8_lossy(&output.stderr);
-                    Err(anyhow!("git status failed: {}", stderr))
+                    anyhow::bail!("git status failed: {stderr}");
                 }
             })
             .boxed()
     }
 
-    fn branches(&self) -> BoxFuture<Result<Vec<Branch>>> {
+    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();
         self.executor
@@ -846,12 +993,11 @@ impl GitRepository for RealGitRepository {
                     .output()
                     .await?;
 
-                if !output.status.success() {
-                    return Err(anyhow!(
-                        "Failed to git git branches:\n{}",
-                        String::from_utf8_lossy(&output.stderr)
-                    ));
-                }
+                anyhow::ensure!(
+                    output.status.success(),
+                    "Failed to git git branches:\n{}",
+                    String::from_utf8_lossy(&output.stderr)
+                );
 
                 let input = String::from_utf8_lossy(&output.stdout);
 
@@ -884,39 +1030,46 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
-    fn change_branch(&self, name: String) -> BoxFuture<Result<()>> {
+    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
         let repo = self.repository.clone();
+        let working_directory = self.working_directory();
+        let git_binary_path = self.git_binary_path.clone();
+        let executor = self.executor.clone();
+        let branch = self.executor.spawn(async move {
+            let repo = repo.lock();
+            let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
+                branch
+            } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
+                let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
+                let revision = revision.get();
+                let branch_commit = revision.peel_to_commit()?;
+                let mut branch = repo.branch(&branch_name, &branch_commit, false)?;
+                branch.set_upstream(Some(&name))?;
+                branch
+            } else {
+                anyhow::bail!("Branch not found");
+            };
+
+            Ok(branch
+                .name()?
+                .context("cannot checkout anonymous branch")?
+                .to_string())
+        });
+
         self.executor
             .spawn(async move {
-                let repo = repo.lock();
-                let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
-                    branch
-                } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
-                    let (_, branch_name) =
-                        name.split_once("/").context("Unexpected branch format")?;
-                    let revision = revision.get();
-                    let branch_commit = revision.peel_to_commit()?;
-                    let mut branch = repo.branch(&branch_name, &branch_commit, false)?;
-                    branch.set_upstream(Some(&name))?;
-                    branch
-                } else {
-                    return Err(anyhow!("Branch not found"));
-                };
+                let branch = branch.await?;
 
-                let revision = branch.get();
-                let as_tree = revision.peel_to_tree()?;
-                repo.checkout_tree(as_tree.as_object(), None)?;
-                repo.set_head(
-                    revision
-                        .name()
-                        .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
-                )?;
-                Ok(())
+                GitBinary::new(git_binary_path, working_directory?, executor)
+                    .run(&["checkout", &branch])
+                    .await?;
+
+                anyhow::Ok(())
             })
             .boxed()
     }
 
-    fn create_branch(&self, name: String) -> BoxFuture<Result<()>> {
+    fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
         let repo = self.repository.clone();
         self.executor
             .spawn(async move {
@@ -928,7 +1081,7 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
-    fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<Result<crate::blame::Blame>> {
+    fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();
 
@@ -950,7 +1103,7 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
-    fn diff(&self, diff: DiffType) -> BoxFuture<Result<String>> {
+    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();
         self.executor
@@ -967,12 +1120,11 @@ impl GitRepository for RealGitRepository {
                     .output()
                     .await?;
 
-                if !output.status.success() {
-                    return Err(anyhow!(
-                        "Failed to run git diff:\n{}",
-                        String::from_utf8_lossy(&output.stderr)
-                    ));
-                }
+                anyhow::ensure!(
+                    output.status.success(),
+                    "Failed to run git diff:\n{}",
+                    String::from_utf8_lossy(&output.stderr)
+                );
                 Ok(String::from_utf8_lossy(&output.stdout).to_string())
             })
             .boxed()
@@ -982,7 +1134,7 @@ impl GitRepository for RealGitRepository {
         &self,
         paths: Vec<RepoPath>,
         env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>> {
+    ) -> BoxFuture<'_, Result<()>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();
         self.executor
@@ -995,13 +1147,11 @@ impl GitRepository for RealGitRepository {
                         .args(paths.iter().map(|p| p.to_unix_style()))
                         .output()
                         .await?;
-
-                    if !output.status.success() {
-                        return Err(anyhow!(
-                            "Failed to stage paths:\n{}",
-                            String::from_utf8_lossy(&output.stderr)
-                        ));
-                    }
+                    anyhow::ensure!(
+                        output.status.success(),
+                        "Failed to stage paths:\n{}",
+                        String::from_utf8_lossy(&output.stderr),
+                    );
                 }
                 Ok(())
             })
@@ -1012,7 +1162,7 @@ impl GitRepository for RealGitRepository {
         &self,
         paths: Vec<RepoPath>,
         env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>> {
+    ) -> BoxFuture<'_, Result<()>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();
 
@@ -1027,12 +1177,11 @@ impl GitRepository for RealGitRepository {
                         .output()
                         .await?;
 
-                    if !output.status.success() {
-                        return Err(anyhow!(
-                            "Failed to unstage:\n{}",
-                            String::from_utf8_lossy(&output.stderr)
-                        ));
-                    }
+                    anyhow::ensure!(
+                        output.status.success(),
+                        "Failed to unstage:\n{}",
+                        String::from_utf8_lossy(&output.stderr),
+                    );
                 }
                 Ok(())
             })
@@ -1045,7 +1194,7 @@ impl GitRepository for RealGitRepository {
         name_and_email: Option<(SharedString, SharedString)>,
         options: CommitOptions,
         env: Arc<HashMap<String, String>>,
-    ) -> BoxFuture<Result<()>> {
+    ) -> BoxFuture<'_, Result<()>> {
         let working_directory = self.working_directory();
         self.executor
             .spawn(async move {
@@ -1066,12 +1215,11 @@ impl GitRepository for RealGitRepository {
 
                 let output = cmd.output().await?;
 
-                if !output.status.success() {
-                    return Err(anyhow!(
-                        "Failed to commit:\n{}",
-                        String::from_utf8_lossy(&output.stderr)
-                    ));
-                }
+                anyhow::ensure!(
+                    output.status.success(),
+                    "Failed to commit:\n{}",
+                    String::from_utf8_lossy(&output.stderr)
+                );
                 Ok(())
             })
             .boxed()
@@ -1085,7 +1233,7 @@ impl GitRepository for RealGitRepository {
         ask_pass: AskPassDelegate,
         env: Arc<HashMap<String, String>>,
         cx: AsyncApp,
-    ) -> BoxFuture<Result<RemoteCommandOutput>> {
+    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
         let working_directory = self.working_directory();
         let executor = cx.background_executor().clone();
         async move {
@@ -1117,7 +1265,7 @@ impl GitRepository for RealGitRepository {
         ask_pass: AskPassDelegate,
         env: Arc<HashMap<String, String>>,
         cx: AsyncApp,
-    ) -> BoxFuture<Result<RemoteCommandOutput>> {
+    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
         let working_directory = self.working_directory();
         let executor = cx.background_executor().clone();
         async move {
@@ -1138,18 +1286,20 @@ impl GitRepository for RealGitRepository {
 
     fn fetch(
         &self,
+        fetch_options: FetchOptions,
         ask_pass: AskPassDelegate,
         env: Arc<HashMap<String, String>>,
         cx: AsyncApp,
-    ) -> BoxFuture<Result<RemoteCommandOutput>> {
+    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
         let working_directory = self.working_directory();
+        let remote_name = format!("{}", fetch_options);
         let executor = cx.background_executor().clone();
         async move {
             let mut command = new_smol_command("git");
             command
                 .envs(env.iter())
                 .current_dir(&working_directory?)
-                .args(["fetch", "--all"])
+                .args(["fetch", &remote_name])
                 .stdout(smol::process::Stdio::piped())
                 .stderr(smol::process::Stdio::piped());
 
@@ -1158,7 +1308,7 @@ impl GitRepository for RealGitRepository {
         .boxed()
     }
 
-    fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<Result<Vec<Remote>>> {
+    fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();
         self.executor
@@ -1187,27 +1337,24 @@ impl GitRepository for RealGitRepository {
                     .output()
                     .await?;
 
-                if output.status.success() {
-                    let remote_names = String::from_utf8_lossy(&output.stdout)
-                        .split('\n')
-                        .filter(|name| !name.is_empty())
-                        .map(|name| Remote {
-                            name: name.trim().to_string().into(),
-                        })
-                        .collect();
-
-                    return Ok(remote_names);
-                } else {
-                    return Err(anyhow!(
-                        "Failed to get remotes:\n{}",
-                        String::from_utf8_lossy(&output.stderr)
-                    ));
-                }
+                anyhow::ensure!(
+                    output.status.success(),
+                    "Failed to get remotes:\n{}",
+                    String::from_utf8_lossy(&output.stderr)
+                );
+                let remote_names = String::from_utf8_lossy(&output.stdout)
+                    .split('\n')
+                    .filter(|name| !name.is_empty())
+                    .map(|name| Remote {
+                        name: name.trim().to_string().into(),
+                    })
+                    .collect();
+                Ok(remote_names)
             })
             .boxed()
     }
 
-    fn check_for_pushed_commit(&self) -> BoxFuture<Result<Vec<SharedString>>> {
+    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();
         self.executor
@@ -1219,11 +1366,11 @@ impl GitRepository for RealGitRepository {
                         .args(args)
                         .output()
                         .await?;
-                    if output.status.success() {
-                        Ok(String::from_utf8(output.stdout)?)
-                    } else {
-                        Err(anyhow!(String::from_utf8_lossy(&output.stderr).to_string()))
-                    }
+                    anyhow::ensure!(
+                        output.status.success(),
+                        String::from_utf8_lossy(&output.stderr).to_string()
+                    );
+                    Ok(String::from_utf8(output.stdout)?)
                 };
 
                 let head = git_cmd(&["rev-parse", "HEAD"])
@@ -1274,10 +1421,12 @@ impl GitRepository for RealGitRepository {
         self.executor
             .spawn(async move {
                 let working_directory = working_directory?;
-                let mut git = GitBinary::new(git_binary_path, working_directory, executor)
+                let mut git = GitBinary::new(git_binary_path, working_directory.clone(), executor)
                     .envs(checkpoint_author_envs());
                 git.with_temp_index(async |git| {
                     let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
+                    let mut excludes = exclude_files(git).await?;
+
                     git.run(&["add", "--all"]).await?;
                     let tree = git.run(&["write-tree"]).await?;
                     let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
@@ -1287,6 +1436,8 @@ impl GitRepository for RealGitRepository {
                         git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
                     };
 
+                    excludes.restore_original().await?;
+
                     Ok(GitRepositoryCheckpoint {
                         commit_sha: checkpoint_sha.parse()?,
                     })
@@ -1296,7 +1447,7 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
-    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<Result<()>> {
+    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();
 
@@ -1305,7 +1456,7 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 let working_directory = working_directory?;
 
-                let mut git = GitBinary::new(git_binary_path, working_directory, executor);
+                let git = GitBinary::new(git_binary_path, working_directory, executor);
                 git.run(&[
                     "restore",
                     "--source",
@@ -1315,12 +1466,16 @@ impl GitRepository for RealGitRepository {
                 ])
                 .await?;
 
-                git.with_temp_index(async move |git| {
-                    git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
-                        .await?;
-                    git.run(&["clean", "-d", "--force"]).await
-                })
-                .await?;
+                // TODO: We don't track binary and large files anymore,
+                //       so the following call would delete them.
+                //       Implement an alternative way to track files added by agent.
+                //
+                // git.with_temp_index(async move |git| {
+                //     git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
+                //         .await?;
+                //     git.run(&["clean", "-d", "--force"]).await
+                // })
+                // .await?;
 
                 Ok(())
             })
@@ -1331,7 +1486,7 @@ impl GitRepository for RealGitRepository {
         &self,
         left: GitRepositoryCheckpoint,
         right: GitRepositoryCheckpoint,
-    ) -> BoxFuture<Result<bool>> {
+    ) -> BoxFuture<'_, Result<bool>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();
 
@@ -1370,7 +1525,7 @@ impl GitRepository for RealGitRepository {
         &self,
         base_checkpoint: GitRepositoryCheckpoint,
         target_checkpoint: GitRepositoryCheckpoint,
-    ) -> BoxFuture<Result<String>> {
+    ) -> BoxFuture<'_, Result<String>> {
         let working_directory = self.working_directory();
         let git_binary_path = self.git_binary_path.clone();
 
@@ -1411,6 +1566,44 @@ fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
     args
 }
 
+/// Temporarily git-ignore commonly ignored files and files over 2MB
+async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
+    const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
+    let mut excludes = git.with_exclude_overrides().await?;
+    excludes
+        .add_excludes(include_str!("./checkpoint.gitignore"))
+        .await?;
+
+    let working_directory = git.working_directory.clone();
+    let untracked_files = git.list_untracked_files().await?;
+    let excluded_paths = untracked_files.into_iter().map(|path| {
+        let working_directory = working_directory.clone();
+        smol::spawn(async move {
+            let full_path = working_directory.join(path.clone());
+            match smol::fs::metadata(&full_path).await {
+                Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
+                    Some(PathBuf::from("/").join(path.clone()))
+                }
+                _ => None,
+            }
+        })
+    });
+
+    let excluded_paths = futures::future::join_all(excluded_paths).await;
+    let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
+
+    if !excluded_paths.is_empty() {
+        let exclude_patterns = excluded_paths
+            .into_iter()
+            .map(|path| path.to_string_lossy().to_string())
+            .collect::<Vec<_>>()
+            .join("\n");
+        excludes.add_excludes(&exclude_patterns).await?;
+    }
+
+    Ok(excludes)
+}
+
 struct GitBinary {
     git_binary_path: PathBuf,
     working_directory: PathBuf,

crates/git/src/status.rs 🔗

@@ -1,5 +1,5 @@
 use crate::repository::RepoPath;
-use anyhow::{Result, anyhow};
+use anyhow::Result;
 use serde::{Deserialize, Serialize};
 use std::{path::Path, str::FromStr, sync::Arc};
 use util::ResultExt;
@@ -241,7 +241,7 @@ impl StatusCode {
             b'R' => Ok(StatusCode::Renamed),
             b'C' => Ok(StatusCode::Copied),
             b' ' => Ok(StatusCode::Unmodified),
-            _ => Err(anyhow!("Invalid status code: {byte}")),
+            _ => anyhow::bail!("Invalid status code: {byte}"),
         }
     }
 
@@ -286,7 +286,7 @@ impl UnmergedStatusCode {
             b'A' => Ok(UnmergedStatusCode::Added),
             b'D' => Ok(UnmergedStatusCode::Deleted),
             b'U' => Ok(UnmergedStatusCode::Updated),
-            _ => Err(anyhow!("Invalid unmerged status code: {byte}")),
+            _ => anyhow::bail!("Invalid unmerged status code: {byte}"),
         }
     }
 }

crates/git_hosting_providers/src/git_hosting_providers.rs 🔗

@@ -3,7 +3,8 @@ mod settings;
 
 use std::sync::Arc;
 
-use anyhow::{Result, anyhow};
+use anyhow::Context as _;
+use anyhow::Result;
 use git::GitHostingProviderRegistry;
 use git::repository::GitRepository;
 use gpui::App;
@@ -58,7 +59,7 @@ pub fn get_host_from_git_remote_url(remote_url: &str) -> Result<String> {
             .ok()
             .and_then(|remote_url| remote_url.host_str().map(|host| host.to_string()))
     })
-    .ok_or_else(|| anyhow!("URL has no host"))
+    .context("URL has no host")
 }
 
 #[cfg(test)]

crates/git_hosting_providers/src/providers/github.rs 🔗

@@ -1,7 +1,7 @@
 use std::str::FromStr;
 use std::sync::{Arc, LazyLock};
 
-use anyhow::{Context, Result, bail};
+use anyhow::{Context as _, Result, bail};
 use async_trait::async_trait;
 use futures::AsyncReadExt;
 use gpui::SharedString;

crates/git_hosting_providers/src/settings.rs 🔗

@@ -25,22 +25,34 @@ fn init_git_hosting_provider_settings(cx: &mut App) {
 }
 
 fn update_git_hosting_providers_from_settings(cx: &mut App) {
+    let settings_store = cx.global::<SettingsStore>();
     let settings = GitHostingProviderSettings::get_global(cx);
     let provider_registry = GitHostingProviderRegistry::global(cx);
 
-    for provider in settings.git_hosting_providers.iter() {
-        let Some(url) = Url::parse(&provider.base_url).log_err() else {
-            continue;
-        };
-
-        let provider = match provider.provider {
-            GitHostingProviderKind::Bitbucket => Arc::new(Bitbucket::new(&provider.name, url)) as _,
-            GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _,
-            GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _,
-        };
-
-        provider_registry.register_hosting_provider(provider);
-    }
+    let local_values: Vec<GitHostingProviderConfig> = settings_store
+        .get_all_locals::<GitHostingProviderSettings>()
+        .into_iter()
+        .flat_map(|(_, _, providers)| providers.git_hosting_providers.clone())
+        .collect();
+
+    let iter = settings
+        .git_hosting_providers
+        .clone()
+        .into_iter()
+        .chain(local_values)
+        .filter_map(|provider| {
+            let url = Url::parse(&provider.base_url).log_err()?;
+
+            Some(match provider.provider {
+                GitHostingProviderKind::Bitbucket => {
+                    Arc::new(Bitbucket::new(&provider.name, url)) as _
+                }
+                GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _,
+                GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _,
+            })
+        });
+
+    provider_registry.set_setting_providers(iter);
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -66,7 +78,7 @@ pub struct GitHostingProviderConfig {
     pub name: String,
 }
 
-#[derive(Default, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
 pub struct GitHostingProviderSettings {
     /// The list of custom Git hosting providers.
     #[serde(default)]

crates/git_ui/Cargo.toml 🔗

@@ -17,10 +17,11 @@ default = []
 test-support = ["multi_buffer/test-support"]
 
 [dependencies]
+agent_settings.workspace = true
 anyhow.workspace = true
 askpass.workspace = true
-assistant_settings.workspace = true
 buffer_diff.workspace = true
+call.workspace = true
 chrono.workspace = true
 collections.workspace = true
 command_palette_hooks.workspace = true
@@ -35,7 +36,6 @@ itertools.workspace = true
 language.workspace = true
 language_model.workspace = true
 linkify.workspace = true
-linkme.workspace = true
 log.workspace = true
 markdown.workspace = true
 menu.workspace = true
@@ -57,16 +57,17 @@ time.workspace = true
 time_format.workspace = true
 ui.workspace = true
 util.workspace = true
+watch.workspace = true
+workspace-hack.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
-workspace-hack.workspace = true
+zed_llm_client.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 windows.workspace = true
 
 [dev-dependencies]
 ctor.workspace = true
-env_logger.workspace = true
 editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 pretty_assertions.workspace = true
@@ -74,3 +75,4 @@ project = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
 unindent.workspace = true
 workspace = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/git_ui/src/branch_picker.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Context as _, anyhow};
+use anyhow::Context as _;
 use fuzzy::StringMatchCandidate;
 
 use collections::HashSet;
@@ -98,15 +98,18 @@ impl BranchList {
 
             let all_branches = cx
                 .background_spawn(async move {
-                    let upstreams: HashSet<_> = all_branches
+                    let remote_upstreams: HashSet<_> = all_branches
                         .iter()
                         .filter_map(|branch| {
-                            let upstream = branch.upstream.as_ref()?;
-                            Some(upstream.ref_name.clone())
+                            branch
+                                .upstream
+                                .as_ref()
+                                .filter(|upstream| upstream.is_remote())
+                                .map(|upstream| upstream.ref_name.clone())
                         })
                         .collect();
 
-                    all_branches.retain(|branch| !upstreams.contains(&branch.ref_name));
+                    all_branches.retain(|branch| !remote_upstreams.contains(&branch.ref_name));
 
                     all_branches.sort_by_key(|branch| {
                         branch
@@ -218,6 +221,7 @@ impl BranchListDelegate {
         let Some(repo) = self.repo.clone() else {
             return;
         };
+        let new_branch_name = new_branch_name.to_string().replace(' ', "-");
         cx.spawn(async move |_, cx| {
             repo.update(cx, |repo, _| {
                 repo.create_branch(new_branch_name.to_string())
@@ -241,7 +245,7 @@ impl PickerDelegate for BranchListDelegate {
     type ListItem = ListItem;
 
     fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
-        "Select branch...".into()
+        "Select branch…".into()
     }
 
     fn editor_position(&self) -> PickerEditorPosition {
@@ -301,13 +305,13 @@ impl PickerDelegate for BranchListDelegate {
                     &candidates,
                     &query,
                     true,
+                    true,
                     10000,
                     &Default::default(),
                     cx.background_executor().clone(),
                 )
                 .await
-                .iter()
-                .cloned()
+                .into_iter()
                 .map(|candidate| BranchEntry {
                     branch: all_branches[candidate.candidate_id].clone(),
                     positions: candidate.positions,
@@ -323,6 +327,7 @@ impl PickerDelegate for BranchListDelegate {
                             .first()
                             .is_some_and(|entry| entry.branch.name() == query)
                     {
+                        let query = query.replace(' ', "-");
                         matches.push(BranchEntry {
                             branch: Branch {
                                 ref_name: format!("refs/heads/{query}").into(),
@@ -358,7 +363,7 @@ impl PickerDelegate for BranchListDelegate {
         }
 
         let current_branch = self.repo.as_ref().map(|repo| {
-            repo.update(cx, |repo, _| {
+            repo.read_with(cx, |repo, _| {
                 repo.branch.as_ref().map(|branch| branch.ref_name.clone())
             })
         });
@@ -379,7 +384,7 @@ impl PickerDelegate for BranchListDelegate {
                         .delegate
                         .repo
                         .as_ref()
-                        .ok_or_else(|| anyhow!("No active repository"))?
+                        .context("No active repository")?
                         .clone();
 
                     let mut cx = cx.to_async();
@@ -408,10 +413,6 @@ impl PickerDelegate for BranchListDelegate {
         cx.emit(DismissEvent);
     }
 
-    fn render_header(&self, _: &mut Window, _cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
-        None
-    }
-
     fn render_match(
         &self,
         ix: usize,
@@ -438,44 +439,43 @@ impl PickerDelegate for BranchListDelegate {
             })
             .unwrap_or_else(|| (None, None));
 
+        let branch_name = if entry.is_new {
+            h_flex()
+                .gap_1()
+                .child(
+                    Icon::new(IconName::Plus)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
+                .child(
+                    Label::new(format!("Create branch \"{}\"…", entry.branch.name()))
+                        .single_line()
+                        .truncate(),
+                )
+                .into_any_element()
+        } else {
+            HighlightedLabel::new(entry.branch.name().to_owned(), entry.positions.clone())
+                .truncate()
+                .into_any_element()
+        };
+
         Some(
             ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
                 .inset(true)
-                .spacing(match self.style {
-                    BranchListStyle::Modal => ListItemSpacing::default(),
-                    BranchListStyle::Popover => ListItemSpacing::ExtraDense,
-                })
                 .spacing(ListItemSpacing::Sparse)
                 .toggle_state(selected)
                 .child(
                     v_flex()
                         .w_full()
+                        .overflow_hidden()
                         .child(
                             h_flex()
-                                .w_full()
-                                .flex_shrink()
-                                .overflow_x_hidden()
-                                .gap_2()
+                                .gap_6()
                                 .justify_between()
-                                .child(div().flex_shrink().overflow_x_hidden().child(
-                                    if entry.is_new {
-                                        Label::new(format!(
-                                            "Create branch \"{}\"…",
-                                            entry.branch.name()
-                                        ))
-                                        .single_line()
-                                        .into_any_element()
-                                    } else {
-                                        HighlightedLabel::new(
-                                            entry.branch.name().to_owned(),
-                                            entry.positions.clone(),
-                                        )
-                                        .truncate()
-                                        .into_any_element()
-                                    },
-                                ))
-                                .when_some(commit_time, |el, commit_time| {
-                                    el.child(
+                                .overflow_x_hidden()
+                                .child(branch_name)
+                                .when_some(commit_time, |label, commit_time| {
+                                    label.child(
                                         Label::new(commit_time)
                                             .size(LabelSize::Small)
                                             .color(Color::Muted)

crates/git_ui/src/commit_modal.rs 🔗

@@ -148,6 +148,7 @@ impl CommitModal {
                 }
             }
             git_panel.set_modal_open(true, cx);
+            git_panel.load_local_committer(cx);
         });
 
         let dock = workspace.dock_at_position(git_panel.position(window, cx));

crates/git_ui/src/commit_view.rs 🔗

@@ -1,6 +1,6 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use buffer_diff::{BufferDiff, BufferDiffSnapshot};
-use editor::{Editor, EditorEvent, MultiBuffer};
+use editor::{Editor, EditorEvent, MultiBuffer, SelectionEffects};
 use git::repository::{CommitDetails, CommitDiff, CommitSummary, RepoPath};
 use gpui::{
     AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter,
@@ -154,7 +154,7 @@ impl CommitView {
             });
             editor.update(cx, |editor, cx| {
                 editor.disable_header_for_buffer(metadata_buffer_id.unwrap(), cx);
-                editor.change_selections(None, window, cx, |selections| {
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
                     selections.select_ranges(vec![0..0]);
                 });
             });
@@ -172,7 +172,7 @@ impl CommitView {
                             .map(|path| path.worktree_id)
                             .or(first_worktree_id)
                     })?
-                    .ok_or_else(|| anyhow!("project has no worktrees"))?;
+                    .context("project has no worktrees")?;
                 let file = Arc::new(GitBlob {
                     path: file.path.clone(),
                     is_deleted,

crates/git_ui/src/conflict_view.rs 🔗

@@ -5,16 +5,17 @@ use editor::{
     display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
 };
 use gpui::{
-    App, Context, Entity, InteractiveElement as _, ParentElement as _, Subscription, WeakEntity,
+    App, Context, Entity, InteractiveElement as _, ParentElement as _, Subscription, Task,
+    WeakEntity,
 };
 use language::{Anchor, Buffer, BufferId};
-use project::{ConflictRegion, ConflictSet, ConflictSetUpdate};
+use project::{ConflictRegion, ConflictSet, ConflictSetUpdate, ProjectItem as _};
 use std::{ops::Range, sync::Arc};
 use ui::{
     ActiveTheme, AnyElement, Element as _, StatefulInteractiveElement, Styled,
-    StyledTypography as _, div, h_flex, rems,
+    StyledTypography as _, Window, div, h_flex, rems,
 };
-use util::{debug_panic, maybe};
+use util::{ResultExt as _, debug_panic, maybe};
 
 pub(crate) struct ConflictAddon {
     buffers: HashMap<BufferId, BufferConflicts>,
@@ -247,6 +248,8 @@ fn conflicts_updated(
             removed_block_ids.insert(block_id);
         }
 
+        editor.remove_gutter_highlights::<ConflictsOuter>(removed_highlighted_ranges.clone(), cx);
+
         editor.remove_highlighted_rows::<ConflictsOuter>(removed_highlighted_ranges.clone(), cx);
         editor.remove_highlighted_rows::<ConflictsOurs>(removed_highlighted_ranges.clone(), cx);
         editor
@@ -324,8 +327,7 @@ fn update_conflict_highlighting(
     cx: &mut Context<Editor>,
 ) {
     log::debug!("update conflict highlighting for {conflict:?}");
-    let theme = cx.theme().clone();
-    let colors = theme.colors();
+
     let outer_start = buffer
         .anchor_in_excerpt(excerpt_id, conflict.range.start)
         .unwrap();
@@ -345,26 +347,29 @@ fn update_conflict_highlighting(
         .anchor_in_excerpt(excerpt_id, conflict.theirs.end)
         .unwrap();
 
-    let ours_background = colors.version_control_conflict_ours_background;
-    let ours_marker = colors.version_control_conflict_ours_marker_background;
-    let theirs_background = colors.version_control_conflict_theirs_background;
-    let theirs_marker = colors.version_control_conflict_theirs_marker_background;
-    let divider_background = colors.version_control_conflict_divider_background;
+    let ours_background = cx.theme().colors().version_control_conflict_marker_ours;
+    let theirs_background = cx.theme().colors().version_control_conflict_marker_theirs;
 
     let options = RowHighlightOptions {
-        include_gutter: false,
+        include_gutter: true,
         ..Default::default()
     };
 
+    editor.insert_gutter_highlight::<ConflictsOuter>(
+        outer_start..their_end,
+        |cx| cx.theme().colors().editor_background,
+        cx,
+    );
+
     // Prevent diff hunk highlighting within the entire conflict region.
-    editor.highlight_rows::<ConflictsOuter>(
-        outer_start..outer_end,
-        divider_background,
+    editor.highlight_rows::<ConflictsOuter>(outer_start..outer_end, theirs_background, options, cx);
+    editor.highlight_rows::<ConflictsOurs>(our_start..our_end, ours_background, options, cx);
+    editor.highlight_rows::<ConflictsOursMarker>(
+        outer_start..our_start,
+        ours_background,
         options,
         cx,
     );
-    editor.highlight_rows::<ConflictsOurs>(our_start..our_end, ours_background, options, cx);
-    editor.highlight_rows::<ConflictsOursMarker>(outer_start..our_start, ours_marker, options, cx);
     editor.highlight_rows::<ConflictsTheirs>(
         their_start..their_end,
         theirs_background,
@@ -373,7 +378,7 @@ fn update_conflict_highlighting(
     );
     editor.highlight_rows::<ConflictsTheirsMarker>(
         their_end..outer_end,
-        theirs_marker,
+        theirs_background,
         options,
         cx,
     );
@@ -404,8 +409,16 @@ fn render_conflict_buttons(
                     let editor = editor.clone();
                     let conflict = conflict.clone();
                     let ours = conflict.ours.clone();
-                    move |_, _, cx| {
-                        resolve_conflict(editor.clone(), excerpt_id, &conflict, &[ours.clone()], cx)
+                    move |_, window, cx| {
+                        resolve_conflict(
+                            editor.clone(),
+                            excerpt_id,
+                            conflict.clone(),
+                            vec![ours.clone()],
+                            window,
+                            cx,
+                        )
+                        .detach()
                     }
                 }),
         )
@@ -422,14 +435,16 @@ fn render_conflict_buttons(
                     let editor = editor.clone();
                     let conflict = conflict.clone();
                     let theirs = conflict.theirs.clone();
-                    move |_, _, cx| {
+                    move |_, window, cx| {
                         resolve_conflict(
                             editor.clone(),
                             excerpt_id,
-                            &conflict,
-                            &[theirs.clone()],
+                            conflict.clone(),
+                            vec![theirs.clone()],
+                            window,
                             cx,
                         )
+                        .detach()
                     }
                 }),
         )
@@ -447,69 +462,104 @@ fn render_conflict_buttons(
                     let conflict = conflict.clone();
                     let ours = conflict.ours.clone();
                     let theirs = conflict.theirs.clone();
-                    move |_, _, cx| {
+                    move |_, window, cx| {
                         resolve_conflict(
                             editor.clone(),
                             excerpt_id,
-                            &conflict,
-                            &[ours.clone(), theirs.clone()],
+                            conflict.clone(),
+                            vec![ours.clone(), theirs.clone()],
+                            window,
                             cx,
                         )
+                        .detach()
                     }
                 }),
         )
         .into_any()
 }
 
-fn resolve_conflict(
+pub(crate) fn resolve_conflict(
     editor: WeakEntity<Editor>,
     excerpt_id: ExcerptId,
-    resolved_conflict: &ConflictRegion,
-    ranges: &[Range<Anchor>],
+    resolved_conflict: ConflictRegion,
+    ranges: Vec<Range<Anchor>>,
+    window: &mut Window,
     cx: &mut App,
-) {
-    let Some(editor) = editor.upgrade() else {
-        return;
-    };
-
-    let multibuffer = editor.read(cx).buffer().read(cx);
-    let snapshot = multibuffer.snapshot(cx);
-    let Some(buffer) = resolved_conflict
-        .ours
-        .end
-        .buffer_id
-        .and_then(|buffer_id| multibuffer.buffer(buffer_id))
-    else {
-        return;
-    };
-    let buffer_snapshot = buffer.read(cx).snapshot();
-
-    resolved_conflict.resolve(buffer, ranges, cx);
-
-    editor.update(cx, |editor, cx| {
-        let conflict_addon = editor.addon_mut::<ConflictAddon>().unwrap();
-        let Some(state) = conflict_addon.buffers.get_mut(&buffer_snapshot.remote_id()) else {
+) -> Task<()> {
+    window.spawn(cx, async move |cx| {
+        let Some((workspace, project, multibuffer, buffer)) = editor
+            .update(cx, |editor, cx| {
+                let workspace = editor.workspace()?;
+                let project = editor.project.clone()?;
+                let multibuffer = editor.buffer().clone();
+                let buffer_id = resolved_conflict.ours.end.buffer_id?;
+                let buffer = multibuffer.read(cx).buffer(buffer_id)?;
+                resolved_conflict.resolve(buffer.clone(), &ranges, cx);
+                let conflict_addon = editor.addon_mut::<ConflictAddon>().unwrap();
+                let snapshot = multibuffer.read(cx).snapshot(cx);
+                let buffer_snapshot = buffer.read(cx).snapshot();
+                let state = conflict_addon
+                    .buffers
+                    .get_mut(&buffer_snapshot.remote_id())?;
+                let ix = state
+                    .block_ids
+                    .binary_search_by(|(range, _)| {
+                        range
+                            .start
+                            .cmp(&resolved_conflict.range.start, &buffer_snapshot)
+                    })
+                    .ok()?;
+                let &(_, block_id) = &state.block_ids[ix];
+                let start = snapshot
+                    .anchor_in_excerpt(excerpt_id, resolved_conflict.range.start)
+                    .unwrap();
+                let end = snapshot
+                    .anchor_in_excerpt(excerpt_id, resolved_conflict.range.end)
+                    .unwrap();
+
+                editor.remove_gutter_highlights::<ConflictsOuter>(vec![start..end], cx);
+
+                editor.remove_highlighted_rows::<ConflictsOuter>(vec![start..end], cx);
+                editor.remove_highlighted_rows::<ConflictsOurs>(vec![start..end], cx);
+                editor.remove_highlighted_rows::<ConflictsTheirs>(vec![start..end], cx);
+                editor.remove_highlighted_rows::<ConflictsOursMarker>(vec![start..end], cx);
+                editor.remove_highlighted_rows::<ConflictsTheirsMarker>(vec![start..end], cx);
+                editor.remove_blocks(HashSet::from_iter([block_id]), None, cx);
+                Some((workspace, project, multibuffer, buffer))
+            })
+            .ok()
+            .flatten()
+        else {
             return;
         };
-        let Ok(ix) = state.block_ids.binary_search_by(|(range, _)| {
-            range
-                .start
-                .cmp(&resolved_conflict.range.start, &buffer_snapshot)
-        }) else {
+        let Some(save) = project
+            .update(cx, |project, cx| {
+                if multibuffer.read(cx).all_diff_hunks_expanded() {
+                    project.save_buffer(buffer.clone(), cx)
+                } else {
+                    Task::ready(Ok(()))
+                }
+            })
+            .ok()
+        else {
             return;
         };
-        let &(_, block_id) = &state.block_ids[ix];
-        let start = snapshot
-            .anchor_in_excerpt(excerpt_id, resolved_conflict.range.start)
-            .unwrap();
-        let end = snapshot
-            .anchor_in_excerpt(excerpt_id, resolved_conflict.range.end)
-            .unwrap();
-        editor.remove_highlighted_rows::<ConflictsOuter>(vec![start..end], cx);
-        editor.remove_highlighted_rows::<ConflictsOurs>(vec![start..end], cx);
-        editor.remove_highlighted_rows::<ConflictsTheirs>(vec![start..end], cx);
-        editor.remove_highlighted_rows::<ConflictsOursMarker>(vec![start..end], cx);
-        editor.remove_highlighted_rows::<ConflictsTheirsMarker>(vec![start..end], cx);
-        editor.remove_blocks(HashSet::from_iter([block_id]), None, cx);
+        if save.await.log_err().is_none() {
+            let open_path = maybe!({
+                let path = buffer
+                    .read_with(cx, |buffer, cx| buffer.project_path(cx))
+                    .ok()
+                    .flatten()?;
+                workspace
+                    .update_in(cx, |workspace, window, cx| {
+                        workspace.open_path_preview(path, None, false, false, false, window, cx)
+                    })
+                    .ok()
+            });
+
+            if let Some(open_path) = open_path {
+                open_path.await.log_err();
+            }
+        }
     })
 }

crates/git_ui/src/diff_view.rs 🔗

@@ -0,0 +1,581 @@
+//! DiffView provides a UI for displaying differences between two buffers.
+
+use anyhow::Result;
+use buffer_diff::{BufferDiff, BufferDiffSnapshot};
+use editor::{Editor, EditorEvent, MultiBuffer};
+use futures::{FutureExt, select_biased};
+use gpui::{
+    AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter,
+    FocusHandle, Focusable, IntoElement, Render, Task, Window,
+};
+use language::Buffer;
+use project::Project;
+use std::{
+    any::{Any, TypeId},
+    path::PathBuf,
+    pin::pin,
+    sync::Arc,
+    time::Duration,
+};
+use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
+use util::paths::PathExt as _;
+use workspace::{
+    Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
+    item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
+    searchable::SearchableItemHandle,
+};
+
+pub struct DiffView {
+    editor: Entity<Editor>,
+    old_buffer: Entity<Buffer>,
+    new_buffer: Entity<Buffer>,
+    buffer_changes_tx: watch::Sender<()>,
+    _recalculate_diff_task: Task<Result<()>>,
+}
+
+const RECALCULATE_DIFF_DEBOUNCE: Duration = Duration::from_millis(250);
+
+impl DiffView {
+    pub fn open(
+        old_path: PathBuf,
+        new_path: PathBuf,
+        workspace: &Workspace,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task<Result<Entity<Self>>> {
+        let workspace = workspace.weak_handle();
+        window.spawn(cx, async move |cx| {
+            let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
+            let old_buffer = project
+                .update(cx, |project, cx| project.open_local_buffer(&old_path, cx))?
+                .await?;
+            let new_buffer = project
+                .update(cx, |project, cx| project.open_local_buffer(&new_path, cx))?
+                .await?;
+
+            let buffer_diff = build_buffer_diff(&old_buffer, &new_buffer, cx).await?;
+
+            workspace.update_in(cx, |workspace, window, cx| {
+                let diff_view = cx.new(|cx| {
+                    DiffView::new(
+                        old_buffer,
+                        new_buffer,
+                        buffer_diff,
+                        project.clone(),
+                        window,
+                        cx,
+                    )
+                });
+
+                let pane = workspace.active_pane();
+                pane.update(cx, |pane, cx| {
+                    pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
+                });
+
+                diff_view
+            })
+        })
+    }
+
+    pub fn new(
+        old_buffer: Entity<Buffer>,
+        new_buffer: Entity<Buffer>,
+        diff: Entity<BufferDiff>,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let multibuffer = cx.new(|cx| {
+            let mut multibuffer = MultiBuffer::singleton(new_buffer.clone(), cx);
+            multibuffer.add_diff(diff.clone(), cx);
+            multibuffer
+        });
+        let editor = cx.new(|cx| {
+            let mut editor =
+                Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
+            editor.start_temporary_diff_override();
+            editor.disable_diagnostics(cx);
+            editor.set_expand_all_diff_hunks(cx);
+            editor.set_render_diff_hunk_controls(
+                Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
+                cx,
+            );
+            editor
+        });
+
+        let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
+
+        for buffer in [&old_buffer, &new_buffer] {
+            cx.subscribe(buffer, move |this, _, event, _| match event {
+                language::BufferEvent::Edited
+                | language::BufferEvent::LanguageChanged
+                | language::BufferEvent::Reparsed => {
+                    this.buffer_changes_tx.send(()).ok();
+                }
+                _ => {}
+            })
+            .detach();
+        }
+
+        Self {
+            editor,
+            buffer_changes_tx,
+            old_buffer,
+            new_buffer,
+            _recalculate_diff_task: cx.spawn(async move |this, cx| {
+                while let Ok(_) = buffer_changes_rx.recv().await {
+                    loop {
+                        let mut timer = cx
+                            .background_executor()
+                            .timer(RECALCULATE_DIFF_DEBOUNCE)
+                            .fuse();
+                        let mut recv = pin!(buffer_changes_rx.recv().fuse());
+                        select_biased! {
+                            _ = timer => break,
+                            _ = recv => continue,
+                        }
+                    }
+
+                    log::trace!("start recalculating");
+                    let (old_snapshot, new_snapshot) = this.update(cx, |this, cx| {
+                        (
+                            this.old_buffer.read(cx).snapshot(),
+                            this.new_buffer.read(cx).snapshot(),
+                        )
+                    })?;
+                    let diff_snapshot = cx
+                        .update(|cx| {
+                            BufferDiffSnapshot::new_with_base_buffer(
+                                new_snapshot.text.clone(),
+                                Some(old_snapshot.text().into()),
+                                old_snapshot,
+                                cx,
+                            )
+                        })?
+                        .await;
+                    diff.update(cx, |diff, cx| {
+                        diff.set_snapshot(diff_snapshot, &new_snapshot, cx)
+                    })?;
+                    log::trace!("finish recalculating");
+                }
+                Ok(())
+            }),
+        }
+    }
+}
+
+async fn build_buffer_diff(
+    old_buffer: &Entity<Buffer>,
+    new_buffer: &Entity<Buffer>,
+    cx: &mut AsyncApp,
+) -> Result<Entity<BufferDiff>> {
+    let old_buffer_snapshot = old_buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
+    let new_buffer_snapshot = new_buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
+
+    let diff_snapshot = cx
+        .update(|cx| {
+            BufferDiffSnapshot::new_with_base_buffer(
+                new_buffer_snapshot.text.clone(),
+                Some(old_buffer_snapshot.text().into()),
+                old_buffer_snapshot,
+                cx,
+            )
+        })?
+        .await;
+
+    cx.new(|cx| {
+        let mut diff = BufferDiff::new(&new_buffer_snapshot.text, cx);
+        diff.set_snapshot(diff_snapshot, &new_buffer_snapshot.text, cx);
+        diff
+    })
+}
+
+impl EventEmitter<EditorEvent> for DiffView {}
+
+impl Focusable for DiffView {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.editor.focus_handle(cx)
+    }
+}
+
+impl Item for DiffView {
+    type Event = EditorEvent;
+
+    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
+        Some(Icon::new(IconName::Diff).color(Color::Muted))
+    }
+
+    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
+        Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
+            .color(if params.selected {
+                Color::Default
+            } else {
+                Color::Muted
+            })
+            .into_any_element()
+    }
+
+    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
+        let old_filename = self
+            .old_buffer
+            .read(cx)
+            .file()
+            .and_then(|file| {
+                Some(
+                    file.full_path(cx)
+                        .file_name()?
+                        .to_string_lossy()
+                        .to_string(),
+                )
+            })
+            .unwrap_or_else(|| "untitled".into());
+        let new_filename = self
+            .new_buffer
+            .read(cx)
+            .file()
+            .and_then(|file| {
+                Some(
+                    file.full_path(cx)
+                        .file_name()?
+                        .to_string_lossy()
+                        .to_string(),
+                )
+            })
+            .unwrap_or_else(|| "untitled".into());
+        format!("{old_filename} ↔ {new_filename}").into()
+    }
+
+    fn tab_tooltip_text(&self, cx: &App) -> Option<ui::SharedString> {
+        let old_path = self
+            .old_buffer
+            .read(cx)
+            .file()
+            .map(|file| file.full_path(cx).compact().to_string_lossy().to_string())
+            .unwrap_or_else(|| "untitled".into());
+        let new_path = self
+            .new_buffer
+            .read(cx)
+            .file()
+            .map(|file| file.full_path(cx).compact().to_string_lossy().to_string())
+            .unwrap_or_else(|| "untitled".into());
+        Some(format!("{old_path} ↔ {new_path}").into())
+    }
+
+    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
+        Editor::to_item_events(event, f)
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        Some("Diff View Opened")
+    }
+
+    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.editor
+            .update(cx, |editor, cx| editor.deactivated(window, cx));
+    }
+
+    fn is_singleton(&self, _: &App) -> bool {
+        false
+    }
+
+    fn act_as_type<'a>(
+        &'a self,
+        type_id: TypeId,
+        self_handle: &'a Entity<Self>,
+        _: &'a App,
+    ) -> Option<AnyView> {
+        if type_id == TypeId::of::<Self>() {
+            Some(self_handle.to_any())
+        } else if type_id == TypeId::of::<Editor>() {
+            Some(self.editor.to_any())
+        } else {
+            None
+        }
+    }
+
+    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+        Some(Box::new(self.editor.clone()))
+    }
+
+    fn for_each_project_item(
+        &self,
+        cx: &App,
+        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
+    ) {
+        self.editor.for_each_project_item(cx, f)
+    }
+
+    fn set_nav_history(
+        &mut self,
+        nav_history: ItemNavHistory,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.editor.update(cx, |editor, _| {
+            editor.set_nav_history(Some(nav_history));
+        });
+    }
+
+    fn navigate(
+        &mut self,
+        data: Box<dyn Any>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> bool {
+        self.editor
+            .update(cx, |editor, cx| editor.navigate(data, window, cx))
+    }
+
+    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
+        ToolbarItemLocation::PrimaryLeft
+    }
+
+    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
+        self.editor.breadcrumbs(theme, cx)
+    }
+
+    fn added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.editor.update(cx, |editor, cx| {
+            editor.added_to_workspace(workspace, window, cx)
+        });
+    }
+
+    fn can_save(&self, cx: &App) -> bool {
+        // The editor handles the new buffer, so delegate to it
+        self.editor.read(cx).can_save(cx)
+    }
+
+    fn save(
+        &mut self,
+        options: SaveOptions,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        // Delegate saving to the editor, which manages the new buffer
+        self.editor
+            .update(cx, |editor, cx| editor.save(options, project, window, cx))
+    }
+}
+
+impl Render for DiffView {
+    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+        self.editor.clone()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use editor::test::editor_test_context::assert_state_with_diff;
+    use gpui::TestAppContext;
+    use project::{FakeFs, Fs, Project};
+    use settings::{Settings, SettingsStore};
+    use std::path::PathBuf;
+    use unindent::unindent;
+    use util::path;
+    use workspace::Workspace;
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            language::init(cx);
+            Project::init_settings(cx);
+            workspace::init_settings(cx);
+            editor::init_settings(cx);
+            theme::ThemeSettings::register(cx)
+        });
+    }
+
+    #[gpui::test]
+    async fn test_diff_view(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/test"),
+            serde_json::json!({
+                "old_file.txt": "old line 1\nline 2\nold line 3\nline 4\n",
+                "new_file.txt": "new line 1\nline 2\nnew line 3\nline 4\n"
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
+
+        let (workspace, mut cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+        let diff_view = workspace
+            .update_in(cx, |workspace, window, cx| {
+                DiffView::open(
+                    PathBuf::from(path!("/test/old_file.txt")),
+                    PathBuf::from(path!("/test/new_file.txt")),
+                    workspace,
+                    window,
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+
+        // Verify initial diff
+        assert_state_with_diff(
+            &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
+            &mut cx,
+            &unindent(
+                "
+                - old line 1
+                + ˇnew line 1
+                  line 2
+                - old line 3
+                + new line 3
+                  line 4
+                ",
+            ),
+        );
+
+        // Modify the new file on disk
+        fs.save(
+            path!("/test/new_file.txt").as_ref(),
+            &unindent(
+                "
+                new line 1
+                line 2
+                new line 3
+                line 4
+                new line 5
+                ",
+            )
+            .into(),
+            Default::default(),
+        )
+        .await
+        .unwrap();
+
+        // The diff now reflects the changes to the new file
+        cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
+        assert_state_with_diff(
+            &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
+            &mut cx,
+            &unindent(
+                "
+                - old line 1
+                + ˇnew line 1
+                  line 2
+                - old line 3
+                + new line 3
+                  line 4
+                + new line 5
+                ",
+            ),
+        );
+
+        // Modify the old file on disk
+        fs.save(
+            path!("/test/old_file.txt").as_ref(),
+            &unindent(
+                "
+                new line 1
+                line 2
+                old line 3
+                line 4
+                ",
+            )
+            .into(),
+            Default::default(),
+        )
+        .await
+        .unwrap();
+
+        // The diff now reflects the changes to the new file
+        cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
+        assert_state_with_diff(
+            &diff_view.read_with(cx, |diff_view, _| diff_view.editor.clone()),
+            &mut cx,
+            &unindent(
+                "
+                  ˇnew line 1
+                  line 2
+                - old line 3
+                + new line 3
+                  line 4
+                + new line 5
+                ",
+            ),
+        );
+    }
+
+    #[gpui::test]
+    async fn test_save_changes_in_diff_view(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/test"),
+            serde_json::json!({
+                "old_file.txt": "old line 1\nline 2\nold line 3\nline 4\n",
+                "new_file.txt": "new line 1\nline 2\nnew line 3\nline 4\n"
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
+
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+        let diff_view = workspace
+            .update_in(cx, |workspace, window, cx| {
+                DiffView::open(
+                    PathBuf::from(path!("/test/old_file.txt")),
+                    PathBuf::from(path!("/test/new_file.txt")),
+                    workspace,
+                    window,
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+
+        diff_view.update_in(cx, |diff_view, window, cx| {
+            diff_view.editor.update(cx, |editor, cx| {
+                editor.insert("modified ", window, cx);
+            });
+        });
+
+        diff_view.update_in(cx, |diff_view, _, cx| {
+            let buffer = diff_view.new_buffer.read(cx);
+            assert!(buffer.is_dirty(), "Buffer should be dirty after edits");
+        });
+
+        let save_task = diff_view.update_in(cx, |diff_view, window, cx| {
+            workspace::Item::save(
+                diff_view,
+                workspace::item::SaveOptions::default(),
+                project.clone(),
+                window,
+                cx,
+            )
+        });
+
+        save_task.await.expect("Save should succeed");
+
+        let saved_content = fs.load(path!("/test/new_file.txt").as_ref()).await.unwrap();
+        assert_eq!(
+            saved_content,
+            "modified new line 1\nline 2\nnew line 3\nline 4\n"
+        );
+
+        diff_view.update_in(cx, |diff_view, _, cx| {
+            let buffer = diff_view.new_buffer.read(cx);
+            assert!(!buffer.is_dirty(), "Buffer should not be dirty after save");
+        });
+    }
+}

crates/git_ui/src/git_panel.rs 🔗

@@ -9,11 +9,10 @@ use crate::{branch_picker, picker_prompt, render_remote_button};
 use crate::{
     git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
 };
-use anyhow::Result;
+use agent_settings::AgentSettings;
+use anyhow::Context as _;
 use askpass::AskPassDelegate;
-use assistant_settings::AssistantSettings;
 use db::kvp::KEY_VALUE_STORE;
-
 use editor::{
     Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar,
     scroll::ScrollbarAutoHide,
@@ -21,18 +20,20 @@ use editor::{
 use futures::StreamExt as _;
 use git::blame::ParsedCommitMessage;
 use git::repository::{
-    Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, PushOptions, Remote,
-    RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus,
+    Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
+    PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
+    UpstreamTrackingStatus, get_git_committer,
 };
 use git::status::StageStatus;
 use git::{Amend, ToggleStaged, repository::RepoPath, status::FileStatus};
 use git::{ExpandCommitEditor, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
 use gpui::{
-    Action, Animation, AnimationExt as _, Axis, ClickEvent, Corner, DismissEvent, Entity,
-    EventEmitter, FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior,
-    ListSizingBehavior, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, Point,
-    PromptLevel, ScrollStrategy, Subscription, Task, Transformation, UniformListScrollHandle,
-    WeakEntity, actions, anchored, deferred, percentage, uniform_list,
+    Action, Animation, AnimationExt as _, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner,
+    DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext,
+    ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent,
+    MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, Subscription, Task,
+    Transformation, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, percentage,
+    uniform_list,
 };
 use itertools::Itertools;
 use language::{Buffer, File};
@@ -42,6 +43,7 @@ use language_model::{
 };
 use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
 use multi_buffer::ExcerptInfo;
+use notifications::status_toast::{StatusToast, ToastIcon};
 use panel::{
     PanelHeader, panel_button, panel_editor_container, panel_editor_style, panel_filled_button,
     panel_icon_button,
@@ -54,6 +56,7 @@ use project::{
 use serde::{Deserialize, Serialize};
 use settings::{Settings as _, SettingsStore};
 use std::future::Future;
+use std::ops::Range;
 use std::path::{Path, PathBuf};
 use std::{collections::HashSet, sync::Arc, time::Duration, usize};
 use strum::{IntoEnumIterator, VariantNames};
@@ -63,14 +66,13 @@ use ui::{
     Tooltip, prelude::*,
 };
 use util::{ResultExt, TryFutureExt, maybe};
-use workspace::AppState;
 
-use notifications::status_toast::{StatusToast, ToastIcon};
 use workspace::{
     Workspace,
     dock::{DockPosition, Panel, PanelEvent},
-    notifications::DetachAndPromptErr,
+    notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId},
 };
+use zed_llm_client::CompletionIntent;
 
 actions!(
     git_panel,
@@ -81,7 +83,6 @@ actions!(
         FocusEditor,
         FocusChanges,
         ToggleFillCoAuthors,
-        GenerateCommitMessage
     ]
 );
 
@@ -198,7 +199,9 @@ impl GitHeaderEntry {
         let this = &self.header;
         let status = status_entry.status;
         match this {
-            Section::Conflict => repo.has_conflict(&status_entry.repo_path),
+            Section::Conflict => {
+                repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path)
+            }
             Section::Tracked => !status.is_created(),
             Section::New => status.is_created(),
         }
@@ -355,6 +358,8 @@ pub struct GitPanel {
     context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
     modal_open: bool,
     show_placeholders: bool,
+    local_committer: Option<GitCommitter>,
+    local_committer_task: Option<Task<()>>,
     _settings_subscription: Subscription,
 }
 
@@ -371,7 +376,10 @@ pub(crate) fn commit_message_editor(
     let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
     let max_lines = if in_panel { MAX_PANEL_EDITOR_LINES } else { 18 };
     let mut commit_editor = Editor::new(
-        EditorMode::AutoHeight { max_lines },
+        EditorMode::AutoHeight {
+            min_lines: 1,
+            max_lines: Some(max_lines),
+        },
         buffer,
         None,
         window,
@@ -382,151 +390,156 @@ pub(crate) fn commit_message_editor(
     commit_editor.set_show_gutter(false, cx);
     commit_editor.set_show_wrap_guides(false, cx);
     commit_editor.set_show_indent_guides(false, cx);
-    commit_editor.set_hard_wrap(Some(72), cx);
     let placeholder = placeholder.unwrap_or("Enter commit message".into());
     commit_editor.set_placeholder_text(placeholder, cx);
     commit_editor
 }
 
 impl GitPanel {
-    pub fn new(
-        workspace: Entity<Workspace>,
-        project: Entity<Project>,
-        app_state: Arc<AppState>,
+    fn new(
+        workspace: &mut Workspace,
         window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Self {
+        cx: &mut Context<Workspace>,
+    ) -> Entity<Self> {
+        let project = workspace.project().clone();
+        let app_state = workspace.app_state().clone();
         let fs = app_state.fs.clone();
         let git_store = project.read(cx).git_store().clone();
         let active_repository = project.read(cx).active_repository(cx);
-        let workspace = workspace.downgrade();
 
-        let focus_handle = cx.focus_handle();
-        cx.on_focus(&focus_handle, window, Self::focus_in).detach();
-        cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
-            this.hide_scrollbars(window, cx);
-        })
-        .detach();
+        let git_panel = cx.new(|cx| {
+            let focus_handle = cx.focus_handle();
+            cx.on_focus(&focus_handle, window, Self::focus_in).detach();
+            cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
+                this.hide_scrollbars(window, cx);
+            })
+            .detach();
 
-        let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
-        cx.observe_global::<SettingsStore>(move |this, cx| {
-            let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
-            if is_sort_by_path != was_sort_by_path {
-                this.update_visible_entries(cx);
-            }
-            was_sort_by_path = is_sort_by_path
-        })
-        .detach();
+            let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
+            cx.observe_global::<SettingsStore>(move |this, cx| {
+                let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
+                if is_sort_by_path != was_sort_by_path {
+                    this.update_visible_entries(cx);
+                }
+                was_sort_by_path = is_sort_by_path
+            })
+            .detach();
 
-        // just to let us render a placeholder editor.
-        // Once the active git repo is set, this buffer will be replaced.
-        let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
-        let commit_editor = cx.new(|cx| {
-            commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
-        });
+            // just to let us render a placeholder editor.
+            // Once the active git repo is set, this buffer will be replaced.
+            let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
+            let commit_editor = cx.new(|cx| {
+                commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
+            });
 
-        commit_editor.update(cx, |editor, cx| {
-            editor.clear(window, cx);
-        });
+            commit_editor.update(cx, |editor, cx| {
+                editor.clear(window, cx);
+            });
 
-        let scroll_handle = UniformListScrollHandle::new();
+            let scroll_handle = UniformListScrollHandle::new();
 
-        cx.subscribe_in(
-            &git_store,
-            window,
-            move |this, git_store, event, window, cx| match event {
-                GitStoreEvent::ActiveRepositoryChanged(_) => {
-                    this.active_repository = git_store.read(cx).active_repository();
-                    this.schedule_update(true, window, cx);
-                }
-                GitStoreEvent::RepositoryUpdated(
-                    _,
-                    RepositoryEvent::Updated { full_scan },
-                    true,
-                ) => {
-                    this.schedule_update(*full_scan, window, cx);
-                }
+            let vertical_scrollbar = ScrollbarProperties {
+                axis: Axis::Vertical,
+                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
+                show_scrollbar: false,
+                show_track: false,
+                auto_hide: false,
+                hide_task: None,
+            };
 
-                GitStoreEvent::RepositoryAdded(_) | GitStoreEvent::RepositoryRemoved(_) => {
-                    this.schedule_update(false, window, cx);
-                }
-                GitStoreEvent::IndexWriteError(error) => {
-                    this.workspace
-                        .update(cx, |workspace, cx| {
-                            workspace.show_error(error, cx);
-                        })
-                        .ok();
+            let horizontal_scrollbar = ScrollbarProperties {
+                axis: Axis::Horizontal,
+                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
+                show_scrollbar: false,
+                show_track: false,
+                auto_hide: false,
+                hide_task: None,
+            };
+
+            let mut assistant_enabled = AgentSettings::get_global(cx).enabled;
+            let _settings_subscription = cx.observe_global::<SettingsStore>(move |_, cx| {
+                if assistant_enabled != AgentSettings::get_global(cx).enabled {
+                    assistant_enabled = AgentSettings::get_global(cx).enabled;
+                    cx.notify();
                 }
-                GitStoreEvent::RepositoryUpdated(_, _, _) => {}
-                GitStoreEvent::JobsUpdated | GitStoreEvent::ConflictsUpdated => {}
-            },
-        )
-        .detach();
+            });
 
-        let vertical_scrollbar = ScrollbarProperties {
-            axis: Axis::Vertical,
-            state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
-            show_scrollbar: false,
-            show_track: false,
-            auto_hide: false,
-            hide_task: None,
-        };
+            cx.subscribe_in(
+                &git_store,
+                window,
+                move |this, _git_store, event, window, cx| match event {
+                    GitStoreEvent::ActiveRepositoryChanged(_) => {
+                        this.active_repository = this.project.read(cx).active_repository(cx);
+                        this.schedule_update(true, window, cx);
+                    }
+                    GitStoreEvent::RepositoryUpdated(
+                        _,
+                        RepositoryEvent::Updated { full_scan, .. },
+                        true,
+                    ) => {
+                        this.schedule_update(*full_scan, window, cx);
+                    }
 
-        let horizontal_scrollbar = ScrollbarProperties {
-            axis: Axis::Horizontal,
-            state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
-            show_scrollbar: false,
-            show_track: false,
-            auto_hide: false,
-            hide_task: None,
-        };
+                    GitStoreEvent::RepositoryAdded(_) | GitStoreEvent::RepositoryRemoved(_) => {
+                        this.schedule_update(false, window, cx);
+                    }
+                    GitStoreEvent::IndexWriteError(error) => {
+                        this.workspace
+                            .update(cx, |workspace, cx| {
+                                workspace.show_error(error, cx);
+                            })
+                            .ok();
+                    }
+                    GitStoreEvent::RepositoryUpdated(_, _, _) => {}
+                    GitStoreEvent::JobsUpdated | GitStoreEvent::ConflictsUpdated => {}
+                },
+            )
+            .detach();
 
-        let mut assistant_enabled = AssistantSettings::get_global(cx).enabled;
-        let _settings_subscription = cx.observe_global::<SettingsStore>(move |_, cx| {
-            if assistant_enabled != AssistantSettings::get_global(cx).enabled {
-                assistant_enabled = AssistantSettings::get_global(cx).enabled;
-                cx.notify();
-            }
+            let mut this = Self {
+                active_repository,
+                commit_editor,
+                conflicted_count: 0,
+                conflicted_staged_count: 0,
+                current_modifiers: window.modifiers(),
+                add_coauthors: true,
+                generate_commit_message_task: None,
+                entries: Vec::new(),
+                focus_handle: cx.focus_handle(),
+                fs,
+                new_count: 0,
+                new_staged_count: 0,
+                pending: Vec::new(),
+                pending_commit: None,
+                amend_pending: false,
+                pending_serialization: Task::ready(None),
+                single_staged_entry: None,
+                single_tracked_entry: None,
+                project,
+                scroll_handle,
+                max_width_item_index: None,
+                selected_entry: None,
+                marked_entries: Vec::new(),
+                tracked_count: 0,
+                tracked_staged_count: 0,
+                update_visible_entries_task: Task::ready(()),
+                width: None,
+                show_placeholders: false,
+                local_committer: None,
+                local_committer_task: None,
+                context_menu: None,
+                workspace: workspace.weak_handle(),
+                modal_open: false,
+                entry_count: 0,
+                horizontal_scrollbar,
+                vertical_scrollbar,
+                _settings_subscription,
+            };
+
+            this.schedule_update(false, window, cx);
+            this
         });
 
-        let mut git_panel = Self {
-            active_repository,
-            commit_editor,
-            conflicted_count: 0,
-            conflicted_staged_count: 0,
-            current_modifiers: window.modifiers(),
-            add_coauthors: true,
-            generate_commit_message_task: None,
-            entries: Vec::new(),
-            focus_handle: cx.focus_handle(),
-            fs,
-            new_count: 0,
-            new_staged_count: 0,
-            pending: Vec::new(),
-            pending_commit: None,
-            amend_pending: false,
-            pending_serialization: Task::ready(None),
-            single_staged_entry: None,
-            single_tracked_entry: None,
-            project,
-            scroll_handle,
-            max_width_item_index: None,
-            selected_entry: None,
-            marked_entries: Vec::new(),
-            tracked_count: 0,
-            tracked_staged_count: 0,
-            update_visible_entries_task: Task::ready(()),
-            width: None,
-            show_placeholders: false,
-            context_menu: None,
-            workspace,
-            modal_open: false,
-            entry_count: 0,
-            horizontal_scrollbar,
-            vertical_scrollbar,
-            _settings_subscription,
-        };
-        git_panel.schedule_update(false, window, cx);
         git_panel
     }
 
@@ -1051,8 +1064,8 @@ impl GitPanel {
                     repo.checkout_files(
                         "HEAD",
                         entries
-                            .iter()
-                            .map(|entries| entries.repo_path.clone())
+                            .into_iter()
+                            .map(|entries| entries.repo_path)
                             .collect(),
                         cx,
                     )
@@ -1482,15 +1495,48 @@ impl GitPanel {
         }
     }
 
-    fn custom_or_suggested_commit_message(&self, cx: &mut Context<Self>) -> Option<String> {
+    fn custom_or_suggested_commit_message(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<String> {
+        let git_commit_language = self.commit_editor.read(cx).language_at(0, cx);
         let message = self.commit_editor.read(cx).text(cx);
-
-        if !message.trim().is_empty() {
-            return Some(message);
+        if message.is_empty() {
+            return self
+                .suggest_commit_message(cx)
+                .filter(|message| !message.trim().is_empty());
+        } else if message.trim().is_empty() {
+            return None;
+        }
+        let buffer = cx.new(|cx| {
+            let mut buffer = Buffer::local(message, cx);
+            buffer.set_language(git_commit_language, cx);
+            buffer
+        });
+        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.text(cx)
+        });
+        if wrapped_message.trim().is_empty() {
+            return None;
         }
+        Some(wrapped_message)
+    }
 
-        self.suggest_commit_message(cx)
-            .filter(|message| !message.trim().is_empty())
+    fn has_commit_message(&self, cx: &mut Context<Self>) -> bool {
+        let text = self.commit_editor.read(cx).text(cx);
+        if !text.trim().is_empty() {
+            return true;
+        } else if text.is_empty() {
+            return self
+                .suggest_commit_message(cx)
+                .is_some_and(|text| !text.trim().is_empty());
+        } else {
+            return false;
+        }
     }
 
     pub(crate) fn commit_changes(
@@ -1519,7 +1565,7 @@ impl GitPanel {
             return;
         }
 
-        let commit_message = self.custom_or_suggested_commit_message(cx);
+        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);
@@ -1626,14 +1672,12 @@ impl GitPanel {
         &mut self,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) -> impl Future<Output = Result<bool, anyhow::Error>> + use<> {
+    ) -> impl Future<Output = anyhow::Result<bool>> + use<> {
         let repo = self.active_repository.clone();
         let mut cx = window.to_async(cx);
 
         async move {
-            let Some(repo) = repo else {
-                return Err(anyhow::anyhow!("No active repository"));
-            };
+            let repo = repo.context("No active repository")?;
 
             let pushed_to: Vec<SharedString> = repo
                 .update(&mut cx, |repo, _| repo.check_for_pushed_commits())?
@@ -1735,7 +1779,7 @@ impl GitPanel {
             }
         });
 
-        let temperature = AssistantSettings::temperature_for_model(&model, cx);
+        let temperature = AgentSettings::temperature_for_model(&model, cx);
 
         self.generate_commit_message_task = Some(cx.spawn(async move |this, cx| {
              async move {
@@ -1743,7 +1787,19 @@ impl GitPanel {
                     this.generate_commit_message_task.take();
                 });
 
-                let mut diff_text = diff.await??;
+                let mut diff_text = match diff.await {
+                    Ok(result) => match result {
+                        Ok(text) => text,
+                        Err(e) => {
+                            Self::show_commit_message_error(&this, &e, cx);
+                            return anyhow::Ok(());
+                        }
+                    },
+                    Err(e) => {
+                        Self::show_commit_message_error(&this, &e, cx);
+                        return anyhow::Ok(());
+                    }
+                };
 
                 const ONE_MB: usize = 1_000_000;
                 if diff_text.len() > ONE_MB {
@@ -1767,6 +1823,7 @@ impl GitPanel {
                 let request = LanguageModelRequest {
                     thread_id: None,
                     prompt_id: None,
+                    intent: Some(CompletionIntent::GenerateGitCommitMessage),
                     mode: None,
                     messages: vec![LanguageModelRequestMessage {
                         role: Role::User,
@@ -1780,26 +1837,37 @@ impl GitPanel {
                 };
 
                 let stream = model.stream_completion_text(request, &cx);
-                let mut messages = stream.await?;
-
-                if !text_empty {
-                    this.update(cx, |this, cx| {
-                        this.commit_message_buffer(cx).update(cx, |buffer, cx| {
-                            let insert_position = buffer.anchor_before(buffer.len());
-                            buffer.edit([(insert_position..insert_position, "\n")], None, cx)
-                        });
-                    })?;
-                }
-
-                while let Some(message) = messages.stream.next().await {
-                    let text = message?;
+                match stream.await {
+                    Ok(mut messages) => {
+                        if !text_empty {
+                            this.update(cx, |this, cx| {
+                                this.commit_message_buffer(cx).update(cx, |buffer, cx| {
+                                    let insert_position = buffer.anchor_before(buffer.len());
+                                    buffer.edit([(insert_position..insert_position, "\n")], None, cx)
+                                });
+                            })?;
+                        }
 
-                    this.update(cx, |this, cx| {
-                        this.commit_message_buffer(cx).update(cx, |buffer, cx| {
-                            let insert_position = buffer.anchor_before(buffer.len());
-                            buffer.edit([(insert_position..insert_position, text)], None, cx);
-                        });
-                    })?;
+                        while let Some(message) = messages.stream.next().await {
+                            match message {
+                                Ok(text) => {
+                                    this.update(cx, |this, cx| {
+                                        this.commit_message_buffer(cx).update(cx, |buffer, cx| {
+                                            let insert_position = buffer.anchor_before(buffer.len());
+                                            buffer.edit([(insert_position..insert_position, text)], None, cx);
+                                        });
+                                    })?;
+                                }
+                                Err(e) => {
+                                    Self::show_commit_message_error(&this, &e, cx);
+                                    break;
+                                }
+                            }
+                        }
+                    }
+                    Err(e) => {
+                        Self::show_commit_message_error(&this, &e, cx);
+                    }
                 }
 
                 anyhow::Ok(())
@@ -1808,7 +1876,49 @@ impl GitPanel {
         }));
     }
 
-    pub(crate) fn fetch(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+    fn get_fetch_options(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Option<FetchOptions>> {
+        let repo = self.active_repository.clone();
+        let workspace = self.workspace.clone();
+
+        cx.spawn_in(window, async move |_, cx| {
+            let repo = repo?;
+            let remotes = repo
+                .update(cx, |repo, _| repo.get_remotes(None))
+                .ok()?
+                .await
+                .ok()?
+                .log_err()?;
+
+            let mut remotes: Vec<_> = remotes.into_iter().map(FetchOptions::Remote).collect();
+            if remotes.len() > 1 {
+                remotes.push(FetchOptions::All);
+            }
+            let selection = cx
+                .update(|window, cx| {
+                    picker_prompt::prompt(
+                        "Pick which remote to fetch",
+                        remotes.iter().map(|r| r.name()).collect(),
+                        workspace,
+                        window,
+                        cx,
+                    )
+                })
+                .ok()?
+                .await?;
+            remotes.get(selection).cloned()
+        })
+    }
+
+    pub(crate) fn fetch(
+        &mut self,
+        is_fetch_all: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         if !self.can_push_and_pull(cx) {
             return;
         }
@@ -1819,13 +1929,28 @@ impl GitPanel {
         telemetry::event!("Git Fetched");
         let askpass = self.askpass_delegate("git fetch", window, cx);
         let this = cx.weak_entity();
+
+        let fetch_options = if is_fetch_all {
+            Task::ready(Some(FetchOptions::All))
+        } else {
+            self.get_fetch_options(window, cx)
+        };
+
         window
             .spawn(cx, async move |cx| {
-                let fetch = repo.update(cx, |repo, cx| repo.fetch(askpass, cx))?;
+                let Some(fetch_options) = fetch_options.await else {
+                    return Ok(());
+                };
+                let fetch = repo.update(cx, |repo, cx| {
+                    repo.fetch(fetch_options.clone(), askpass, cx)
+                })?;
 
                 let remote_message = fetch.await?;
                 this.update(cx, |this, cx| {
-                    let action = RemoteAction::Fetch;
+                    let action = match fetch_options {
+                        FetchOptions::All => RemoteAction::Fetch(None),
+                        FetchOptions::Remote(remote) => RemoteAction::Fetch(Some(remote)),
+                    };
                     match remote_message {
                         Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
                         Err(e) => {
@@ -1936,7 +2061,7 @@ impl GitPanel {
         };
         telemetry::event!("Git Pulled");
         let branch = branch.clone();
-        let remote = self.get_current_remote(window, cx);
+        let remote = self.get_remote(false, window, cx);
         cx.spawn_in(window, async move |this, cx| {
             let remote = match remote.await {
                 Ok(Some(remote)) => remote,
@@ -1981,7 +2106,13 @@ impl GitPanel {
         .detach_and_log_err(cx);
     }
 
-    pub(crate) fn push(&mut self, force_push: bool, window: &mut Window, cx: &mut Context<Self>) {
+    pub(crate) fn push(
+        &mut self,
+        force_push: bool,
+        select_remote: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         if !self.can_push_and_pull(cx) {
             return;
         }
@@ -2006,7 +2137,7 @@ impl GitPanel {
                 _ => None,
             }
         };
-        let remote = self.get_current_remote(window, cx);
+        let remote = self.get_remote(select_remote, window, cx);
 
         cx.spawn_in(window, async move |this, cx| {
             let remote = match remote.await {
@@ -2080,8 +2211,9 @@ impl GitPanel {
         !self.project.read(cx).is_via_collab()
     }
 
-    fn get_current_remote(
+    fn get_remote(
         &mut self,
+        always_select: bool,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl Future<Output = anyhow::Result<Option<Remote>>> + use<> {
@@ -2090,45 +2222,51 @@ impl GitPanel {
         let mut cx = window.to_async(cx);
 
         async move {
-            let Some(repo) = repo else {
-                return Err(anyhow::anyhow!("No active repository"));
-            };
-
-            let mut current_remotes: Vec<Remote> = repo
+            let repo = repo.context("No active repository")?;
+            let current_remotes: Vec<Remote> = repo
                 .update(&mut cx, |repo, _| {
-                    let Some(current_branch) = repo.branch.as_ref() else {
-                        return Err(anyhow::anyhow!("No active branch"));
+                    let current_branch = if always_select {
+                        None
+                    } else {
+                        let current_branch = repo.branch.as_ref().context("No active branch")?;
+                        Some(current_branch.name().to_string())
                     };
-
-                    Ok(repo.get_remotes(Some(current_branch.name().to_string())))
+                    anyhow::Ok(repo.get_remotes(current_branch))
                 })??
                 .await??;
 
-            if current_remotes.len() == 0 {
-                return Err(anyhow::anyhow!("No active remote"));
-            } else if current_remotes.len() == 1 {
-                return Ok(Some(current_remotes.pop().unwrap()));
-            } else {
-                let current_remotes: Vec<_> = current_remotes
-                    .into_iter()
-                    .map(|remotes| remotes.name)
-                    .collect();
-                let selection = cx
-                    .update(|window, cx| {
-                        picker_prompt::prompt(
-                            "Pick which remote to push to",
-                            current_remotes.clone(),
-                            workspace,
-                            window,
-                            cx,
-                        )
-                    })?
-                    .await;
+            let current_remotes: Vec<_> = current_remotes
+                .into_iter()
+                .map(|remotes| remotes.name)
+                .collect();
+            let selection = cx
+                .update(|window, cx| {
+                    picker_prompt::prompt(
+                        "Pick which remote to push to",
+                        current_remotes.clone(),
+                        workspace,
+                        window,
+                        cx,
+                    )
+                })?
+                .await;
 
-                Ok(selection.map(|selection| Remote {
-                    name: current_remotes[selection].clone(),
-                }))
-            }
+            Ok(selection.map(|selection| Remote {
+                name: current_remotes[selection].clone(),
+            }))
+        }
+    }
+
+    pub fn load_local_committer(&mut self, cx: &Context<Self>) {
+        if self.local_committer_task.is_none() {
+            self.local_committer_task = Some(cx.spawn(async move |this, cx| {
+                let committer = get_git_committer(cx).await;
+                this.update(cx, |this, cx| {
+                    this.local_committer = Some(committer);
+                    cx.notify()
+                })
+                .ok();
+            }));
         }
     }
 
@@ -2154,34 +2292,38 @@ impl GitPanel {
             let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
                 continue;
             };
-            if participant.can_write() && participant.user.email.is_some() {
-                let email = participant.user.email.clone().unwrap();
-
-                new_co_authors.push((
-                    participant
-                        .user
-                        .name
-                        .clone()
-                        .unwrap_or_else(|| participant.user.github_login.clone()),
-                    email,
-                ))
+            if !participant.can_write() {
+                continue;
+            }
+            if let Some(email) = &collaborator.committer_email {
+                let name = collaborator
+                    .committer_name
+                    .clone()
+                    .or_else(|| participant.user.name.clone())
+                    .unwrap_or_else(|| participant.user.github_login.clone());
+                new_co_authors.push((name.clone(), email.clone()))
             }
         }
         if !project.is_local() && !project.is_read_only(cx) {
-            if let Some(user) = room.local_participant_user(cx) {
-                if let Some(email) = user.email.clone() {
-                    new_co_authors.push((
-                        user.name
-                            .clone()
-                            .unwrap_or_else(|| user.github_login.clone()),
-                        email.clone(),
-                    ))
-                }
+            if let Some(local_committer) = self.local_committer(room, cx) {
+                new_co_authors.push(local_committer);
             }
         }
         new_co_authors
     }
 
+    fn local_committer(&self, room: &call::Room, cx: &App) -> Option<(String, String)> {
+        let user = room.local_participant_user(cx)?;
+        let committer = self.local_committer.as_ref()?;
+        let email = committer.email.clone()?;
+        let name = committer
+            .name
+            .clone()
+            .or_else(|| user.name.clone())
+            .unwrap_or_else(|| user.github_login.clone());
+        Some((name, email))
+    }
+
     fn toggle_fill_co_authors(
         &mut self,
         _: &ToggleFillCoAuthors,
@@ -2339,7 +2481,7 @@ impl GitPanel {
         let repo = repo.read(cx);
 
         for entry in repo.cached_status() {
-            let is_conflict = repo.has_conflict(&entry.repo_path);
+            let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path);
             let is_new = entry.status.is_created();
             let staging = entry.status.staging();
 
@@ -2510,7 +2652,7 @@ impl GitPanel {
                 continue;
             };
             self.entry_count += 1;
-            if repo.has_conflict(&status_entry.repo_path) {
+            if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) {
                 self.conflicted_count += 1;
                 if self.entry_staging(status_entry).has_staged() {
                     self.conflicted_staged_count += 1;
@@ -2583,24 +2725,43 @@ impl GitPanel {
         } else {
             workspace.update(cx, |workspace, cx| {
                 let workspace_weak = cx.weak_entity();
-                let toast =
-                    StatusToast::new(format!("git {} failed", action.clone()), cx, |this, _cx| {
-                        this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
-                            .action("View Log", move |window, cx| {
-                                let message = message.clone();
-                                let action = action.clone();
-                                workspace_weak
-                                    .update(cx, move |workspace, cx| {
-                                        Self::open_output(action, workspace, &message, window, cx)
-                                    })
-                                    .ok();
-                            })
-                    });
+                let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
+                    this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
+                        .action("View Log", move |window, cx| {
+                            let message = message.clone();
+                            let action = action.clone();
+                            workspace_weak
+                                .update(cx, move |workspace, cx| {
+                                    Self::open_output(action, workspace, &message, window, cx)
+                                })
+                                .ok();
+                        })
+                });
                 workspace.toggle_status_toast(toast, cx)
             });
         }
     }
 
+    fn show_commit_message_error<E>(weak_this: &WeakEntity<Self>, err: &E, cx: &mut AsyncApp)
+    where
+        E: std::fmt::Debug + std::fmt::Display,
+    {
+        if let Ok(Some(workspace)) = weak_this.update(cx, |this, _cx| this.workspace.upgrade()) {
+            let _ = workspace.update(cx, |workspace, cx| {
+                struct CommitMessageError;
+                let notification_id = NotificationId::unique::<CommitMessageError>();
+                workspace.show_notification(notification_id, cx, |cx| {
+                    cx.new(|cx| {
+                        ErrorMessagePrompt::new(
+                            format!("Failed to generate commit message: {err}"),
+                            cx,
+                        )
+                    })
+                });
+            });
+        }
+    }
+
     fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) {
         let Some(workspace) = self.workspace.upgrade() else {
             return;
@@ -2839,7 +3000,7 @@ impl GitPanel {
             (false, "No changes to commit")
         } else if self.pending_commit.is_some() {
             (false, "Commit in progress")
-        } else if self.custom_or_suggested_commit_message(cx).is_none() {
+        } else if !self.has_commit_message(cx) {
             (false, "No commit message")
         } else if !self.has_write_access(cx) {
             (false, "You do not have write access to this project")
@@ -3574,8 +3735,10 @@ impl GitPanel {
                     .relative()
                     .overflow_hidden()
                     .child(
-                        uniform_list(cx.entity().clone(), "entries", entry_count, {
-                            move |this, range, window, cx| {
+                        uniform_list(
+                            "entries",
+                            entry_count,
+                            cx.processor(move |this, range: Range<usize>, window, cx| {
                                 let mut items = Vec::with_capacity(range.end - range.start);
 
                                 for ix in range {
@@ -3603,8 +3766,8 @@ impl GitPanel {
                                 }
 
                                 items
-                            }
-                        })
+                            }),
+                        )
                         .when(
                             !self.horizontal_scrollbar.show_track
                                 && self.horizontal_scrollbar.show_scrollbar,
@@ -4053,10 +4216,36 @@ impl GitPanel {
         self.amend_pending = value;
         cx.notify();
     }
+
+    pub async fn load(
+        workspace: WeakEntity<Workspace>,
+        mut cx: AsyncWindowContext,
+    ) -> anyhow::Result<Entity<Self>> {
+        let serialized_panel = cx
+            .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&GIT_PANEL_KEY) })
+            .await
+            .context("loading git panel")
+            .log_err()
+            .flatten()
+            .and_then(|panel| serde_json::from_str::<SerializedGitPanel>(&panel).log_err());
+
+        workspace.update_in(&mut cx, |workspace, window, cx| {
+            let panel = GitPanel::new(workspace, window, cx);
+
+            if let Some(serialized_panel) = serialized_panel {
+                panel.update(cx, |panel, cx| {
+                    panel.width = serialized_panel.width;
+                    cx.notify();
+                })
+            }
+
+            panel
+        })
+    }
 }
 
 fn current_language_model(cx: &Context<'_, GitPanel>) -> Option<Arc<dyn LanguageModel>> {
-    assistant_settings::AssistantSettings::get_global(cx)
+    agent_settings::AgentSettings::get_global(cx)
         .enabled
         .then(|| {
             let ConfiguredModel { provider, model } =
@@ -4079,8 +4268,9 @@ impl Render for GitPanel {
         let has_write_access = self.has_write_access(cx);
 
         let has_co_authors = room.map_or(false, |room| {
-            room.read(cx)
-                .remote_participants()
+            self.load_local_committer(cx);
+            let room = room.read(cx);
+            room.remote_participants()
                 .values()
                 .any(|remote_participant| remote_participant.can_write())
         });

crates/git_ui/src/git_panel_settings.rs 🔗

@@ -70,6 +70,11 @@ pub struct GitPanelSettingsContent {
     ///
     /// Default: false
     pub sort_by_path: Option<bool>,
+
+    /// Whether to collapse untracked files in the diff panel.
+    ///
+    /// Default: false
+    pub collapse_untracked_diff: Option<bool>,
 }
 
 #[derive(Deserialize, Debug, Clone, PartialEq)]
@@ -81,6 +86,7 @@ pub struct GitPanelSettings {
     pub scrollbar: ScrollbarSettings,
     pub fallback_branch_name: String,
     pub sort_by_path: bool,
+    pub collapse_untracked_diff: bool,
 }
 
 impl Settings for GitPanelSettings {

crates/git_ui/src/git_ui.rs 🔗

@@ -10,7 +10,7 @@ use git::{
     status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
 };
 use git_panel_settings::GitPanelSettings;
-use gpui::{Action, App, FocusHandle, actions};
+use gpui::{Action, App, Context, FocusHandle, Window, actions};
 use onboarding::GitOnboardingModal;
 use project_diff::ProjectDiff;
 use ui::prelude::*;
@@ -22,6 +22,7 @@ mod commit_modal;
 pub mod commit_tooltip;
 mod commit_view;
 mod conflict_view;
+pub mod diff_view;
 pub mod git_panel;
 mod git_panel_settings;
 pub mod onboarding;
@@ -59,7 +60,15 @@ pub fn init(cx: &mut App) {
                     return;
                 };
                 panel.update(cx, |panel, cx| {
-                    panel.fetch(window, cx);
+                    panel.fetch(true, window, cx);
+                });
+            });
+            workspace.register_action(|workspace, _: &git::FetchFrom, window, cx| {
+                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+                    return;
+                };
+                panel.update(cx, |panel, cx| {
+                    panel.fetch(false, window, cx);
                 });
             });
             workspace.register_action(|workspace, _: &git::Push, window, cx| {
@@ -67,7 +76,15 @@ pub fn init(cx: &mut App) {
                     return;
                 };
                 panel.update(cx, |panel, cx| {
-                    panel.push(false, window, cx);
+                    panel.push(false, false, window, cx);
+                });
+            });
+            workspace.register_action(|workspace, _: &git::PushTo, window, cx| {
+                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+                    return;
+                };
+                panel.update(cx, |panel, cx| {
+                    panel.push(false, true, window, cx);
                 });
             });
             workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
@@ -75,7 +92,7 @@ pub fn init(cx: &mut App) {
                     return;
                 };
                 panel.update(cx, |panel, cx| {
-                    panel.push(true, window, cx);
+                    panel.push(true, false, window, cx);
                 });
             });
             workspace.register_action(|workspace, _: &git::Pull, window, cx| {
@@ -126,10 +143,41 @@ pub fn init(cx: &mut App) {
                 panel.git_init(window, cx);
             });
         });
+        workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
+            open_modified_files(workspace, window, cx);
+        });
     })
     .detach();
 }
 
+fn open_modified_files(
+    workspace: &mut Workspace,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+        return;
+    };
+    let modified_paths: Vec<_> = panel.update(cx, |panel, cx| {
+        let Some(repo) = panel.active_repository.as_ref() else {
+            return Vec::new();
+        };
+        let repo = repo.read(cx);
+        repo.cached_status()
+            .filter_map(|entry| {
+                if entry.status.is_modified() {
+                    repo.repo_path_to_project_path(&entry.repo_path, cx)
+                } else {
+                    None
+                }
+            })
+            .collect()
+    });
+    for path in modified_paths {
+        workspace.open_path(path, None, true, window, cx).detach();
+    }
+}
+
 pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
     GitStatusIcon::new(status)
 }
@@ -367,9 +415,11 @@ mod remote_button {
                             el.context(keybinding_target.clone())
                         })
                         .action("Fetch", git::Fetch.boxed_clone())
+                        .action("Fetch From", git::FetchFrom.boxed_clone())
                         .action("Pull", git::Pull.boxed_clone())
                         .separator()
                         .action("Push", git::Push.boxed_clone())
+                        .action("Push To", git::PushTo.boxed_clone())
                         .action("Force Push", git::ForcePush.boxed_clone())
                 }))
             })

crates/git_ui/src/picker_prompt.rs 🔗

@@ -28,6 +28,8 @@ pub fn prompt(
 ) -> Task<Option<usize>> {
     if options.is_empty() {
         return Task::ready(None);
+    } else if options.len() == 1 {
+        return Task::ready(Some(0));
     }
     let prompt = prompt.to_string().into();
 
@@ -144,7 +146,7 @@ impl PickerDelegate for PickerPromptDelegate {
         cx: &mut Context<Picker<Self>>,
     ) -> Task<()> {
         cx.spawn_in(window, async move |picker, cx| {
-            let candidates = picker.update(cx, |picker, _| {
+            let candidates = picker.read_with(cx, |picker, _| {
                 picker
                     .delegate
                     .all_options
@@ -172,6 +174,7 @@ impl PickerDelegate for PickerPromptDelegate {
                     &candidates,
                     &query,
                     true,
+                    true,
                     10000,
                     &Default::default(),
                     cx.background_executor().clone(),

crates/git_ui/src/project_diff.rs 🔗

@@ -8,7 +8,7 @@ use anyhow::Result;
 use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
 use collections::HashSet;
 use editor::{
-    Editor, EditorEvent,
+    Editor, EditorEvent, SelectionEffects,
     actions::{GoToHunk, GoToPreviousHunk},
     scroll::Autoscroll,
 };
@@ -37,7 +37,7 @@ use util::ResultExt as _;
 use workspace::{
     CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
     ToolbarItemView, Workspace,
-    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
+    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
     searchable::SearchableItemHandle,
 };
 
@@ -141,13 +141,24 @@ impl ProjectDiff {
         let editor = cx.new(|cx| {
             let mut diff_display_editor =
                 Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
-            diff_display_editor.disable_inline_diagnostics();
+            diff_display_editor.disable_diagnostics(cx);
             diff_display_editor.set_expand_all_diff_hunks(cx);
             diff_display_editor.register_addon(GitPanelAddon {
                 workspace: workspace.downgrade(),
             });
             diff_display_editor
         });
+        window.defer(cx, {
+            let workspace = workspace.clone();
+            let editor = editor.clone();
+            move |window, cx| {
+                workspace.update(cx, |workspace, cx| {
+                    editor.update(cx, |editor, cx| {
+                        editor.added_to_workspace(workspace, window, cx);
+                    })
+                });
+            }
+        });
         cx.subscribe_in(&editor, window, Self::handle_editor_event)
             .detach();
 
@@ -166,12 +177,19 @@ impl ProjectDiff {
         );
 
         let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
+        let mut was_collapse_untracked_diff =
+            GitPanelSettings::get_global(cx).collapse_untracked_diff;
         cx.observe_global::<SettingsStore>(move |this, cx| {
             let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
-            if is_sort_by_path != was_sort_by_path {
+            let is_collapse_untracked_diff =
+                GitPanelSettings::get_global(cx).collapse_untracked_diff;
+            if is_sort_by_path != was_sort_by_path
+                || is_collapse_untracked_diff != was_collapse_untracked_diff
+            {
                 *this.update_needed.borrow_mut() = ();
             }
-            was_sort_by_path = is_sort_by_path
+            was_sort_by_path = is_sort_by_path;
+            was_collapse_untracked_diff = is_collapse_untracked_diff;
         })
         .detach();
 
@@ -208,7 +226,7 @@ impl ProjectDiff {
         };
         let repo = git_repo.read(cx);
 
-        let namespace = if repo.has_conflict(&entry.repo_path) {
+        let namespace = if repo.had_conflict_on_last_merge_head_change(&entry.repo_path) {
             CONFLICT_NAMESPACE
         } else if entry.status.is_created() {
             NEW_NAMESPACE
@@ -237,9 +255,14 @@ impl ProjectDiff {
     fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
             self.editor.update(cx, |editor, cx| {
-                editor.change_selections(Some(Autoscroll::focused()), window, cx, |s| {
-                    s.select_ranges([position..position]);
-                })
+                editor.change_selections(
+                    SelectionEffects::scroll(Autoscroll::focused()),
+                    window,
+                    cx,
+                    |s| {
+                        s.select_ranges([position..position]);
+                    },
+                )
             });
         } else {
             self.pending_scroll = Some(path_key);
@@ -361,7 +384,7 @@ impl ProjectDiff {
                 };
                 let namespace = if GitPanelSettings::get_global(cx).sort_by_path {
                     TRACKED_NAMESPACE
-                } else if repo.has_conflict(&entry.repo_path) {
+                } else if repo.had_conflict_on_last_merge_head_change(&entry.repo_path) {
                     CONFLICT_NAMESPACE
                 } else if entry.status.is_created() {
                     NEW_NAMESPACE
@@ -445,12 +468,16 @@ impl ProjectDiff {
 
         self.editor.update(cx, |editor, cx| {
             if was_empty {
-                editor.change_selections(None, window, cx, |selections| {
+                editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
                     // TODO select the very beginning (possibly inside a deletion)
                     selections.select_ranges([0..0])
                 });
             }
-            if is_excerpt_newly_added && diff_buffer.file_status.is_deleted() {
+            if is_excerpt_newly_added
+                && (diff_buffer.file_status.is_deleted()
+                    || (diff_buffer.file_status.is_untracked()
+                        && GitPanelSettings::get_global(cx).collapse_untracked_diff))
+            {
                 editor.fold_buffer(snapshot.text.remote_id(), cx)
             }
         });
@@ -621,12 +648,12 @@ impl Item for ProjectDiff {
 
     fn save(
         &mut self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        self.editor.save(format, project, window, cx)
+        self.editor.save(options, project, window, cx)
     }
 
     fn save_as(
@@ -1323,6 +1350,7 @@ fn merge_anchor_ranges<'a>(
 mod tests {
     use db::indoc;
     use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
+    use git::status::{UnmergedStatus, UnmergedStatusCode};
     use gpui::TestAppContext;
     use project::FakeFs;
     use serde_json::json;
@@ -1335,7 +1363,7 @@ mod tests {
 
     #[ctor::ctor]
     fn init_logger() {
-        env_logger::init();
+        zlog::init_test();
     }
 
     fn init_test(cx: &mut TestAppContext) {
@@ -1375,6 +1403,7 @@ mod tests {
         fs.set_head_for_repo(
             path!("/project/.git").as_ref(),
             &[("foo.txt".into(), "foo\n".into())],
+            "deadbeef",
         );
         fs.set_index_for_repo(
             path!("/project/.git").as_ref(),
@@ -1382,7 +1411,7 @@ mod tests {
         );
         cx.run_until_parked();
 
-        let editor = diff.update(cx, |diff, _| diff.editor.clone());
+        let editor = diff.read_with(cx, |diff, _| diff.editor.clone());
         assert_state_with_diff(
             &editor,
             cx,
@@ -1511,10 +1540,11 @@ mod tests {
         fs.set_head_for_repo(
             path!("/project/.git").as_ref(),
             &[("foo".into(), "original\n".into())],
+            "deadbeef",
         );
         cx.run_until_parked();
 
-        let diff_editor = diff.update(cx, |diff, _| diff.editor.clone());
+        let diff_editor = diff.read_with(cx, |diff, _| diff.editor.clone());
 
         assert_state_with_diff(
             &diff_editor,
@@ -1551,7 +1581,15 @@ mod tests {
 
         cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
             buffer_editor.set_text("different\n", window, cx);
-            buffer_editor.save(false, project.clone(), window, cx)
+            buffer_editor.save(
+                SaveOptions {
+                    format: false,
+                    autosave: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
         })
         .await
         .unwrap();
@@ -1583,7 +1621,10 @@ mod tests {
         );
     }
 
-    use crate::project_diff::{self, ProjectDiff};
+    use crate::{
+        conflict_view::resolve_conflict,
+        project_diff::{self, ProjectDiff},
+    };
 
     #[gpui::test]
     async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
@@ -1627,7 +1668,7 @@ mod tests {
             workspace.active_item_as::<ProjectDiff>(cx).unwrap()
         });
         cx.focus(&item);
-        let editor = item.update(cx, |item, _| item.editor.clone());
+        let editor = item.read_with(cx, |item, _| item.editor.clone());
 
         let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
 
@@ -1741,7 +1782,7 @@ mod tests {
             workspace.active_item_as::<ProjectDiff>(cx).unwrap()
         });
         cx.focus(&item);
-        let editor = item.update(cx, |item, _| item.editor.clone());
+        let editor = item.read_with(cx, |item, _| item.editor.clone());
 
         let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
 
@@ -1754,4 +1795,80 @@ mod tests {
 
         cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
     }
+
+    #[gpui::test]
+    async fn test_saving_resolved_conflicts(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "foo": "<<<<<<< x\nours\n=======\ntheirs\n>>>>>>> y\n",
+            }),
+        )
+        .await;
+        fs.set_status_for_repo(
+            Path::new(path!("/project/.git")),
+            &[(
+                Path::new("foo"),
+                UnmergedStatus {
+                    first_head: UnmergedStatusCode::Updated,
+                    second_head: UnmergedStatusCode::Updated,
+                }
+                .into(),
+            )],
+        );
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let diff = cx.new_window_entity(|window, cx| {
+            ProjectDiff::new(project.clone(), workspace, window, cx)
+        });
+        cx.run_until_parked();
+
+        cx.update(|window, cx| {
+            let editor = diff.read(cx).editor.clone();
+            let excerpt_ids = editor.read(cx).buffer().read(cx).excerpt_ids();
+            assert_eq!(excerpt_ids.len(), 1);
+            let excerpt_id = excerpt_ids[0];
+            let buffer = editor
+                .read(cx)
+                .buffer()
+                .read(cx)
+                .all_buffers()
+                .into_iter()
+                .next()
+                .unwrap();
+            let buffer_id = buffer.read(cx).remote_id();
+            let conflict_set = diff
+                .read(cx)
+                .editor
+                .read(cx)
+                .addon::<ConflictAddon>()
+                .unwrap()
+                .conflict_set(buffer_id)
+                .unwrap();
+            assert!(conflict_set.read(cx).has_conflict);
+            let snapshot = conflict_set.read(cx).snapshot();
+            assert_eq!(snapshot.conflicts.len(), 1);
+
+            let ours_range = snapshot.conflicts[0].ours.clone();
+
+            resolve_conflict(
+                editor.downgrade(),
+                excerpt_id,
+                snapshot.conflicts[0].clone(),
+                vec![ours_range],
+                window,
+                cx,
+            )
+        })
+        .await;
+
+        let contents = fs.read_file_sync(path!("/project/foo")).unwrap();
+        let contents = String::from_utf8(contents).unwrap();
+        assert_eq!(contents, "ours\n");
+    }
 }

crates/git_ui/src/remote_output.rs 🔗

@@ -6,7 +6,7 @@ use util::ResultExt as _;
 
 #[derive(Clone)]
 pub enum RemoteAction {
-    Fetch,
+    Fetch(Option<Remote>),
     Pull(Remote),
     Push(SharedString, Remote),
 }
@@ -14,7 +14,7 @@ pub enum RemoteAction {
 impl RemoteAction {
     pub fn name(&self) -> &'static str {
         match self {
-            RemoteAction::Fetch => "fetch",
+            RemoteAction::Fetch(_) => "fetch",
             RemoteAction::Pull(_) => "pull",
             RemoteAction::Push(_, _) => "push",
         }
@@ -34,15 +34,19 @@ pub struct SuccessMessage {
 
 pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> SuccessMessage {
     match action {
-        RemoteAction::Fetch => {
+        RemoteAction::Fetch(remote) => {
             if output.stderr.is_empty() {
                 SuccessMessage {
                     message: "Already up to date".into(),
                     style: SuccessStyle::Toast,
                 }
             } else {
+                let message = match remote {
+                    Some(remote) => format!("Synchronized with {}", remote.name),
+                    None => "Synchronized with remotes".into(),
+                };
                 SuccessMessage {
-                    message: "Synchronized with remotes".into(),
+                    message,
                     style: SuccessStyle::ToastWithLog { output },
                 }
             }

crates/git_ui/src/repository_selector.rs 🔗

@@ -1,6 +1,4 @@
-use gpui::{
-    AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity,
-};
+use gpui::{App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity};
 use itertools::Itertools;
 use picker::{Picker, PickerDelegate};
 use project::{Project, git_store::Repository};
@@ -207,15 +205,6 @@ impl PickerDelegate for RepositorySelectorDelegate {
             .ok();
     }
 
-    fn render_header(
-        &self,
-        _window: &mut Window,
-        _cx: &mut Context<Picker<Self>>,
-    ) -> Option<AnyElement> {
-        // TODO: Implement header rendering if needed
-        None
-    }
-
     fn render_match(
         &self,
         ix: usize,

crates/go_to_line/src/cursor_position.rs 🔗

@@ -39,15 +39,32 @@ pub struct UserCaretPosition {
 }
 
 impl UserCaretPosition {
-    pub fn at_selection_end(selection: &Selection<Point>, snapshot: &MultiBufferSnapshot) -> Self {
+    pub(crate) fn at_selection_end(
+        selection: &Selection<Point>,
+        snapshot: &MultiBufferSnapshot,
+    ) -> Self {
         let selection_end = selection.head();
-        let line_start = Point::new(selection_end.row, 0);
-        let chars_to_last_position = snapshot
-            .text_summary_for_range::<text::TextSummary, _>(line_start..selection_end)
-            .chars as u32;
+        let (line, character) = if let Some((buffer_snapshot, point, _)) =
+            snapshot.point_to_buffer_point(selection_end)
+        {
+            let line_start = Point::new(point.row, 0);
+
+            let chars_to_last_position = buffer_snapshot
+                .text_summary_for_range::<text::TextSummary, _>(line_start..point)
+                .chars as u32;
+            (line_start.row, chars_to_last_position)
+        } else {
+            let line_start = Point::new(selection_end.row, 0);
+
+            let chars_to_last_position = snapshot
+                .text_summary_for_range::<text::TextSummary, _>(line_start..selection_end)
+                .chars as u32;
+            (selection_end.row, chars_to_last_position)
+        };
+
         Self {
-            line: NonZeroU32::new(selection_end.row + 1).expect("added 1"),
-            character: NonZeroU32::new(chars_to_last_position + 1).expect("added 1"),
+            line: NonZeroU32::new(line + 1).expect("added 1"),
+            character: NonZeroU32::new(character + 1).expect("added 1"),
         }
     }
 }

crates/go_to_line/src/go_to_line.rs 🔗

@@ -2,8 +2,8 @@ pub mod cursor_position;
 
 use cursor_position::{LineIndicatorFormat, UserCaretPosition};
 use editor::{
-    Anchor, Editor, MultiBufferSnapshot, RowHighlightOptions, ToOffset, ToPoint, actions::Tab,
-    scroll::Autoscroll,
+    Anchor, Editor, MultiBufferSnapshot, RowHighlightOptions, SelectionEffects, ToOffset, ToPoint,
+    actions::Tab, scroll::Autoscroll,
 };
 use gpui::{
     App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Styled,
@@ -249,9 +249,12 @@ impl GoToLine {
             let Some(start) = self.anchor_from_query(&snapshot, cx) else {
                 return;
             };
-            editor.change_selections(Some(Autoscroll::center()), window, cx, |s| {
-                s.select_anchor_ranges([start..start])
-            });
+            editor.change_selections(
+                SelectionEffects::scroll(Autoscroll::center()),
+                window,
+                cx,
+                |s| s.select_anchor_ranges([start..start]),
+            );
             editor.focus_handle(cx).focus(window);
             cx.notify()
         });

crates/google_ai/src/google_ai.rs 🔗

@@ -39,8 +39,7 @@ pub async fn stream_generate_content(
                             match serde_json::from_str(line) {
                                 Ok(response) => Some(Ok(response)),
                                 Err(error) => Some(Err(anyhow!(format!(
-                                    "Error parsing JSON: {:?}\n{:?}",
-                                    error, line
+                                    "Error parsing JSON: {error:?}\n{line:?}"
                                 )))),
                             }
                         } else {
@@ -85,15 +84,13 @@ pub async fn count_tokens(
     let mut response = client.send(http_request).await?;
     let mut text = String::new();
     response.body_mut().read_to_string(&mut text).await?;
-    if response.status().is_success() {
-        Ok(serde_json::from_str::<CountTokensResponse>(&text)?)
-    } else {
-        Err(anyhow!(
-            "error during countTokens, status code: {:?}, body: {}",
-            response.status(),
-            text
-        ))
-    }
+    anyhow::ensure!(
+        response.status().is_success(),
+        "error during countTokens, status code: {:?}, body: {}",
+        response.status(),
+        text
+    );
+    Ok(serde_json::from_str::<CountTokensResponse>(&text)?)
 }
 
 pub fn validate_generate_content_request(request: &GenerateContentRequest) -> Result<()> {
@@ -205,6 +202,7 @@ pub enum Part {
     InlineDataPart(InlineDataPart),
     FunctionCallPart(FunctionCallPart),
     FunctionResponsePart(FunctionResponsePart),
+    ThoughtPart(ThoughtPart),
 }
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -238,6 +236,13 @@ pub struct FunctionResponsePart {
     pub function_response: FunctionResponse,
 }
 
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ThoughtPart {
+    pub thought: bool,
+    pub thought_signature: String,
+}
+
 #[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct CitationSource {
@@ -271,17 +276,33 @@ pub struct PromptFeedback {
 #[serde(rename_all = "camelCase")]
 pub struct UsageMetadata {
     #[serde(skip_serializing_if = "Option::is_none")]
-    pub prompt_token_count: Option<usize>,
+    pub prompt_token_count: Option<u64>,
     #[serde(skip_serializing_if = "Option::is_none")]
-    pub cached_content_token_count: Option<usize>,
+    pub cached_content_token_count: Option<u64>,
     #[serde(skip_serializing_if = "Option::is_none")]
-    pub candidates_token_count: Option<usize>,
+    pub candidates_token_count: Option<u64>,
     #[serde(skip_serializing_if = "Option::is_none")]
-    pub tool_use_prompt_token_count: Option<usize>,
+    pub tool_use_prompt_token_count: Option<u64>,
     #[serde(skip_serializing_if = "Option::is_none")]
-    pub thoughts_token_count: Option<usize>,
+    pub thoughts_token_count: Option<u64>,
     #[serde(skip_serializing_if = "Option::is_none")]
-    pub total_token_count: Option<usize>,
+    pub total_token_count: Option<u64>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ThinkingConfig {
+    pub thinking_budget: u32,
+}
+
+#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
+pub enum GoogleModelMode {
+    #[default]
+    Default,
+    Thinking {
+        budget_tokens: Option<u32>,
+    },
 }
 
 #[derive(Debug, Deserialize, Serialize)]
@@ -299,6 +320,8 @@ pub struct GenerationConfig {
     pub top_p: Option<f64>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub top_k: Option<usize>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub thinking_config: Option<ThinkingConfig>,
 }
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -372,7 +395,7 @@ pub struct CountTokensRequest {
 #[derive(Debug, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct CountTokensResponse {
-    pub total_tokens: usize,
+    pub total_tokens: u64,
 }
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -468,83 +491,151 @@ impl<'de> Deserialize<'de> for ModelName {
 pub enum Model {
     #[serde(rename = "gemini-1.5-pro")]
     Gemini15Pro,
+    #[serde(rename = "gemini-1.5-flash-8b")]
+    Gemini15Flash8b,
     #[serde(rename = "gemini-1.5-flash")]
     Gemini15Flash,
-    #[serde(rename = "gemini-2.0-pro-exp")]
-    Gemini20Pro,
+    #[serde(
+        rename = "gemini-2.0-flash-lite",
+        alias = "gemini-2.0-flash-lite-preview"
+    )]
+    Gemini20FlashLite,
     #[serde(rename = "gemini-2.0-flash")]
-    #[default]
     Gemini20Flash,
-    #[serde(rename = "gemini-2.0-flash-thinking-exp")]
-    Gemini20FlashThinking,
-    #[serde(rename = "gemini-2.0-flash-lite-preview")]
-    Gemini20FlashLite,
-    #[serde(rename = "gemini-2.5-pro-exp-03-25")]
-    Gemini25ProExp0325,
-    #[serde(rename = "gemini-2.5-pro-preview-03-25")]
-    Gemini25ProPreview0325,
-    #[serde(rename = "gemini-2.5-flash-preview-04-17")]
-    Gemini25FlashPreview0417,
+    #[serde(
+        rename = "gemini-2.5-flash-lite-preview",
+        alias = "gemini-2.5-flash-lite-preview-06-17"
+    )]
+    Gemini25FlashLitePreview,
+    #[serde(
+        rename = "gemini-2.5-flash",
+        alias = "gemini-2.0-flash-thinking-exp",
+        alias = "gemini-2.5-flash-preview-04-17",
+        alias = "gemini-2.5-flash-preview-05-20",
+        alias = "gemini-2.5-flash-preview-latest"
+    )]
+    #[default]
+    Gemini25Flash,
+    #[serde(
+        rename = "gemini-2.5-pro",
+        alias = "gemini-2.0-pro-exp",
+        alias = "gemini-2.5-pro-preview-latest",
+        alias = "gemini-2.5-pro-exp-03-25",
+        alias = "gemini-2.5-pro-preview-03-25",
+        alias = "gemini-2.5-pro-preview-05-06",
+        alias = "gemini-2.5-pro-preview-06-05"
+    )]
+    Gemini25Pro,
     #[serde(rename = "custom")]
     Custom {
         name: String,
         /// The name displayed in the UI, such as in the assistant panel model dropdown menu.
         display_name: Option<String>,
-        max_tokens: usize,
+        max_tokens: u64,
+        #[serde(default)]
+        mode: GoogleModelMode,
     },
 }
 
 impl Model {
-    pub fn default_fast() -> Model {
-        Model::Gemini15Flash
+    pub fn default_fast() -> Self {
+        Self::Gemini20FlashLite
     }
 
     pub fn id(&self) -> &str {
         match self {
-            Model::Gemini15Pro => "gemini-1.5-pro",
-            Model::Gemini15Flash => "gemini-1.5-flash",
-            Model::Gemini20Pro => "gemini-2.0-pro-exp",
-            Model::Gemini20Flash => "gemini-2.0-flash",
-            Model::Gemini20FlashThinking => "gemini-2.0-flash-thinking-exp",
-            Model::Gemini20FlashLite => "gemini-2.0-flash-lite-preview",
-            Model::Gemini25ProExp0325 => "gemini-2.5-pro-exp-03-25",
-            Model::Gemini25ProPreview0325 => "gemini-2.5-pro-preview-03-25",
-            Model::Gemini25FlashPreview0417 => "gemini-2.5-flash-preview-04-17",
-            Model::Custom { name, .. } => name,
+            Self::Gemini15Pro => "gemini-1.5-pro",
+            Self::Gemini15Flash8b => "gemini-1.5-flash-8b",
+            Self::Gemini15Flash => "gemini-1.5-flash",
+            Self::Gemini20FlashLite => "gemini-2.0-flash-lite",
+            Self::Gemini20Flash => "gemini-2.0-flash",
+            Self::Gemini25FlashLitePreview => "gemini-2.5-flash-lite-preview",
+            Self::Gemini25Flash => "gemini-2.5-flash",
+            Self::Gemini25Pro => "gemini-2.5-pro",
+            Self::Custom { name, .. } => name,
+        }
+    }
+    pub fn request_id(&self) -> &str {
+        match self {
+            Self::Gemini15Pro => "gemini-1.5-pro",
+            Self::Gemini15Flash8b => "gemini-1.5-flash-8b",
+            Self::Gemini15Flash => "gemini-1.5-flash",
+            Self::Gemini20FlashLite => "gemini-2.0-flash-lite",
+            Self::Gemini20Flash => "gemini-2.0-flash",
+            Self::Gemini25FlashLitePreview => "gemini-2.5-flash-lite-preview-06-17",
+            Self::Gemini25Flash => "gemini-2.5-flash",
+            Self::Gemini25Pro => "gemini-2.5-pro",
+            Self::Custom { name, .. } => name,
         }
     }
 
     pub fn display_name(&self) -> &str {
         match self {
-            Model::Gemini15Pro => "Gemini 1.5 Pro",
-            Model::Gemini15Flash => "Gemini 1.5 Flash",
-            Model::Gemini20Pro => "Gemini 2.0 Pro",
-            Model::Gemini20Flash => "Gemini 2.0 Flash",
-            Model::Gemini20FlashThinking => "Gemini 2.0 Flash Thinking",
-            Model::Gemini20FlashLite => "Gemini 2.0 Flash Lite",
-            Model::Gemini25ProExp0325 => "Gemini 2.5 Pro Exp",
-            Model::Gemini25ProPreview0325 => "Gemini 2.5 Pro Preview",
-            Model::Gemini25FlashPreview0417 => "Gemini 2.5 Flash Preview",
+            Self::Gemini15Pro => "Gemini 1.5 Pro",
+            Self::Gemini15Flash8b => "Gemini 1.5 Flash-8b",
+            Self::Gemini15Flash => "Gemini 1.5 Flash",
+            Self::Gemini20FlashLite => "Gemini 2.0 Flash-Lite",
+            Self::Gemini20Flash => "Gemini 2.0 Flash",
+            Self::Gemini25FlashLitePreview => "Gemini 2.5 Flash-Lite Preview",
+            Self::Gemini25Flash => "Gemini 2.5 Flash",
+            Self::Gemini25Pro => "Gemini 2.5 Pro",
             Self::Custom {
                 name, display_name, ..
             } => display_name.as_ref().unwrap_or(name),
         }
     }
 
-    pub fn max_token_count(&self) -> usize {
-        const ONE_MILLION: usize = 1_048_576;
-        const TWO_MILLION: usize = 2_097_152;
+    pub fn max_token_count(&self) -> u64 {
         match self {
-            Model::Gemini15Pro => TWO_MILLION,
-            Model::Gemini15Flash => ONE_MILLION,
-            Model::Gemini20Pro => TWO_MILLION,
-            Model::Gemini20Flash => ONE_MILLION,
-            Model::Gemini20FlashThinking => ONE_MILLION,
-            Model::Gemini20FlashLite => ONE_MILLION,
-            Model::Gemini25ProExp0325 => ONE_MILLION,
-            Model::Gemini25ProPreview0325 => ONE_MILLION,
-            Model::Gemini25FlashPreview0417 => ONE_MILLION,
-            Model::Custom { max_tokens, .. } => *max_tokens,
+            Self::Gemini15Pro => 2_097_152,
+            Self::Gemini15Flash8b => 1_048_576,
+            Self::Gemini15Flash => 1_048_576,
+            Self::Gemini20FlashLite => 1_048_576,
+            Self::Gemini20Flash => 1_048_576,
+            Self::Gemini25FlashLitePreview => 1_000_000,
+            Self::Gemini25Flash => 1_048_576,
+            Self::Gemini25Pro => 1_048_576,
+            Self::Custom { max_tokens, .. } => *max_tokens,
+        }
+    }
+
+    pub fn max_output_tokens(&self) -> Option<u64> {
+        match self {
+            Model::Gemini15Pro => Some(8_192),
+            Model::Gemini15Flash8b => Some(8_192),
+            Model::Gemini15Flash => Some(8_192),
+            Model::Gemini20FlashLite => Some(8_192),
+            Model::Gemini20Flash => Some(8_192),
+            Model::Gemini25FlashLitePreview => Some(64_000),
+            Model::Gemini25Flash => Some(65_536),
+            Model::Gemini25Pro => Some(65_536),
+            Model::Custom { .. } => None,
+        }
+    }
+
+    pub fn supports_tools(&self) -> bool {
+        true
+    }
+
+    pub fn supports_images(&self) -> bool {
+        true
+    }
+
+    pub fn mode(&self) -> GoogleModelMode {
+        match self {
+            Self::Gemini15Pro
+            | Self::Gemini15Flash8b
+            | Self::Gemini15Flash
+            | Self::Gemini20FlashLite
+            | Self::Gemini20Flash => GoogleModelMode::Default,
+            Self::Gemini25FlashLitePreview | Self::Gemini25Flash | Self::Gemini25Pro => {
+                GoogleModelMode::Thinking {
+                    // By default these models are set to "auto", so we preserve that behavior
+                    // but indicate they are capable of thinking mode
+                    budget_tokens: None,
+                }
+            }
+            Self::Custom { mode, .. } => *mode,
         }
     }
 }

crates/gpui/Cargo.toml 🔗

@@ -22,6 +22,7 @@ test-support = [
     "wayland",
     "x11",
 ]
+inspector = ["gpui_macros/inspector"]
 leak-detection = ["backtrace"]
 runtime_shaders = []
 macos-blade = [
@@ -125,6 +126,7 @@ uuid.workspace = true
 waker-fn = "1.2.0"
 lyon = "1.0"
 workspace-hack.workspace = true
+libc.workspace = true
 
 [target.'cfg(target_os = "macos")'.dependencies]
 block = "0.1"
@@ -257,6 +259,10 @@ path = "examples/image/image.rs"
 name = "input"
 path = "examples/input.rs"
 
+[[example]]
+name = "on_window_close_quit"
+path = "examples/on_window_close_quit.rs"
+
 [[example]]
 name = "opacity"
 path = "examples/opacity.rs"
@@ -277,6 +283,10 @@ path = "examples/shadow.rs"
 name = "svg"
 path = "examples/svg/svg.rs"
 
+[[example]]
+name = "text"
+path = "examples/text.rs"
+
 [[example]]
 name = "text_wrapper"
 path = "examples/text_wrapper.rs"
@@ -288,7 +298,3 @@ path = "examples/uniform_list.rs"
 [[example]]
 name = "window_shadow"
 path = "examples/window_shadow.rs"
-
-[[example]]
-name = "on_window_close_quit"
-path = "examples/on_window_close_quit.rs"

crates/gpui/examples/data_table.rs 🔗

@@ -1,8 +1,4 @@
-use std::{
-    ops::Range,
-    rc::Rc,
-    time::{Duration, Instant},
-};
+use std::{ops::Range, rc::Rc, time::Duration};
 
 use gpui::{
     App, Application, Bounds, Context, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point,
@@ -22,7 +18,7 @@ pub struct Quote {
     open: f64,
     high: f64,
     low: f64,
-    timestamp: Instant,
+    timestamp: Duration,
     volume: i64,
     turnover: f64,
     ttm: f64,
@@ -50,8 +46,7 @@ impl Quote {
         let open = prev_close + rng.gen_range(-3.0..3.0);
         let high = (prev_close + rng.gen_range::<f64, _>(0.0..10.0)).max(open);
         let low = (prev_close - rng.gen_range::<f64, _>(0.0..10.0)).min(open);
-        // Randomize the timestamp in the past 24 hours
-        let timestamp = Instant::now() - Duration::from_secs(rng.gen_range(0..86400));
+        let timestamp = Duration::from_secs(rng.gen_range(0..86400));
         let volume = rng.gen_range(1_000_000..100_000_000);
         let turnover = last_done * volume as f64;
         let symbol = {
@@ -170,7 +165,7 @@ impl TableRow {
                     .child(format!("{:.2}%", self.quote.change())),
                 "timestamp" => div()
                     .text_color(color)
-                    .child(format!("{:?}", self.quote.timestamp.elapsed().as_secs())),
+                    .child(format!("{:?}", self.quote.timestamp.as_secs())),
                 "open" => div()
                     .text_color(color)
                     .child(format!("{:.2}", self.quote.open)),
@@ -378,8 +373,6 @@ impl DataTable {
 
 impl Render for DataTable {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let entity = cx.entity();
-
         div()
             .font_family(".SystemUIFont")
             .bg(gpui::white())
@@ -431,8 +424,10 @@ impl Render for DataTable {
                             .relative()
                             .size_full()
                             .child(
-                                uniform_list(entity, "items", self.quotes.len(), {
-                                    move |this, range, _, _| {
+                                uniform_list(
+                                    "items",
+                                    self.quotes.len(),
+                                    cx.processor(move |this, range: Range<usize>, _, _| {
                                         this.visible_range = range.clone();
                                         let mut items = Vec::with_capacity(range.end - range.start);
                                         for i in range {
@@ -441,8 +436,8 @@ impl Render for DataTable {
                                             }
                                         }
                                         items
-                                    }
-                                })
+                                    }),
+                                )
                                 .size_full()
                                 .track_scroll(self.scroll_handle.clone()),
                             )

crates/gpui/examples/image_loading.rs 🔗

@@ -1,6 +1,5 @@
 use std::{path::Path, sync::Arc, time::Duration};
 
-use anyhow::anyhow;
 use gpui::{
     Animation, AnimationExt, App, Application, Asset, AssetLogger, AssetSource, Bounds, Context,
     Hsla, ImageAssetLoader, ImageCacheError, ImgResourceLoader, LOADING_DELAY, Length, Pixels,
@@ -57,7 +56,7 @@ impl Asset for LoadImageWithParameters {
             timer.await;
             if parameters.fail {
                 log::error!("Intentionally failed to load image");
-                Err(anyhow!("Failed to load image").into())
+                Err(anyhow::anyhow!("Failed to load image").into())
             } else {
                 data.await
             }
@@ -177,7 +176,7 @@ impl Render for ImageLoadingExample {
                         )
                         .to_path_buf();
                         img(image_source.clone())
-                            .id("image-1")
+                            .id("image-4")
                             .border_1()
                             .size_12()
                             .with_fallback(|| Self::fallback_element().into_any_element())

crates/gpui/examples/input.rs 🔗

@@ -26,6 +26,7 @@ actions!(
         Paste,
         Cut,
         Copy,
+        Quit,
     ]
 );
 
@@ -404,16 +405,20 @@ impl IntoElement for TextElement {
 
 impl Element for TextElement {
     type RequestLayoutState = ();
-
     type PrepaintState = PrepaintState;
 
     fn id(&self) -> Option<ElementId> {
         None
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
@@ -426,6 +431,7 @@ impl Element for TextElement {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
         bounds: Bounds<Pixels>,
         _request_layout: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -481,8 +487,7 @@ impl Element for TextElement {
         let font_size = style.font_size.to_pixels(window.rem_size());
         let line = window
             .text_system()
-            .shape_line(display_text, font_size, &runs)
-            .unwrap();
+            .shape_line(display_text, font_size, &runs);
 
         let cursor_pos = line.x_for_index(cursor);
         let (selection, cursor) = if selected_range.is_empty() {
@@ -524,6 +529,7 @@ impl Element for TextElement {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
         bounds: Bounds<Pixels>,
         _request_layout: &mut Self::RequestLayoutState,
         prepaint: &mut Self::PrepaintState,
@@ -736,5 +742,7 @@ fn main() {
                 cx.activate(true);
             })
             .unwrap();
+        cx.on_action(|_: &Quit, cx| cx.quit());
+        cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
     });
 }

crates/gpui/examples/opacity.rs 🔗

@@ -121,7 +121,7 @@ impl Render for HelloWorld {
                             .bg(gpui::blue())
                             .border_3()
                             .border_color(gpui::black())
-                            .shadow(smallvec::smallvec![BoxShadow {
+                            .shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.5),
                                 blur_radius: px(1.0),
                                 spread_radius: px(5.0),

crates/gpui/examples/painting.rs 🔗

@@ -1,13 +1,14 @@
 use gpui::{
     Application, Background, Bounds, ColorSpace, Context, MouseDownEvent, Path, PathBuilder,
-    PathStyle, Pixels, Point, Render, StrokeOptions, Window, WindowOptions, bounds, canvas, div,
-    linear_color_stop, linear_gradient, point, prelude::*, px, rgb, size,
+    PathStyle, Pixels, Point, Render, SharedString, StrokeOptions, Window, WindowOptions, bounds,
+    canvas, div, linear_color_stop, linear_gradient, point, prelude::*, px, rgb, size,
 };
 
 struct PaintingViewer {
     default_lines: Vec<(Path<Pixels>, Background)>,
     lines: Vec<Vec<Point<Pixels>>>,
     start: Point<Pixels>,
+    dashed: bool,
     _painting: bool,
 }
 
@@ -27,10 +28,15 @@ impl PaintingViewer {
 
         // draw a lightening bolt ⚡
         let mut builder = PathBuilder::fill();
-        builder.move_to(point(px(150.), px(200.)));
-        builder.line_to(point(px(200.), px(125.)));
-        builder.line_to(point(px(200.), px(175.)));
-        builder.line_to(point(px(250.), px(100.)));
+        builder.add_polygon(
+            &[
+                point(px(150.), px(200.)),
+                point(px(200.), px(125.)),
+                point(px(200.), px(175.)),
+                point(px(250.), px(100.)),
+            ],
+            false,
+        );
         let path = builder.build().unwrap();
         lines.push((path, rgb(0x1d4ed8).into()));
 
@@ -58,6 +64,7 @@ impl PaintingViewer {
             .color_space(ColorSpace::Oklab),
         ));
 
+        // draw linear gradient
         let square_bounds = Bounds {
             origin: point(px(450.), px(100.)),
             size: size(px(200.), px(80.)),
@@ -87,13 +94,54 @@ impl PaintingViewer {
             ),
         ));
 
+        // draw a pie chart
+        let center = point(px(96.), px(96.));
+        let pie_center = point(px(775.), px(155.));
+        let segments = [
+            (
+                point(px(871.), px(155.)),
+                point(px(747.), px(63.)),
+                rgb(0x1374e9),
+            ),
+            (
+                point(px(747.), px(63.)),
+                point(px(679.), px(163.)),
+                rgb(0xe13527),
+            ),
+            (
+                point(px(679.), px(163.)),
+                point(px(754.), px(249.)),
+                rgb(0x0751ce),
+            ),
+            (
+                point(px(754.), px(249.)),
+                point(px(854.), px(210.)),
+                rgb(0x209742),
+            ),
+            (
+                point(px(854.), px(210.)),
+                point(px(871.), px(155.)),
+                rgb(0xfbc10a),
+            ),
+        ];
+
+        for (start, end, color) in segments {
+            let mut builder = PathBuilder::fill();
+            builder.move_to(start);
+            builder.arc_to(center, px(0.), false, false, end);
+            builder.line_to(pie_center);
+            builder.close();
+            let path = builder.build().unwrap();
+            lines.push((path, color.into()));
+        }
+
         // draw a wave
         let options = StrokeOptions::default()
             .with_line_width(1.)
             .with_line_join(lyon::path::LineJoin::Bevel);
         let mut builder = PathBuilder::stroke(px(1.)).with_style(PathStyle::Stroke(options));
         builder.move_to(point(px(40.), px(320.)));
-        for i in 0..50 {
+        for i in 1..50 {
             builder.line_to(point(
                 px(40.0 + i as f32 * 10.0),
                 px(320.0 + (i as f32 * 10.0).sin() * 40.0),
@@ -114,6 +162,7 @@ impl PaintingViewer {
             default_lines: lines.clone(),
             lines: vec![],
             start: point(px(0.), px(0.)),
+            dashed: false,
             _painting: false,
         }
     }
@@ -123,10 +172,30 @@ impl PaintingViewer {
         cx.notify();
     }
 }
+
+fn button(
+    text: &str,
+    cx: &mut Context<PaintingViewer>,
+    on_click: impl Fn(&mut PaintingViewer, &mut Context<PaintingViewer>) + 'static,
+) -> impl IntoElement {
+    div()
+        .id(SharedString::from(text.to_string()))
+        .child(text.to_string())
+        .bg(gpui::black())
+        .text_color(gpui::white())
+        .active(|this| this.opacity(0.8))
+        .flex()
+        .px_3()
+        .py_1()
+        .on_click(cx.listener(move |this, _, _, cx| on_click(this, cx)))
+}
+
 impl Render for PaintingViewer {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let default_lines = self.default_lines.clone();
         let lines = self.lines.clone();
+        let dashed = self.dashed;
+
         div()
             .font_family(".SystemUIFont")
             .bg(gpui::white())
@@ -143,17 +212,14 @@ impl Render for PaintingViewer {
                     .child("Mouse down any point and drag to draw lines (Hold on shift key to draw straight lines)")
                     .child(
                         div()
-                            .id("clear")
-                            .child("Clean up")
-                            .bg(gpui::black())
-                            .text_color(gpui::white())
-                            .active(|this| this.opacity(0.8))
                             .flex()
-                            .px_3()
-                            .py_1()
-                            .on_click(cx.listener(|this, _, _, cx| {
-                                this.clear(cx);
-                            })),
+                            .gap_x_2()
+                            .child(button(
+                                if dashed { "Solid" } else { "Dashed" },
+                                cx,
+                                move |this, _| this.dashed = !dashed,
+                            ))
+                            .child(button("Clear", cx, |this, cx| this.clear(cx))),
                     ),
             )
             .child(
@@ -163,7 +229,6 @@ impl Render for PaintingViewer {
                         canvas(
                             move |_, _, _| {},
                             move |_, _, window, _| {
-
                                 for (path, color) in default_lines {
                                     window.paint_path(path, color);
                                 }
@@ -174,6 +239,9 @@ impl Render for PaintingViewer {
                                     }
 
                                     let mut builder = PathBuilder::stroke(px(1.));
+                                    if dashed {
+                                        builder = builder.dash_array(&[px(4.), px(2.)]);
+                                    }
                                     for (i, p) in points.into_iter().enumerate() {
                                         if i == 0 {
                                             builder.move_to(p);

crates/gpui/examples/scrollable.rs 🔗

@@ -0,0 +1,60 @@
+use gpui::{
+    App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px,
+    size,
+};
+
+struct Scrollable {}
+
+impl Render for Scrollable {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        div()
+            .size_full()
+            .id("vertical")
+            .p_4()
+            .overflow_scroll()
+            .bg(gpui::white())
+            .child("Example for test 2 way scroll in nested layout")
+            .child(
+                div()
+                    .h(px(5000.))
+                    .border_1()
+                    .border_color(gpui::blue())
+                    .bg(gpui::blue().opacity(0.05))
+                    .p_4()
+                    .child(
+                        div()
+                            .mb_5()
+                            .w_full()
+                            .id("horizontal")
+                            .overflow_scroll()
+                            .child(
+                                div()
+                                    .w(px(2000.))
+                                    .h(px(150.))
+                                    .bg(gpui::green().opacity(0.1))
+                                    .hover(|this| this.bg(gpui::green().opacity(0.2)))
+                                    .border_1()
+                                    .border_color(gpui::green())
+                                    .p_4()
+                                    .child("Scroll Horizontal"),
+                            ),
+                    )
+                    .child("Scroll Vertical"),
+            )
+    }
+}
+
+fn main() {
+    Application::new().run(|cx: &mut App| {
+        let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                ..Default::default()
+            },
+            |_, cx| cx.new(|_| Scrollable {}),
+        )
+        .unwrap();
+        cx.activate(true);
+    });
+}

crates/gpui/examples/shadow.rs 🔗

@@ -3,8 +3,6 @@ use gpui::{
     WindowOptions, div, hsla, point, prelude::*, px, relative, rgb, size,
 };
 
-use smallvec::smallvec;
-
 struct Shadow {}
 
 impl Shadow {
@@ -103,7 +101,7 @@ impl Render for Shadow {
                         example(
                             "Square",
                             Shadow::square()
-                                .shadow(smallvec![BoxShadow {
+                                .shadow(vec![BoxShadow {
                                     color: hsla(0.0, 0.5, 0.5, 0.3),
                                     offset: point(px(0.), px(8.)),
                                     blur_radius: px(8.),
@@ -113,7 +111,7 @@ impl Render for Shadow {
                         example(
                             "Rounded 4",
                             Shadow::rounded_small()
-                                .shadow(smallvec![BoxShadow {
+                                .shadow(vec![BoxShadow {
                                     color: hsla(0.0, 0.5, 0.5, 0.3),
                                     offset: point(px(0.), px(8.)),
                                     blur_radius: px(8.),
@@ -123,7 +121,7 @@ impl Render for Shadow {
                         example(
                             "Rounded 8",
                             Shadow::rounded_medium()
-                                .shadow(smallvec![BoxShadow {
+                                .shadow(vec![BoxShadow {
                                     color: hsla(0.0, 0.5, 0.5, 0.3),
                                     offset: point(px(0.), px(8.)),
                                     blur_radius: px(8.),
@@ -133,7 +131,7 @@ impl Render for Shadow {
                         example(
                             "Rounded 16",
                             Shadow::rounded_large()
-                                .shadow(smallvec![BoxShadow {
+                                .shadow(vec![BoxShadow {
                                     color: hsla(0.0, 0.5, 0.5, 0.3),
                                     offset: point(px(0.), px(8.)),
                                     blur_radius: px(8.),
@@ -143,7 +141,7 @@ impl Render for Shadow {
                         example(
                             "Circle",
                             Shadow::base()
-                                .shadow(smallvec![BoxShadow {
+                                .shadow(vec![BoxShadow {
                                     color: hsla(0.0, 0.5, 0.5, 0.3),
                                     offset: point(px(0.), px(8.)),
                                     blur_radius: px(8.),
@@ -175,7 +173,7 @@ impl Render for Shadow {
                     .children(vec![
                         example(
                             "Blur 0",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(0.),
@@ -184,7 +182,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Blur 2",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(2.),
@@ -193,7 +191,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Blur 4",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(4.),
@@ -202,7 +200,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Blur 8",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -211,7 +209,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Blur 16",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(16.),
@@ -227,7 +225,7 @@ impl Render for Shadow {
                     .children(vec![
                         example(
                             "Spread 0",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -236,7 +234,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Spread 2",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -245,7 +243,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Spread 4",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -254,7 +252,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Spread 8",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -263,7 +261,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Spread 16",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -279,7 +277,7 @@ impl Render for Shadow {
                     .children(vec![
                         example(
                             "Square Spread 0",
-                            Shadow::square().shadow(smallvec![BoxShadow {
+                            Shadow::square().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -288,7 +286,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Square Spread 8",
-                            Shadow::square().shadow(smallvec![BoxShadow {
+                            Shadow::square().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -297,7 +295,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Square Spread 16",
-                            Shadow::square().shadow(smallvec![BoxShadow {
+                            Shadow::square().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -313,7 +311,7 @@ impl Render for Shadow {
                     .children(vec![
                         example(
                             "Rounded Large Spread 0",
-                            Shadow::rounded_large().shadow(smallvec![BoxShadow {
+                            Shadow::rounded_large().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -322,7 +320,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Rounded Large Spread 8",
-                            Shadow::rounded_large().shadow(smallvec![BoxShadow {
+                            Shadow::rounded_large().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -331,7 +329,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Rounded Large Spread 16",
-                            Shadow::rounded_large().shadow(smallvec![BoxShadow {
+                            Shadow::rounded_large().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.0, 0.0, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -347,7 +345,7 @@ impl Render for Shadow {
                     .children(vec![
                         example(
                             "Left",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.5, 0.5, 0.3),
                                 offset: point(px(-8.), px(0.)),
                                 blur_radius: px(8.),
@@ -356,7 +354,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Right",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.5, 0.5, 0.3),
                                 offset: point(px(8.), px(0.)),
                                 blur_radius: px(8.),
@@ -365,7 +363,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Top",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.5, 0.5, 0.3),
                                 offset: point(px(0.), px(-8.)),
                                 blur_radius: px(8.),
@@ -374,7 +372,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Bottom",
-                            Shadow::base().shadow(smallvec![BoxShadow {
+                            Shadow::base().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.5, 0.5, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -390,7 +388,7 @@ impl Render for Shadow {
                     .children(vec![
                         example(
                             "Square Left",
-                            Shadow::square().shadow(smallvec![BoxShadow {
+                            Shadow::square().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.5, 0.5, 0.3),
                                 offset: point(px(-8.), px(0.)),
                                 blur_radius: px(8.),
@@ -399,7 +397,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Square Right",
-                            Shadow::square().shadow(smallvec![BoxShadow {
+                            Shadow::square().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.5, 0.5, 0.3),
                                 offset: point(px(8.), px(0.)),
                                 blur_radius: px(8.),
@@ -408,7 +406,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Square Top",
-                            Shadow::square().shadow(smallvec![BoxShadow {
+                            Shadow::square().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.5, 0.5, 0.3),
                                 offset: point(px(0.), px(-8.)),
                                 blur_radius: px(8.),
@@ -417,7 +415,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Square Bottom",
-                            Shadow::square().shadow(smallvec![BoxShadow {
+                            Shadow::square().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.5, 0.5, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -433,7 +431,7 @@ impl Render for Shadow {
                     .children(vec![
                         example(
                             "Rounded Large Left",
-                            Shadow::rounded_large().shadow(smallvec![BoxShadow {
+                            Shadow::rounded_large().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.5, 0.5, 0.3),
                                 offset: point(px(-8.), px(0.)),
                                 blur_radius: px(8.),
@@ -442,7 +440,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Rounded Large Right",
-                            Shadow::rounded_large().shadow(smallvec![BoxShadow {
+                            Shadow::rounded_large().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.5, 0.5, 0.3),
                                 offset: point(px(8.), px(0.)),
                                 blur_radius: px(8.),
@@ -451,7 +449,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Rounded Large Top",
-                            Shadow::rounded_large().shadow(smallvec![BoxShadow {
+                            Shadow::rounded_large().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.5, 0.5, 0.3),
                                 offset: point(px(0.), px(-8.)),
                                 blur_radius: px(8.),
@@ -460,7 +458,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Rounded Large Bottom",
-                            Shadow::rounded_large().shadow(smallvec![BoxShadow {
+                            Shadow::rounded_large().shadow(vec![BoxShadow {
                                 color: hsla(0.0, 0.5, 0.5, 0.3),
                                 offset: point(px(0.), px(8.)),
                                 blur_radius: px(8.),
@@ -476,7 +474,7 @@ impl Render for Shadow {
                     .children(vec![
                         example(
                             "Circle Multiple",
-                            Shadow::base().shadow(smallvec![
+                            Shadow::base().shadow(vec![
                                 BoxShadow {
                                     color: hsla(0.0 / 360., 1.0, 0.5, 0.3), // Red
                                     offset: point(px(0.), px(-12.)),
@@ -505,7 +503,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Square Multiple",
-                            Shadow::square().shadow(smallvec![
+                            Shadow::square().shadow(vec![
                                 BoxShadow {
                                     color: hsla(0.0 / 360., 1.0, 0.5, 0.3), // Red
                                     offset: point(px(0.), px(-12.)),
@@ -534,7 +532,7 @@ impl Render for Shadow {
                         ),
                         example(
                             "Rounded Large Multiple",
-                            Shadow::rounded_large().shadow(smallvec![
+                            Shadow::rounded_large().shadow(vec![
                                 BoxShadow {
                                     color: hsla(0.0 / 360., 1.0, 0.5, 0.3), // Red
                                     offset: point(px(0.), px(-12.)),

crates/gpui/examples/text.rs 🔗

@@ -0,0 +1,333 @@
+use std::{
+    ops::{Deref, DerefMut},
+    sync::Arc,
+};
+
+use gpui::{
+    AbsoluteLength, App, Application, Context, DefiniteLength, ElementId, Global, Hsla, Menu,
+    SharedString, TextStyle, TitlebarOptions, Window, WindowBounds, WindowOptions, bounds,
+    colors::DefaultColors, div, point, prelude::*, px, relative, rgb, size,
+};
+use std::iter;
+
+#[derive(Clone, Debug)]
+pub struct TextContext {
+    font_size: f32,
+    line_height: f32,
+    type_scale: f32,
+}
+
+impl Default for TextContext {
+    fn default() -> Self {
+        TextContext {
+            font_size: 16.0,
+            line_height: 1.3,
+            type_scale: 1.33,
+        }
+    }
+}
+
+impl TextContext {
+    pub fn get_global(cx: &App) -> &Arc<TextContext> {
+        &cx.global::<GlobalTextContext>().0
+    }
+}
+
+#[derive(Clone, Debug)]
+pub struct GlobalTextContext(pub Arc<TextContext>);
+
+impl Deref for GlobalTextContext {
+    type Target = Arc<TextContext>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl DerefMut for GlobalTextContext {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.0
+    }
+}
+
+impl Global for GlobalTextContext {}
+
+pub trait ActiveTextContext {
+    fn text_context(&self) -> &Arc<TextContext>;
+}
+
+impl ActiveTextContext for App {
+    fn text_context(&self) -> &Arc<TextContext> {
+        &self.global::<GlobalTextContext>().0
+    }
+}
+
+#[derive(Clone, PartialEq)]
+pub struct SpecimenTheme {
+    pub bg: Hsla,
+    pub fg: Hsla,
+}
+
+impl Default for SpecimenTheme {
+    fn default() -> Self {
+        Self {
+            bg: gpui::white(),
+            fg: gpui::black(),
+        }
+    }
+}
+
+impl SpecimenTheme {
+    pub fn invert(&self) -> Self {
+        Self {
+            bg: self.fg,
+            fg: self.bg,
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, IntoElement)]
+struct Specimen {
+    id: ElementId,
+    scale: f32,
+    text_style: Option<TextStyle>,
+    string: SharedString,
+    invert: bool,
+}
+
+impl Specimen {
+    pub fn new(id: usize) -> Self {
+        let string = SharedString::new_static("The quick brown fox jumps over the lazy dog");
+        let id_string = format!("specimen-{}", id);
+        let id = ElementId::Name(id_string.into());
+        Self {
+            id,
+            scale: 1.0,
+            text_style: None,
+            string,
+            invert: false,
+        }
+    }
+
+    pub fn invert(mut self) -> Self {
+        self.invert = !self.invert;
+        self
+    }
+
+    pub fn scale(mut self, scale: f32) -> Self {
+        self.scale = scale;
+        self
+    }
+}
+
+impl RenderOnce for Specimen {
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let rem_size = window.rem_size();
+        let scale = self.scale;
+        let global_style = cx.text_context();
+
+        let style_override = self.text_style;
+
+        let mut font_size = global_style.font_size;
+        let mut line_height = global_style.line_height;
+
+        if let Some(style_override) = style_override {
+            font_size = style_override.font_size.to_pixels(rem_size).0;
+            line_height = match style_override.line_height {
+                DefiniteLength::Absolute(absolute_len) => match absolute_len {
+                    AbsoluteLength::Rems(absolute_len) => absolute_len.to_pixels(rem_size).0,
+                    AbsoluteLength::Pixels(absolute_len) => absolute_len.0,
+                },
+                DefiniteLength::Fraction(value) => value,
+            };
+        }
+
+        let mut theme = SpecimenTheme::default();
+
+        if self.invert {
+            theme = theme.invert();
+        }
+
+        div()
+            .id(self.id)
+            .bg(theme.bg)
+            .text_color(theme.fg)
+            .text_size(px(font_size * scale))
+            .line_height(relative(line_height))
+            .p(px(10.0))
+            .child(self.string.clone())
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, IntoElement)]
+struct CharacterGrid {
+    scale: f32,
+    invert: bool,
+    text_style: Option<TextStyle>,
+}
+
+impl CharacterGrid {
+    pub fn new() -> Self {
+        Self {
+            scale: 1.0,
+            invert: false,
+            text_style: None,
+        }
+    }
+
+    pub fn scale(mut self, scale: f32) -> Self {
+        self.scale = scale;
+        self
+    }
+}
+
+impl RenderOnce for CharacterGrid {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let mut theme = SpecimenTheme::default();
+
+        if self.invert {
+            theme = theme.invert();
+        }
+
+        let characters = vec![
+            "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "A", "B", "C", "D", "E", "F", "G",
+            "H", "I", "J", "K", "L", "M", "N", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y",
+            "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "p", "q",
+            "r", "s", "t", "u", "v", "w", "x", "y", "z", "ẞ", "ſ", "ß", "ð", "Þ", "þ", "α", "β",
+            "Γ", "γ", "Δ", "δ", "η", "θ", "ι", "κ", "Λ", "λ", "μ", "ν", "ξ", "π", "τ", "υ", "φ",
+            "χ", "ψ", "∂", "а", "в", "Ж", "ж", "З", "з", "К", "к", "л", "м", "Н", "н", "Р", "р",
+            "У", "у", "ф", "ч", "ь", "ы", "Э", "э", "Я", "я", "ij", "öẋ", ".,", "⣝⣑", "~", "*",
+            "_", "^", "`", "'", "(", "{", "«", "#", "&", "@", "$", "¢", "%", "|", "?", "¶", "µ",
+            "❮", "<=", "!=", "==", "--", "++", "=>", "->",
+        ];
+
+        let columns = 11;
+        let rows = characters.len().div_ceil(columns);
+
+        let grid_rows = (0..rows).map(|row_idx| {
+            let start_idx = row_idx * columns;
+            let end_idx = (start_idx + columns).min(characters.len());
+
+            div()
+                .w_full()
+                .flex()
+                .flex_row()
+                .children((start_idx..end_idx).map(|i| {
+                    div()
+                        .text_center()
+                        .size(px(62.))
+                        .bg(theme.bg)
+                        .text_color(theme.fg)
+                        .text_size(px(24.0))
+                        .line_height(relative(1.0))
+                        .child(characters[i])
+                }))
+                .when(end_idx - start_idx < columns, |d| {
+                    d.children(
+                        iter::repeat_with(|| div().flex_1()).take(columns - (end_idx - start_idx)),
+                    )
+                })
+        });
+
+        div().p_4().gap_2().flex().flex_col().children(grid_rows)
+    }
+}
+
+struct TextExample {
+    next_id: usize,
+}
+
+impl TextExample {
+    fn next_id(&mut self) -> usize {
+        self.next_id += 1;
+        self.next_id
+    }
+}
+
+impl Render for TextExample {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let tcx = cx.text_context();
+        let colors = cx.default_colors().clone();
+
+        let type_scale = tcx.type_scale;
+
+        let step_down_2 = 1.0 / (type_scale * type_scale);
+        let step_down_1 = 1.0 / type_scale;
+        let base = 1.0;
+        let step_up_1 = base * type_scale;
+        let step_up_2 = step_up_1 * type_scale;
+        let step_up_3 = step_up_2 * type_scale;
+        let step_up_4 = step_up_3 * type_scale;
+        let step_up_5 = step_up_4 * type_scale;
+        let step_up_6 = step_up_5 * type_scale;
+
+        div()
+            .size_full()
+            .child(
+                div()
+                    .id("text-example")
+                    .overflow_y_scroll()
+                    .overflow_x_hidden()
+                    .bg(rgb(0xffffff))
+                    .size_full()
+                    .child(div().child(CharacterGrid::new().scale(base)))
+                    .child(
+                        div()
+                            .child(Specimen::new(self.next_id()).scale(step_down_2))
+                            .child(Specimen::new(self.next_id()).scale(step_down_2).invert())
+                            .child(Specimen::new(self.next_id()).scale(step_down_1))
+                            .child(Specimen::new(self.next_id()).scale(step_down_1).invert())
+                            .child(Specimen::new(self.next_id()).scale(base))
+                            .child(Specimen::new(self.next_id()).scale(base).invert())
+                            .child(Specimen::new(self.next_id()).scale(step_up_1))
+                            .child(Specimen::new(self.next_id()).scale(step_up_1).invert())
+                            .child(Specimen::new(self.next_id()).scale(step_up_2))
+                            .child(Specimen::new(self.next_id()).scale(step_up_2).invert())
+                            .child(Specimen::new(self.next_id()).scale(step_up_3))
+                            .child(Specimen::new(self.next_id()).scale(step_up_3).invert())
+                            .child(Specimen::new(self.next_id()).scale(step_up_4))
+                            .child(Specimen::new(self.next_id()).scale(step_up_4).invert())
+                            .child(Specimen::new(self.next_id()).scale(step_up_5))
+                            .child(Specimen::new(self.next_id()).scale(step_up_5).invert())
+                            .child(Specimen::new(self.next_id()).scale(step_up_6))
+                            .child(Specimen::new(self.next_id()).scale(step_up_6).invert()),
+                    ),
+            )
+            .child(div().w(px(240.)).h_full().bg(colors.container))
+    }
+}
+
+fn main() {
+    Application::new().run(|cx: &mut App| {
+        cx.set_menus(vec![Menu {
+            name: "GPUI Typography".into(),
+            items: vec![],
+        }]);
+
+        cx.init_colors();
+        cx.set_global(GlobalTextContext(Arc::new(TextContext::default())));
+
+        let window = cx
+            .open_window(
+                WindowOptions {
+                    titlebar: Some(TitlebarOptions {
+                        title: Some("GPUI Typography".into()),
+                        ..Default::default()
+                    }),
+                    window_bounds: Some(WindowBounds::Windowed(bounds(
+                        point(px(0.0), px(0.0)),
+                        size(px(920.), px(720.)),
+                    ))),
+                    ..Default::default()
+                },
+                |_window, cx| cx.new(|_cx| TextExample { next_id: 0 }),
+            )
+            .unwrap();
+
+        window
+            .update(cx, |_view, _window, cx| {
+                cx.activate(true);
+            })
+            .unwrap();
+    });
+}

crates/gpui/examples/text_wrapper.rs 🔗

@@ -73,7 +73,7 @@ impl Render for HelloWorld {
                     .flex_shrink_0()
                     .text_xl()
                     .overflow_hidden()
-                    .text_overflow(TextOverflow::Ellipsis(""))
+                    .text_overflow(TextOverflow::Truncate("".into()))
                     .border_1()
                     .border_color(gpui::green())
                     .child("TRUNCATE: ".to_owned() + text),
@@ -83,7 +83,7 @@ impl Render for HelloWorld {
                     .flex_shrink_0()
                     .text_xl()
                     .overflow_hidden()
-                    .text_overflow(TextOverflow::Ellipsis(""))
+                    .text_overflow(TextOverflow::Truncate("".into()))
                     .line_clamp(3)
                     .border_1()
                     .border_color(gpui::green())

crates/gpui/examples/uniform_list.rs 🔗

@@ -9,10 +9,9 @@ impl Render for UniformListExample {
     fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         div().size_full().bg(rgb(0xffffff)).child(
             uniform_list(
-                cx.entity().clone(),
                 "entries",
                 50,
-                |_this, range, _window, _cx| {
+                cx.processor(|_this, range, _window, _cx| {
                     let mut items = Vec::new();
                     for ix in range {
                         let item = ix + 1;
@@ -29,7 +28,7 @@ impl Render for UniformListExample {
                         );
                     }
                     items
-                },
+                }),
             )
             .h_full(),
         )

crates/gpui/examples/window.rs 🔗

@@ -1,6 +1,6 @@
 use gpui::{
-    App, Application, Bounds, Context, SharedString, Timer, Window, WindowBounds, WindowKind,
-    WindowOptions, div, prelude::*, px, rgb, size,
+    App, Application, Bounds, Context, KeyBinding, PromptButton, PromptLevel, SharedString, Timer,
+    Window, WindowBounds, WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size,
 };
 
 struct SubWindow {
@@ -169,9 +169,47 @@ impl Render for WindowDemo {
                 let content_size = window.bounds().size;
                 window.resize(size(content_size.height, content_size.width));
             }))
+            .child(button("Prompt", |window, cx| {
+                let answer = window.prompt(
+                    PromptLevel::Info,
+                    "Are you sure?",
+                    None,
+                    &["Ok", "Cancel"],
+                    cx,
+                );
+
+                cx.spawn(async move |_| {
+                    if answer.await.unwrap() == 0 {
+                        println!("You have clicked Ok");
+                    } else {
+                        println!("You have clicked Cancel");
+                    }
+                })
+                .detach();
+            }))
+            .child(button("Prompt (non-English)", |window, cx| {
+                let answer = window.prompt(
+                    PromptLevel::Info,
+                    "Are you sure?",
+                    None,
+                    &[PromptButton::ok("确定"), PromptButton::cancel("取消")],
+                    cx,
+                );
+
+                cx.spawn(async move |_| {
+                    if answer.await.unwrap() == 0 {
+                        println!("You have clicked Ok");
+                    } else {
+                        println!("You have clicked Cancel");
+                    }
+                })
+                .detach();
+            }))
     }
 }
 
+actions!(window, [Quit]);
+
 fn main() {
     Application::new().run(|cx: &mut App| {
         let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx);
@@ -193,5 +231,9 @@ fn main() {
             },
         )
         .unwrap();
+
+        cx.activate(true);
+        cx.on_action(|_: &Quit, cx| cx.quit());
+        cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
     });
 }

crates/gpui/examples/window_shadow.rs 🔗

@@ -1,8 +1,8 @@
 use gpui::{
-    App, Application, Bounds, Context, CursorStyle, Decorations, Hsla, MouseButton, Pixels, Point,
-    ResizeEdge, Size, Window, WindowBackgroundAppearance, WindowBounds, WindowDecorations,
-    WindowOptions, black, canvas, div, green, point, prelude::*, px, rgb, size, transparent_black,
-    white,
+    App, Application, Bounds, Context, CursorStyle, Decorations, HitboxBehavior, Hsla, MouseButton,
+    Pixels, Point, ResizeEdge, Size, Window, WindowBackgroundAppearance, WindowBounds,
+    WindowDecorations, WindowOptions, black, canvas, div, green, point, prelude::*, px, rgb, size,
+    transparent_black, white,
 };
 
 struct WindowShadow {}
@@ -37,7 +37,7 @@ impl Render for WindowShadow {
                                         point(px(0.0), px(0.0)),
                                         window.window_bounds().get_bounds().size,
                                     ),
-                                    false,
+                                    HitboxBehavior::Normal,
                                 )
                             },
                             move |_bounds, hitbox, window, _cx| {
@@ -61,7 +61,7 @@ impl Render for WindowShadow {
                                             CursorStyle::ResizeUpRightDownLeft
                                         }
                                     },
-                                    Some(&hitbox),
+                                    &hitbox,
                                 );
                             },
                         )
@@ -104,7 +104,7 @@ impl Render for WindowShadow {
                             .when(!tiling.left, |div| div.border_l(border_size))
                             .when(!tiling.right, |div| div.border_r(border_size))
                             .when(!tiling.is_tiled(), |div| {
-                                div.shadow(smallvec::smallvec![gpui::BoxShadow {
+                                div.shadow(vec![gpui::BoxShadow {
                                     color: Hsla {
                                         h: 0.,
                                         s: 0.,
@@ -144,7 +144,7 @@ impl Render for WindowShadow {
                                         .w(px(200.0))
                                         .h(px(100.0))
                                         .bg(green())
-                                        .shadow(smallvec::smallvec![gpui::BoxShadow {
+                                        .shadow(vec![gpui::BoxShadow {
                                             color: Hsla {
                                                 h: 0.,
                                                 s: 0.,

crates/gpui/src/action.rs 🔗

@@ -1,6 +1,6 @@
-use crate::SharedString;
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use collections::HashMap;
+pub use gpui_macros::Action;
 pub use no_action::{NoAction, is_no_action};
 use serde_json::json;
 use std::{
@@ -8,28 +8,89 @@ use std::{
     fmt::Display,
 };
 
-/// Actions are used to implement keyboard-driven UI.
-/// When you declare an action, you can bind keys to the action in the keymap and
-/// listeners for that action in the element tree.
+/// Defines and registers unit structs that can be used as actions. For more complex data types, derive `Action`.
 ///
-/// To declare a list of simple actions, you can use the actions! macro, which defines a simple unit struct
-/// action for each listed action name in the given namespace.
-/// ```rust
+/// For example:
+///
+/// ```
 /// actions!(editor, [MoveUp, MoveDown, MoveLeft, MoveRight, Newline]);
 /// ```
-/// More complex data types can also be actions, providing they implement Clone, PartialEq,
-/// and serde_derive::Deserialize.
-/// Use `impl_actions!` to automatically implement the action in the given namespace.
+///
+/// This will create actions with names like `editor::MoveUp`, `editor::MoveDown`, etc.
+///
+/// The namespace argument `editor` can also be omitted, though it is required for Zed actions.
+#[macro_export]
+macro_rules! actions {
+    ($namespace:path, [ $( $(#[$attr:meta])* $name:ident),* $(,)? ]) => {
+        $(
+            #[derive(::std::clone::Clone, ::std::cmp::PartialEq, ::std::default::Default, ::std::fmt::Debug, gpui::Action)]
+            #[action(namespace = $namespace)]
+            $(#[$attr])*
+            pub struct $name;
+        )*
+    };
+    ([ $( $(#[$attr:meta])* $name:ident),* $(,)? ]) => {
+        $(
+            #[derive(::std::clone::Clone, ::std::cmp::PartialEq, ::std::default::Default, ::std::fmt::Debug, gpui::Action)]
+            $(#[$attr])*
+            pub struct $name;
+        )*
+    };
+}
+
+/// Actions are used to implement keyboard-driven UI. When you declare an action, you can bind keys
+/// to the action in the keymap and listeners for that action in the element tree.
+///
+/// To declare a list of simple actions, you can use the actions! macro, which defines a simple unit
+/// struct action for each listed action name in the given namespace.
+///
+/// ```
+/// actions!(editor, [MoveUp, MoveDown, MoveLeft, MoveRight, Newline]);
+/// ```
+///
+/// Registering the actions with the same name will result in a panic during  `App` creation.
+///
+/// # Derive Macro
+///
+/// More complex data types can also be actions, by using the derive macro for `Action`:
+///
 /// ```
-/// #[derive(Clone, PartialEq, serde_derive::Deserialize)]
+/// #[derive(Clone, PartialEq, serde::Deserialize, schemars::JsonSchema, Action)]
+/// #[action(namespace = editor)]
 /// pub struct SelectNext {
 ///     pub replace_newest: bool,
 /// }
-/// impl_actions!(editor, [SelectNext]);
 /// ```
 ///
-/// If you want to control the behavior of the action trait manually, you can use the lower-level `#[register_action]`
-/// macro, which only generates the code needed to register your action before `main`.
+/// The derive macro for `Action` requires that the type implement `Clone` and `PartialEq`. It also
+/// requires `serde::Deserialize` and `schemars::JsonSchema` unless `#[action(no_json)]` is
+/// specified. In Zed these trait impls are used to load keymaps from JSON.
+///
+/// Multiple arguments separated by commas may be specified in `#[action(...)]`:
+///
+/// - `namespace = some_namespace` sets the namespace. In Zed this is required.
+///
+/// - `name = "ActionName"` overrides the action's name. This must not contain `::`.
+///
+/// - `no_json` causes the `build` method to always error and `action_json_schema` to return `None`,
+/// and allows actions not implement `serde::Serialize` and `schemars::JsonSchema`.
+///
+/// - `no_register` skips registering the action. This is useful for implementing the `Action` trait
+/// while not supporting invocation by name or JSON deserialization.
+///
+/// - `deprecated_aliases = ["editor::SomeAction"]` specifies deprecated old names for the action.
+/// These action names should *not* correspond to any actions that are registered. These old names
+/// can then still be used to refer to invoke this action. In Zed, the keymap JSON schema will
+/// accept these old names and provide warnings.
+///
+/// - `deprecated = "Message about why this action is deprecation"` specifies a deprecation message.
+/// In Zed, the keymap JSON schema will cause this to be displayed as a warning.
+///
+/// # Manual Implementation
+///
+/// If you want to control the behavior of the action trait manually, you can use the lower-level
+/// `#[register_action]` macro, which only generates the code needed to register your action before
+/// `main`.
 ///
 /// ```
 /// #[derive(gpui::private::serde::Deserialize, std::cmp::PartialEq, std::clone::Clone)]
@@ -50,10 +111,10 @@ pub trait Action: Any + Send {
     fn partial_eq(&self, action: &dyn Action) -> bool;
 
     /// Get the name of this action, for displaying in UI
-    fn name(&self) -> &str;
+    fn name(&self) -> &'static str;
 
-    /// Get the name of this action for debugging
-    fn debug_name() -> &'static str
+    /// Get the name of this action type (static)
+    fn name_for_type() -> &'static str
     where
         Self: Sized;
 
@@ -73,13 +134,24 @@ pub trait Action: Any + Send {
         None
     }
 
-    /// A list of alternate, deprecated names for this action.
+    /// A list of alternate, deprecated names for this action. These names can still be used to
+    /// invoke the action. In Zed, the keymap JSON schema will accept these old names and provide
+    /// warnings.
     fn deprecated_aliases() -> &'static [&'static str]
     where
         Self: Sized,
     {
         &[]
     }
+
+    /// Returns the deprecation message for this action, if any. In Zed, the keymap JSON schema will
+    /// cause this to be displayed as a warning.
+    fn deprecation_message() -> Option<&'static str>
+    where
+        Self: Sized,
+    {
+        None
+    }
 }
 
 impl std::fmt::Debug for dyn Action {
@@ -141,10 +213,11 @@ impl Display for ActionBuildError {
 type ActionBuilder = fn(json: serde_json::Value) -> anyhow::Result<Box<dyn Action>>;
 
 pub(crate) struct ActionRegistry {
-    by_name: HashMap<SharedString, ActionData>,
-    names_by_type_id: HashMap<TypeId, SharedString>,
-    all_names: Vec<SharedString>, // So we can return a static slice.
-    deprecations: HashMap<SharedString, SharedString>,
+    by_name: HashMap<&'static str, ActionData>,
+    names_by_type_id: HashMap<TypeId, &'static str>,
+    all_names: Vec<&'static str>, // So we can return a static slice.
+    deprecated_aliases: HashMap<&'static str, &'static str>, // deprecated name -> preferred name
+    deprecation_messages: HashMap<&'static str, &'static str>, // action name -> deprecation message
 }
 
 impl Default for ActionRegistry {
@@ -153,7 +226,8 @@ impl Default for ActionRegistry {
             by_name: Default::default(),
             names_by_type_id: Default::default(),
             all_names: Default::default(),
-            deprecations: Default::default(),
+            deprecated_aliases: Default::default(),
+            deprecation_messages: Default::default(),
         };
 
         this.load_actions();
@@ -177,10 +251,11 @@ pub struct MacroActionBuilder(pub fn() -> MacroActionData);
 #[doc(hidden)]
 pub struct MacroActionData {
     pub name: &'static str,
-    pub aliases: &'static [&'static str],
     pub type_id: TypeId,
     pub build: ActionBuilder,
     pub json_schema: fn(&mut schemars::r#gen::SchemaGenerator) -> Option<schemars::schema::Schema>,
+    pub deprecated_aliases: &'static [&'static str],
+    pub deprecation_message: Option<&'static str>,
 }
 
 inventory::collect!(MacroActionBuilder);
@@ -197,37 +272,52 @@ impl ActionRegistry {
     #[cfg(test)]
     pub(crate) fn load_action<A: Action>(&mut self) {
         self.insert_action(MacroActionData {
-            name: A::debug_name(),
-            aliases: A::deprecated_aliases(),
+            name: A::name_for_type(),
             type_id: TypeId::of::<A>(),
             build: A::build,
             json_schema: A::action_json_schema,
+            deprecated_aliases: A::deprecated_aliases(),
+            deprecation_message: A::deprecation_message(),
         });
     }
 
     fn insert_action(&mut self, action: MacroActionData) {
-        let name: SharedString = action.name.into();
+        let name = action.name;
+        if self.by_name.contains_key(name) {
+            panic!(
+                "Action with name `{name}` already registered \
+                (might be registered in `#[action(deprecated_aliases = [...])]`."
+            );
+        }
         self.by_name.insert(
-            name.clone(),
+            name,
             ActionData {
                 build: action.build,
                 json_schema: action.json_schema,
             },
         );
-        for &alias in action.aliases {
-            let alias: SharedString = alias.into();
+        for &alias in action.deprecated_aliases {
+            if self.by_name.contains_key(alias) {
+                panic!(
+                    "Action with name `{alias}` already registered. \
+                    `{alias}` is specified in `#[action(deprecated_aliases = [...])]` for action `{name}`."
+                );
+            }
             self.by_name.insert(
-                alias.clone(),
+                alias,
                 ActionData {
                     build: action.build,
                     json_schema: action.json_schema,
                 },
             );
-            self.deprecations.insert(alias.clone(), name.clone());
+            self.deprecated_aliases.insert(alias, name);
             self.all_names.push(alias);
         }
-        self.names_by_type_id.insert(action.type_id, name.clone());
+        self.names_by_type_id.insert(action.type_id, name);
         self.all_names.push(name);
+        if let Some(deprecation_msg) = action.deprecation_message {
+            self.deprecation_messages.insert(name, deprecation_msg);
+        }
     }
 
     /// Construct an action based on its name and optional JSON parameters sourced from the keymap.
@@ -235,10 +325,9 @@ impl ActionRegistry {
         let name = self
             .names_by_type_id
             .get(type_id)
-            .ok_or_else(|| anyhow!("no action type registered for {:?}", type_id))?
-            .clone();
+            .with_context(|| format!("no action type registered for {type_id:?}"))?;
 
-        Ok(self.build_action(&name, None)?)
+        Ok(self.build_action(name, None)?)
     }
 
     /// Construct an action based on its name and optional JSON parameters sourced from the keymap.
@@ -262,14 +351,14 @@ impl ActionRegistry {
         })
     }
 
-    pub fn all_action_names(&self) -> &[SharedString] {
+    pub fn all_action_names(&self) -> &[&'static str] {
         self.all_names.as_slice()
     }
 
     pub fn action_schemas(
         &self,
         generator: &mut schemars::r#gen::SchemaGenerator,
-    ) -> Vec<(SharedString, Option<schemars::schema::Schema>)> {
+    ) -> Vec<(&'static str, Option<schemars::schema::Schema>)> {
         // Use the order from all_names so that the resulting schema has sensible order.
         self.all_names
             .iter()
@@ -278,296 +367,44 @@ impl ActionRegistry {
                     .by_name
                     .get(name)
                     .expect("All actions in all_names should be registered");
-                (name.clone(), (action_data.json_schema)(generator))
+                (*name, (action_data.json_schema)(generator))
             })
             .collect::<Vec<_>>()
     }
 
-    pub fn action_deprecations(&self) -> &HashMap<SharedString, SharedString> {
-        &self.deprecations
+    pub fn deprecated_aliases(&self) -> &HashMap<&'static str, &'static str> {
+        &self.deprecated_aliases
     }
-}
-
-/// Defines and registers unit structs that can be used as actions.
-///
-/// To use more complex data types as actions, use `impl_actions!`
-#[macro_export]
-macro_rules! actions {
-    ($namespace:path, [ $($name:ident),* $(,)? ]) => {
-        $(
-            // Unfortunately rust-analyzer doesn't display the name due to
-            // https://github.com/rust-lang/rust-analyzer/issues/8092
-            #[doc = stringify!($name)]
-            #[doc = "action generated by `gpui::actions!`"]
-            #[derive(::std::clone::Clone,::std::cmp::PartialEq, ::std::default::Default)]
-            pub struct $name;
-
-            gpui::__impl_action!($namespace, $name, $name,
-                fn build(_: gpui::private::serde_json::Value) -> gpui::Result<::std::boxed::Box<dyn gpui::Action>> {
-                    Ok(Box::new(Self))
-                },
-                fn action_json_schema(
-                    _: &mut gpui::private::schemars::r#gen::SchemaGenerator,
-                ) -> Option<gpui::private::schemars::schema::Schema> {
-                    None
-                }
-            );
-
-            gpui::register_action!($name);
-        )*
-    };
-}
-
-/// Defines and registers a unit struct that can be used as an actions, with a name that differs
-/// from it's type name.
-///
-/// To use more complex data types as actions, and rename them use `impl_action_as!`
-#[macro_export]
-macro_rules! action_as {
-    ($namespace:path, $name:ident as $visual_name:ident) => {
-        // Unfortunately rust-analyzer doesn't display the name due to
-        // https://github.com/rust-lang/rust-analyzer/issues/8092
-        #[doc = stringify!($name)]
-        #[doc = "action generated by `gpui::action_as!`"]
-        #[derive(
-            ::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, ::std::cmp::PartialEq,
-        )]
-        pub struct $name;
-
-        gpui::__impl_action!(
-            $namespace,
-            $name,
-            $visual_name,
-            fn build(
-                _: gpui::private::serde_json::Value,
-            ) -> gpui::Result<::std::boxed::Box<dyn gpui::Action>> {
-                Ok(Box::new(Self))
-            },
-            fn action_json_schema(
-                generator: &mut gpui::private::schemars::r#gen::SchemaGenerator,
-            ) -> Option<gpui::private::schemars::schema::Schema> {
-                None
-            }
-        );
-
-        gpui::register_action!($name);
-    };
-}
-
-/// Defines and registers a unit struct that can be used as an action, with some deprecated aliases.
-#[macro_export]
-macro_rules! action_with_deprecated_aliases {
-    ($namespace:path, $name:ident, [$($alias:literal),* $(,)?]) => {
-        // Unfortunately rust-analyzer doesn't display the name due to
-        // https://github.com/rust-lang/rust-analyzer/issues/8092
-        #[doc = stringify!($name)]
-        #[doc = "action, generated by `gpui::action_with_deprecated_aliases!`"]
-        #[derive(
-            ::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, ::std::cmp::PartialEq,
-        )]
-        pub struct $name;
-
-        gpui::__impl_action!(
-            $namespace,
-            $name,
-            $name,
-            fn build(
-                value: gpui::private::serde_json::Value,
-            ) -> gpui::Result<::std::boxed::Box<dyn gpui::Action>> {
-                Ok(Box::new(Self))
-            },
-
-            fn action_json_schema(
-                generator: &mut gpui::private::schemars::r#gen::SchemaGenerator,
-            ) -> Option<gpui::private::schemars::schema::Schema> {
-                None
-            },
-
-            fn deprecated_aliases() -> &'static [&'static str] {
-                &[
-                    $($alias),*
-                ]
-            }
-        );
-
-        gpui::register_action!($name);
-    };
-}
-
-/// Registers the action and implements the Action trait for any struct that implements Clone,
-/// Default, PartialEq, serde_deserialize::Deserialize, and schemars::JsonSchema.
-///
-/// Similar to `impl_actions!`, but only handles one struct, and registers some deprecated aliases.
-#[macro_export]
-macro_rules! impl_action_with_deprecated_aliases {
-    ($namespace:path, $name:ident, [$($alias:literal),* $(,)?]) => {
-        gpui::__impl_action!(
-            $namespace,
-            $name,
-            $name,
-            fn build(
-                value: gpui::private::serde_json::Value,
-            ) -> gpui::Result<::std::boxed::Box<dyn gpui::Action>> {
-                Ok(std::boxed::Box::new(gpui::private::serde_json::from_value::<Self>(value)?))
-            },
-
-            fn action_json_schema(
-                generator: &mut gpui::private::schemars::r#gen::SchemaGenerator,
-            ) -> Option<gpui::private::schemars::schema::Schema> {
-                Some(<Self as gpui::private::schemars::JsonSchema>::json_schema(
-                    generator,
-                ))
-            },
-
-            fn deprecated_aliases() -> &'static [&'static str] {
-                &[
-                    $($alias),*
-                ]
-            }
-        );
-
-        gpui::register_action!($name);
-    };
-}
-
-/// Registers the action and implements the Action trait for any struct that implements Clone,
-/// Default, PartialEq, serde_deserialize::Deserialize, and schemars::JsonSchema.
-///
-/// Similar to `actions!`, but accepts structs with fields.
-///
-/// Fields and variants that don't make sense for user configuration should be annotated with
-/// #[serde(skip)].
-#[macro_export]
-macro_rules! impl_actions {
-    ($namespace:path, [ $($name:ident),* $(,)? ]) => {
-        $(
-            gpui::__impl_action!($namespace, $name, $name,
-                fn build(value: gpui::private::serde_json::Value) -> gpui::Result<::std::boxed::Box<dyn gpui::Action>> {
-                    Ok(std::boxed::Box::new(gpui::private::serde_json::from_value::<Self>(value)?))
-                },
-                fn action_json_schema(
-                    generator: &mut gpui::private::schemars::r#gen::SchemaGenerator,
-                ) -> Option<gpui::private::schemars::schema::Schema> {
-                    Some(<Self as gpui::private::schemars::JsonSchema>::json_schema(
-                        generator,
-                    ))
-                }
-            );
-
-            gpui::register_action!($name);
-        )*
-    };
-}
-
-/// Implements the Action trait for internal action structs that implement Clone, Default,
-/// PartialEq. The purpose of this is to conveniently define values that can be passed in `dyn
-/// Action`.
-///
-/// These actions are internal and so are not registered and do not support deserialization.
-#[macro_export]
-macro_rules! impl_internal_actions {
-    ($namespace:path, [ $($name:ident),* $(,)? ]) => {
-        $(
-            gpui::__impl_action!($namespace, $name, $name,
-                fn build(value: gpui::private::serde_json::Value) -> gpui::Result<::std::boxed::Box<dyn gpui::Action>> {
-                    gpui::Result::Err(gpui::private::anyhow::anyhow!(
-                        concat!(
-                            stringify!($namespace),
-                            "::",
-                            stringify!($visual_name),
-                            " is an internal action, so cannot be built from JSON."
-                        )))
-                },
-                fn action_json_schema(
-                    generator: &mut gpui::private::schemars::r#gen::SchemaGenerator,
-                ) -> Option<gpui::private::schemars::schema::Schema> {
-                    None
-                }
-            );
-        )*
-    };
-}
-
-/// Implements the Action trait for a struct that implements Clone, Default, PartialEq, and
-/// serde_deserialize::Deserialize. Allows you to rename the action visually, without changing the
-/// struct's name.
-///
-/// Fields and variants that don't make sense for user configuration should be annotated with
-/// #[serde(skip)].
-#[macro_export]
-macro_rules! impl_action_as {
-    ($namespace:path, $name:ident as $visual_name:tt ) => {
-        gpui::__impl_action!(
-            $namespace,
-            $name,
-            $visual_name,
-            fn build(
-                value: gpui::private::serde_json::Value,
-            ) -> gpui::Result<::std::boxed::Box<dyn gpui::Action>> {
-                Ok(std::boxed::Box::new(
-                    gpui::private::serde_json::from_value::<Self>(value)?,
-                ))
-            },
-            fn action_json_schema(
-                generator: &mut gpui::private::schemars::r#gen::SchemaGenerator,
-            ) -> Option<gpui::private::schemars::schema::Schema> {
-                Some(<Self as gpui::private::schemars::JsonSchema>::json_schema(
-                    generator,
-                ))
-            }
-        );
 
-        gpui::register_action!($name);
-    };
+    pub fn deprecation_messages(&self) -> &HashMap<&'static str, &'static str> {
+        &self.deprecation_messages
+    }
 }
 
-#[doc(hidden)]
-#[macro_export]
-macro_rules! __impl_action {
-    ($namespace:path, $name:ident, $visual_name:tt, $($items:item),*) => {
-        impl gpui::Action for $name {
-            fn name(&self) -> &'static str
-            {
-                concat!(
-                    stringify!($namespace),
-                    "::",
-                    stringify!($visual_name),
-                )
-            }
-
-            fn debug_name() -> &'static str
-            where
-                Self: ::std::marker::Sized
-            {
-                concat!(
-                    stringify!($namespace),
-                    "::",
-                    stringify!($visual_name),
-                )
-            }
-
-            fn partial_eq(&self, action: &dyn gpui::Action) -> bool {
-                action
-                    .as_any()
-                    .downcast_ref::<Self>()
-                    .map_or(false, |a| self == a)
-            }
-
-            fn boxed_clone(&self) ->  std::boxed::Box<dyn gpui::Action> {
-                ::std::boxed::Box::new(self.clone())
-            }
-
-
-            $($items)*
-        }
-    };
+/// Generate a list of all the registered actions.
+/// Useful for transforming the list of available actions into a
+/// format suited for static analysis such as in validating keymaps, or
+/// generating documentation.
+pub fn generate_list_of_all_registered_actions() -> Vec<MacroActionData> {
+    let mut actions = Vec::new();
+    for builder in inventory::iter::<MacroActionBuilder> {
+        actions.push(builder.0());
+    }
+    actions
 }
 
 mod no_action {
     use crate as gpui;
     use std::any::Any as _;
 
-    actions!(zed, [NoAction]);
+    actions!(
+        zed,
+        [
+            /// Action with special handling which unbinds the keybinding this is associated with,
+            /// if it is the highest precedence match.
+            NoAction
+        ]
+    );
 
     /// Returns whether or not this action represents a removed key binding.
     pub fn is_no_action(action: &dyn gpui::Action) -> bool {

crates/gpui/src/app.rs 🔗

@@ -1,6 +1,6 @@
 use std::{
     any::{TypeId, type_name},
-    cell::{Ref, RefCell, RefMut},
+    cell::{BorrowMutError, Ref, RefCell, RefMut},
     marker::PhantomData,
     mem,
     ops::{Deref, DerefMut},
@@ -10,7 +10,7 @@ use std::{
     time::Duration,
 };
 
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use derive_more::{Deref, DerefMut};
 use futures::{
     Future, FutureExt,
@@ -30,15 +30,19 @@ use smallvec::SmallVec;
 pub use test_context::*;
 use util::{ResultExt, debug_panic};
 
+#[cfg(any(feature = "inspector", debug_assertions))]
+use crate::InspectorElementRegistry;
 use crate::{
     Action, ActionBuildError, ActionRegistry, Any, AnyView, AnyWindowHandle, AppContext, Asset,
     AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DispatchPhase, DisplayId,
     EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext,
     Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform,
-    PlatformDisplay, PlatformKeyboardLayout, Point, PromptBuilder, PromptHandle, PromptLevel,
-    Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString,
+    PlatformDisplay, PlatformKeyboardLayout, Point, PromptBuilder, PromptButton, PromptHandle,
+    PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource,
     SubscriberSet, Subscription, SvgRenderer, Task, TextSystem, Window, WindowAppearance,
-    WindowHandle, WindowId, WindowInvalidator, current_platform, hash, init_app_menus,
+    WindowHandle, WindowId, WindowInvalidator,
+    colors::{Colors, GlobalColors},
+    current_platform, hash, init_app_menus,
 };
 
 mod async_context;
@@ -60,7 +64,7 @@ pub struct AppCell {
 impl AppCell {
     #[doc(hidden)]
     #[track_caller]
-    pub fn borrow(&self) -> AppRef {
+    pub fn borrow(&self) -> AppRef<'_> {
         if option_env!("TRACK_THREAD_BORROWS").is_some() {
             let thread_id = std::thread::current().id();
             eprintln!("borrowed {thread_id:?}");
@@ -70,13 +74,23 @@ impl AppCell {
 
     #[doc(hidden)]
     #[track_caller]
-    pub fn borrow_mut(&self) -> AppRefMut {
+    pub fn borrow_mut(&self) -> AppRefMut<'_> {
         if option_env!("TRACK_THREAD_BORROWS").is_some() {
             let thread_id = std::thread::current().id();
             eprintln!("borrowed {thread_id:?}");
         }
         AppRefMut(self.app.borrow_mut())
     }
+
+    #[doc(hidden)]
+    #[track_caller]
+    pub fn try_borrow_mut(&self) -> Result<AppRefMut<'_>, BorrowMutError> {
+        if option_env!("TRACK_THREAD_BORROWS").is_some() {
+            let thread_id = std::thread::current().id();
+            eprintln!("borrowed {thread_id:?}");
+        }
+        Ok(AppRefMut(self.app.try_borrow_mut()?))
+    }
 }
 
 #[doc(hidden)]
@@ -269,6 +283,10 @@ pub struct App {
     pub(crate) window_invalidators_by_entity:
         FxHashMap<EntityId, FxHashMap<WindowId, WindowInvalidator>>,
     pub(crate) tracked_entities: FxHashMap<WindowId, FxHashSet<EntityId>>,
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub(crate) inspector_renderer: Option<crate::InspectorRenderer>,
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub(crate) inspector_element_registry: InspectorElementRegistry,
     #[cfg(any(test, feature = "test-support", debug_assertions))]
     pub(crate) name: Option<&'static str>,
     quitting: bool,
@@ -333,6 +351,10 @@ impl App {
                 layout_id_buffer: Default::default(),
                 propagate_event: true,
                 prompt_builder: Some(PromptBuilder::Default),
+                #[cfg(any(feature = "inspector", debug_assertions))]
+                inspector_renderer: None,
+                #[cfg(any(feature = "inspector", debug_assertions))]
+                inspector_element_registry: InspectorElementRegistry::default(),
                 quitting: false,
 
                 #[cfg(any(test, feature = "test-support", debug_assertions))]
@@ -887,7 +909,7 @@ impl App {
                     })
                     .collect::<Vec<_>>()
                 {
-                    self.update_window(window, |_, window, cx| window.draw(cx))
+                    self.update_window(window, |_, window, cx| window.draw(cx).clear())
                         .unwrap();
                 }
 
@@ -1009,9 +1031,9 @@ impl App {
             let mut window = cx
                 .windows
                 .get_mut(id)
-                .ok_or_else(|| anyhow!("window not found"))?
+                .context("window not found")?
                 .take()
-                .ok_or_else(|| anyhow!("window not found"))?;
+                .context("window not found")?;
 
             let root_view = window.root.clone().unwrap();
 
@@ -1030,7 +1052,7 @@ impl App {
             } else {
                 cx.windows
                     .get_mut(id)
-                    .ok_or_else(|| anyhow!("window not found"))?
+                    .context("window not found")?
                     .replace(window);
             }
 
@@ -1107,7 +1129,7 @@ impl App {
         self.globals_by_type
             .get(&TypeId::of::<G>())
             .map(|any_state| any_state.downcast_ref::<G>().unwrap())
-            .ok_or_else(|| anyhow!("no state of type {} exists", type_name::<G>()))
+            .with_context(|| format!("no state of type {} exists", type_name::<G>()))
             .unwrap()
     }
 
@@ -1126,7 +1148,7 @@ impl App {
         self.globals_by_type
             .get_mut(&global_type)
             .and_then(|any_state| any_state.downcast_mut::<G>())
-            .ok_or_else(|| anyhow!("no state of type {} exists", type_name::<G>()))
+            .with_context(|| format!("no state of type {} exists", type_name::<G>()))
             .unwrap()
     }
 
@@ -1189,7 +1211,7 @@ impl App {
         GlobalLease::new(
             self.globals_by_type
                 .remove(&TypeId::of::<G>())
-                .ok_or_else(|| anyhow!("no global registered of type {}", type_name::<G>()))
+                .with_context(|| format!("no global registered of type {}", type_name::<G>()))
                 .unwrap(),
         )
     }
@@ -1352,7 +1374,7 @@ impl App {
 
     /// Get all action names that have been registered. Note that registration only allows for
     /// actions to be built dynamically, and is unrelated to binding actions in the element tree.
-    pub fn all_action_names(&self) -> &[SharedString] {
+    pub fn all_action_names(&self) -> &[&'static str] {
         self.actions.all_action_names()
     }
 
@@ -1367,13 +1389,18 @@ impl App {
     pub fn action_schemas(
         &self,
         generator: &mut schemars::r#gen::SchemaGenerator,
-    ) -> Vec<(SharedString, Option<schemars::schema::Schema>)> {
+    ) -> Vec<(&'static str, Option<schemars::schema::Schema>)> {
         self.actions.action_schemas(generator)
     }
 
-    /// Get a list of all deprecated action aliases and their canonical names.
-    pub fn action_deprecations(&self) -> &HashMap<SharedString, SharedString> {
-        self.actions.action_deprecations()
+    /// Get a map from a deprecated action name to the canonical name.
+    pub fn deprecated_actions_to_preferred_actions(&self) -> &HashMap<&'static str, &'static str> {
+        self.actions.deprecated_aliases()
+    }
+
+    /// Get a list of all action deprecation messages.
+    pub fn action_deprecation_messages(&self) -> &HashMap<&'static str, &'static str> {
+        self.actions.deprecation_messages()
     }
 
     /// Register a callback to be invoked when the application is about to quit.
@@ -1537,6 +1564,11 @@ impl App {
         self.active_drag.is_some()
     }
 
+    /// Gets the cursor style of the currently active drag operation.
+    pub fn active_drag_cursor_style(&self) -> Option<CursorStyle> {
+        self.active_drag.as_ref().and_then(|drag| drag.cursor_style)
+    }
+
     /// Stops active drag and clears any related effects.
     pub fn stop_active_drag(&mut self, window: &mut Window) -> bool {
         if self.active_drag.is_some() {
@@ -1548,6 +1580,21 @@ impl App {
         }
     }
 
+    /// Sets the cursor style for the currently active drag operation.
+    pub fn set_active_drag_cursor_style(
+        &mut self,
+        cursor_style: CursorStyle,
+        window: &mut Window,
+    ) -> bool {
+        if let Some(ref mut drag) = self.active_drag {
+            drag.cursor_style = Some(cursor_style);
+            window.refresh();
+            true
+        } else {
+            false
+        }
+    }
+
     /// Set the prompt renderer for GPUI. This will replace the default or platform specific
     /// prompts with this custom implementation.
     pub fn set_prompt_builder(
@@ -1556,14 +1603,14 @@ impl App {
             PromptLevel,
             &str,
             Option<&str>,
-            &[&str],
+            &[PromptButton],
             PromptHandle,
             &mut Window,
             &mut App,
         ) -> RenderablePromptHandle
         + 'static,
     ) {
-        self.prompt_builder = Some(PromptBuilder::Custom(Box::new(renderer)))
+        self.prompt_builder = Some(PromptBuilder::Custom(Box::new(renderer)));
     }
 
     /// Reset the prompt builder to the default implementation.
@@ -1643,7 +1690,7 @@ impl App {
 
     /// Removes an image from the sprite atlas on all windows.
     ///
-    /// If the current window is being updated, it will be removed from `App.windows``, you can use `current_window` to specify the current window.
+    /// If the current window is being updated, it will be removed from `App.windows`, you can use `current_window` to specify the current window.
     /// This is a no-op if the image is not in the sprite atlas.
     pub fn drop_image(&mut self, image: Arc<RenderImage>, current_window: Option<&mut Window>) {
         // remove the texture from all other windows
@@ -1656,6 +1703,28 @@ impl App {
             _ = window.drop_image(image);
         }
     }
+
+    /// Sets the renderer for the inspector.
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub fn set_inspector_renderer(&mut self, f: crate::InspectorRenderer) {
+        self.inspector_renderer = Some(f);
+    }
+
+    /// Registers a renderer specific to an inspector state.
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub fn register_inspector_element<T: 'static, R: crate::IntoElement>(
+        &mut self,
+        f: impl 'static + Fn(crate::InspectorElementId, &T, &mut Window, &mut App) -> R,
+    ) {
+        self.inspector_element_registry.register(f);
+    }
+
+    /// Initializes gpui's default colors for the application.
+    ///
+    /// These colors can be accessed through `cx.default_colors()`.
+    pub fn init_colors(&mut self) {
+        self.set_global(GlobalColors(Arc::new(Colors::default())));
+    }
 }
 
 impl AppContext for App {
@@ -1746,7 +1815,7 @@ impl AppContext for App {
         let window = self
             .windows
             .get(window.id)
-            .ok_or_else(|| anyhow!("window not found"))?
+            .context("window not found")?
             .as_ref()
             .expect("attempted to read a window that is already on the stack");
 
@@ -1896,9 +1965,12 @@ impl HttpClient for NullHttpClient {
         _req: http_client::Request<http_client::AsyncBody>,
     ) -> futures::future::BoxFuture<
         'static,
-        Result<http_client::Response<http_client::AsyncBody>, anyhow::Error>,
+        anyhow::Result<http_client::Response<http_client::AsyncBody>>,
     > {
-        async move { Err(anyhow!("No HttpClient available")) }.boxed()
+        async move {
+            anyhow::bail!("No HttpClient available");
+        }
+        .boxed()
     }
 
     fn proxy(&self) -> Option<&Url> {

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

@@ -1,9 +1,9 @@
 use crate::{
     AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, BorrowAppContext,
-    Entity, EventEmitter, Focusable, ForegroundExecutor, Global, PromptLevel, Render, Reservation,
-    Result, Subscription, Task, VisualContext, Window, WindowHandle,
+    Entity, EventEmitter, Focusable, ForegroundExecutor, Global, PromptButton, PromptLevel, Render,
+    Reservation, Result, Subscription, Task, VisualContext, Window, WindowHandle,
 };
-use anyhow::{Context as _, anyhow};
+use anyhow::Context as _;
 use derive_more::{Deref, DerefMut};
 use futures::channel::oneshot;
 use std::{future::Future, rc::Weak};
@@ -27,19 +27,13 @@ impl AppContext for AsyncApp {
         &mut self,
         build_entity: impl FnOnce(&mut Context<T>) -> T,
     ) -> Self::Result<Entity<T>> {
-        let app = self
-            .app
-            .upgrade()
-            .ok_or_else(|| anyhow!("app was released"))?;
+        let app = self.app.upgrade().context("app was released")?;
         let mut app = app.borrow_mut();
         Ok(app.new(build_entity))
     }
 
     fn reserve_entity<T: 'static>(&mut self) -> Result<Reservation<T>> {
-        let app = self
-            .app
-            .upgrade()
-            .ok_or_else(|| anyhow!("app was released"))?;
+        let app = self.app.upgrade().context("app was released")?;
         let mut app = app.borrow_mut();
         Ok(app.reserve_entity())
     }
@@ -49,10 +43,7 @@ impl AppContext for AsyncApp {
         reservation: Reservation<T>,
         build_entity: impl FnOnce(&mut Context<T>) -> T,
     ) -> Result<Entity<T>> {
-        let app = self
-            .app
-            .upgrade()
-            .ok_or_else(|| anyhow!("app was released"))?;
+        let app = self.app.upgrade().context("app was released")?;
         let mut app = app.borrow_mut();
         Ok(app.insert_entity(reservation, build_entity))
     }
@@ -62,10 +53,7 @@ impl AppContext for AsyncApp {
         handle: &Entity<T>,
         update: impl FnOnce(&mut T, &mut Context<T>) -> R,
     ) -> Self::Result<R> {
-        let app = self
-            .app
-            .upgrade()
-            .ok_or_else(|| anyhow!("app was released"))?;
+        let app = self.app.upgrade().context("app was released")?;
         let mut app = app.borrow_mut();
         Ok(app.update_entity(handle, update))
     }
@@ -88,7 +76,7 @@ impl AppContext for AsyncApp {
         F: FnOnce(AnyView, &mut Window, &mut App) -> T,
     {
         let app = self.app.upgrade().context("app was released")?;
-        let mut lock = app.borrow_mut();
+        let mut lock = app.try_borrow_mut()?;
         lock.update_window(window, f)
     }
 
@@ -125,10 +113,7 @@ impl AppContext for AsyncApp {
 impl AsyncApp {
     /// Schedules all windows in the application to be redrawn.
     pub fn refresh(&self) -> Result<()> {
-        let app = self
-            .app
-            .upgrade()
-            .ok_or_else(|| anyhow!("app was released"))?;
+        let app = self.app.upgrade().context("app was released")?;
         let mut lock = app.borrow_mut();
         lock.refresh_windows();
         Ok(())
@@ -146,10 +131,7 @@ impl AsyncApp {
 
     /// Invoke the given function in the context of the app, then flush any effects produced during its invocation.
     pub fn update<R>(&self, f: impl FnOnce(&mut App) -> R) -> Result<R> {
-        let app = self
-            .app
-            .upgrade()
-            .ok_or_else(|| anyhow!("app was released"))?;
+        let app = self.app.upgrade().context("app was released")?;
         let mut lock = app.borrow_mut();
         Ok(lock.update(f))
     }
@@ -165,10 +147,7 @@ impl AsyncApp {
         T: 'static + EventEmitter<Event>,
         Event: 'static,
     {
-        let app = self
-            .app
-            .upgrade()
-            .ok_or_else(|| anyhow!("app was released"))?;
+        let app = self.app.upgrade().context("app was released")?;
         let mut lock = app.borrow_mut();
         let subscription = lock.subscribe(entity, on_event);
         Ok(subscription)
@@ -183,10 +162,7 @@ impl AsyncApp {
     where
         V: 'static + Render,
     {
-        let app = self
-            .app
-            .upgrade()
-            .ok_or_else(|| anyhow!("app was released"))?;
+        let app = self.app.upgrade().context("app was released")?;
         let mut lock = app.borrow_mut();
         lock.open_window(options, build_root_view)
     }
@@ -206,10 +182,7 @@ impl AsyncApp {
     /// Determine whether global state of the specified type has been assigned.
     /// Returns an error if the `App` has been dropped.
     pub fn has_global<G: Global>(&self) -> Result<bool> {
-        let app = self
-            .app
-            .upgrade()
-            .ok_or_else(|| anyhow!("app was released"))?;
+        let app = self.app.upgrade().context("app was released")?;
         let app = app.borrow_mut();
         Ok(app.has_global::<G>())
     }
@@ -219,10 +192,7 @@ impl AsyncApp {
     /// Panics if no global state of the specified type has been assigned.
     /// Returns an error if the `App` has been dropped.
     pub fn read_global<G: Global, R>(&self, read: impl FnOnce(&G, &App) -> R) -> Result<R> {
-        let app = self
-            .app
-            .upgrade()
-            .ok_or_else(|| anyhow!("app was released"))?;
+        let app = self.app.upgrade().context("app was released")?;
         let app = app.borrow_mut();
         Ok(read(app.global(), &app))
     }
@@ -245,10 +215,7 @@ impl AsyncApp {
         &self,
         update: impl FnOnce(&mut G, &mut App) -> R,
     ) -> Result<R> {
-        let app = self
-            .app
-            .upgrade()
-            .ok_or_else(|| anyhow!("app was released"))?;
+        let app = self.app.upgrade().context("app was released")?;
         let mut app = app.borrow_mut();
         Ok(app.update(|cx| cx.update_global(update)))
     }
@@ -347,13 +314,16 @@ impl AsyncWindowContext {
     /// Present a platform dialog.
     /// The provided message will be presented, along with buttons for each answer.
     /// When a button is clicked, the returned Receiver will receive the index of the clicked button.
-    pub fn prompt(
+    pub fn prompt<T>(
         &mut self,
         level: PromptLevel,
         message: &str,
         detail: Option<&str>,
-        answers: &[&str],
-    ) -> oneshot::Receiver<usize> {
+        answers: &[T],
+    ) -> oneshot::Receiver<usize>
+    where
+        T: Clone + Into<PromptButton>,
+    {
         self.window
             .update(self, |_, window, cx| {
                 window.prompt(level, message, detail, answers, cx)

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

@@ -225,6 +225,18 @@ impl<'a, T: 'static> Context<'a, T> {
         }
     }
 
+    /// Convenience method for producing view state in a closure.
+    /// See `listener` for more details.
+    pub fn processor<E, R>(
+        &self,
+        f: impl Fn(&mut T, E, &mut Window, &mut Context<T>) -> R + 'static,
+    ) -> impl Fn(E, &mut Window, &mut App) -> R + 'static {
+        let view = self.entity();
+        move |e: E, window: &mut Window, cx: &mut App| {
+            view.update(cx, |view, cx| f(view, e, window, cx))
+        }
+    }
+
     /// Run something using this entity and cx, when the returned struct is dropped
     pub fn on_drop(
         &self,

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

@@ -1,5 +1,5 @@
 use crate::{App, AppContext, VisualContext, Window, seal::Sealed};
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use collections::FxHashSet;
 use derive_more::{Deref, DerefMut};
 use parking_lot::{RwLock, RwLockUpgradableReadGuard};
@@ -20,11 +20,11 @@ use std::{
     thread::panicking,
 };
 
+use super::Context;
+use crate::util::atomic_incr_if_not_zero;
 #[cfg(any(test, feature = "leak-detection"))]
 use collections::HashMap;
 
-use super::Context;
-
 slotmap::new_key_type! {
     /// A unique identifier for a entity across the application.
     pub struct EntityId;
@@ -529,11 +529,10 @@ impl AnyWeakEntity {
         let ref_counts = ref_counts.read();
         let ref_count = ref_counts.counts.get(self.entity_id)?;
 
-        // entity_id is in dropped_entity_ids
-        if ref_count.load(SeqCst) == 0 {
+        if atomic_incr_if_not_zero(ref_count) == 0 {
+            // entity_id is in dropped_entity_ids
             return None;
         }
-        ref_count.fetch_add(1, SeqCst);
         drop(ref_counts);
 
         Some(AnyEntity {
@@ -585,7 +584,7 @@ impl AnyWeakEntity {
             // Safety:
             //   Docs say this is safe but can be unspecified if slotmap changes the representation
             //   after `1.0.7`, that said, providing a valid entity_id here is not necessary as long
-            //   as we guarantee that that `entity_id` is never used if `entity_ref_counts` equals
+            //   as we guarantee that `entity_id` is never used if `entity_ref_counts` equals
             //   to `Weak::new()` (that is, it's unable to upgrade), that is the invariant that
             //   actually needs to be hold true.
             //
@@ -692,7 +691,7 @@ impl<T: 'static> WeakEntity<T> {
     {
         crate::Flatten::flatten(
             self.upgrade()
-                .ok_or_else(|| anyhow!("entity released"))
+                .context("entity released")
                 .map(|this| cx.update_entity(&this, update)),
         )
     }
@@ -710,7 +709,7 @@ impl<T: 'static> WeakEntity<T> {
         Result<C::Result<R>>: crate::Flatten<R>,
     {
         let window = cx.window_handle();
-        let this = self.upgrade().ok_or_else(|| anyhow!("entity released"))?;
+        let this = self.upgrade().context("entity released")?;
 
         crate::Flatten::flatten(window.update(cx, |_, window, cx| {
             this.update(cx, |entity, cx| update(entity, window, cx))
@@ -727,7 +726,7 @@ impl<T: 'static> WeakEntity<T> {
     {
         crate::Flatten::flatten(
             self.upgrade()
-                .ok_or_else(|| anyhow!("entity release"))
+                .context("entity released")
                 .map(|this| cx.read_entity(&this, read)),
         )
     }

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

@@ -1,7 +1,7 @@
 use crate::{
     Action, AnyView, AnyWindowHandle, App, AppCell, AppContext, AsyncApp, AvailableSpace,
-    BackgroundExecutor, BorrowAppContext, Bounds, ClipboardItem, DrawPhase, Drawable, Element,
-    Empty, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Modifiers,
+    BackgroundExecutor, BorrowAppContext, Bounds, Capslock, ClipboardItem, DrawPhase, Drawable,
+    Element, Empty, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Modifiers,
     ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
     Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform,
     TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds,
@@ -771,7 +771,18 @@ impl VisualTestContext {
 
     /// Simulate a modifiers changed event
     pub fn simulate_modifiers_change(&mut self, modifiers: Modifiers) {
-        self.simulate_event(ModifiersChangedEvent { modifiers })
+        self.simulate_event(ModifiersChangedEvent {
+            modifiers,
+            capslock: Capslock { on: false },
+        })
+    }
+
+    /// Simulate a capslock changed event
+    pub fn simulate_capslock_change(&mut self, on: bool) {
+        self.simulate_event(ModifiersChangedEvent {
+            modifiers: Modifiers::none(),
+            capslock: Capslock { on },
+        })
     }
 
     /// Simulates the user resizing the window to the new size.

crates/gpui/src/arena.rs 🔗

@@ -1,5 +1,5 @@
 use std::{
-    alloc,
+    alloc::{self, handle_alloc_error},
     cell::Cell,
     ops::{Deref, DerefMut},
     ptr,
@@ -20,43 +20,98 @@ impl Drop for ArenaElement {
     }
 }
 
-pub struct Arena {
+struct Chunk {
     start: *mut u8,
     end: *mut u8,
     offset: *mut u8,
-    elements: Vec<ArenaElement>,
-    valid: Rc<Cell<bool>>,
 }
 
-impl Arena {
-    pub fn new(size_in_bytes: usize) -> Self {
+impl Drop for Chunk {
+    fn drop(&mut self) {
+        unsafe {
+            let chunk_size = self.end.offset_from_unsigned(self.start);
+            // this never fails as it succeeded during allocation
+            let layout = alloc::Layout::from_size_align(chunk_size, 1).unwrap();
+            alloc::dealloc(self.start, layout);
+        }
+    }
+}
+
+impl Chunk {
+    fn new(chunk_size: usize) -> Self {
         unsafe {
-            let layout = alloc::Layout::from_size_align(size_in_bytes, 1).unwrap();
+            // this only fails if chunk_size is unreasonably huge
+            let layout = alloc::Layout::from_size_align(chunk_size, 1).unwrap();
             let start = alloc::alloc(layout);
-            let end = start.add(size_in_bytes);
+            if start.is_null() {
+                handle_alloc_error(layout);
+            }
+            let end = start.add(chunk_size);
             Self {
                 start,
                 end,
                 offset: start,
-                elements: Vec::new(),
-                valid: Rc::new(Cell::new(true)),
             }
         }
     }
 
-    pub fn len(&self) -> usize {
-        self.offset as usize - self.start as usize
+    fn allocate(&mut self, layout: alloc::Layout) -> Option<*mut u8> {
+        unsafe {
+            let aligned = self.offset.add(self.offset.align_offset(layout.align()));
+            let next = aligned.add(layout.size());
+
+            if next <= self.end {
+                self.offset = next;
+                Some(aligned)
+            } else {
+                None
+            }
+        }
+    }
+
+    fn reset(&mut self) {
+        self.offset = self.start;
+    }
+}
+
+pub struct Arena {
+    chunks: Vec<Chunk>,
+    elements: Vec<ArenaElement>,
+    valid: Rc<Cell<bool>>,
+    current_chunk_index: usize,
+    chunk_size: usize,
+}
+
+impl Drop for Arena {
+    fn drop(&mut self) {
+        self.clear();
+    }
+}
+
+impl Arena {
+    pub fn new(chunk_size: usize) -> Self {
+        assert!(chunk_size > 0);
+        Self {
+            chunks: vec![Chunk::new(chunk_size)],
+            elements: Vec::new(),
+            valid: Rc::new(Cell::new(true)),
+            current_chunk_index: 0,
+            chunk_size,
+        }
     }
 
     pub fn capacity(&self) -> usize {
-        self.end as usize - self.start as usize
+        self.chunks.len() * self.chunk_size
     }
 
     pub fn clear(&mut self) {
         self.valid.set(false);
         self.valid = Rc::new(Cell::new(true));
         self.elements.clear();
-        self.offset = self.start;
+        for chunk_index in 0..=self.current_chunk_index {
+            self.chunks[chunk_index].reset();
+        }
+        self.current_chunk_index = 0;
     }
 
     #[inline(always)]
@@ -79,33 +134,45 @@ impl Arena {
 
         unsafe {
             let layout = alloc::Layout::new::<T>();
-            let offset = self.offset.add(self.offset.align_offset(layout.align()));
-            let next_offset = offset.add(layout.size());
-            assert!(next_offset <= self.end, "not enough space in Arena");
-
-            let result = ArenaBox {
-                ptr: offset.cast(),
-                valid: self.valid.clone(),
+            let mut current_chunk = &mut self.chunks[self.current_chunk_index];
+            let ptr = if let Some(ptr) = current_chunk.allocate(layout) {
+                ptr
+            } else {
+                self.current_chunk_index += 1;
+                if self.current_chunk_index >= self.chunks.len() {
+                    self.chunks.push(Chunk::new(self.chunk_size));
+                    assert_eq!(self.current_chunk_index, self.chunks.len() - 1);
+                    log::info!(
+                        "increased element arena capacity to {}kb",
+                        self.capacity() / 1024,
+                    );
+                }
+                current_chunk = &mut self.chunks[self.current_chunk_index];
+                if let Some(ptr) = current_chunk.allocate(layout) {
+                    ptr
+                } else {
+                    panic!(
+                        "Arena chunk_size of {} is too small to allocate {} bytes",
+                        self.chunk_size,
+                        layout.size()
+                    );
+                }
             };
 
-            inner_writer(result.ptr, f);
+            inner_writer(ptr.cast(), f);
             self.elements.push(ArenaElement {
-                value: offset,
+                value: ptr,
                 drop: drop::<T>,
             });
-            self.offset = next_offset;
 
-            result
+            ArenaBox {
+                ptr: ptr.cast(),
+                valid: self.valid.clone(),
+            }
         }
     }
 }
 
-impl Drop for Arena {
-    fn drop(&mut self) {
-        self.clear();
-    }
-}
-
 pub struct ArenaBox<T: ?Sized> {
     ptr: *mut T,
     valid: Rc<Cell<bool>>,
@@ -147,32 +214,6 @@ impl<T: ?Sized> DerefMut for ArenaBox<T> {
     }
 }
 
-pub struct ArenaRef<T: ?Sized>(ArenaBox<T>);
-
-impl<T: ?Sized> From<ArenaBox<T>> for ArenaRef<T> {
-    fn from(value: ArenaBox<T>) -> Self {
-        ArenaRef(value)
-    }
-}
-
-impl<T: ?Sized> Clone for ArenaRef<T> {
-    fn clone(&self) -> Self {
-        Self(ArenaBox {
-            ptr: self.0.ptr,
-            valid: self.0.valid.clone(),
-        })
-    }
-}
-
-impl<T: ?Sized> Deref for ArenaRef<T> {
-    type Target = T;
-
-    #[inline(always)]
-    fn deref(&self) -> &Self::Target {
-        self.0.deref()
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use std::{cell::Cell, rc::Rc};
@@ -215,13 +256,17 @@ mod tests {
     }
 
     #[test]
-    #[should_panic(expected = "not enough space in Arena")]
-    fn test_arena_overflow() {
-        let mut arena = Arena::new(16);
+    fn test_arena_grow() {
+        let mut arena = Arena::new(8);
         arena.alloc(|| 1u64);
         arena.alloc(|| 2u64);
-        // This should panic.
-        arena.alloc(|| 3u64);
+
+        assert_eq!(arena.capacity(), 16);
+
+        arena.alloc(|| 3u32);
+        arena.alloc(|| 4u32);
+
+        assert_eq!(arena.capacity(), 24);
     }
 
     #[test]

crates/gpui/src/bounds_tree.rs 🔗

@@ -8,7 +8,7 @@ use std::{
 #[derive(Debug)]
 pub(crate) struct BoundsTree<U>
 where
-    U: Default + Clone + Debug,
+    U: Clone + Debug + Default + PartialEq,
 {
     root: Option<usize>,
     nodes: Vec<Node<U>>,
@@ -17,7 +17,14 @@ where
 
 impl<U> BoundsTree<U>
 where
-    U: Clone + Debug + PartialOrd + Add<U, Output = U> + Sub<Output = U> + Half + Default,
+    U: Clone
+        + Debug
+        + PartialEq
+        + PartialOrd
+        + Add<U, Output = U>
+        + Sub<Output = U>
+        + Half
+        + Default,
 {
     pub fn clear(&mut self) {
         self.root = None;
@@ -104,7 +111,7 @@ where
             self.root = Some(new_parent);
         }
 
-        for node_index in self.stack.drain(..) {
+        for node_index in self.stack.drain(..).rev() {
             let Node::Internal {
                 max_order: max_ordering,
                 ..
@@ -112,7 +119,10 @@ where
             else {
                 unreachable!()
             };
-            *max_ordering = cmp::max(*max_ordering, ordering);
+            if *max_ordering >= ordering {
+                break;
+            }
+            *max_ordering = ordering;
         }
 
         ordering
@@ -174,7 +184,7 @@ where
 
 impl<U> Default for BoundsTree<U>
 where
-    U: Default + Clone + Debug,
+    U: Clone + Debug + Default + PartialEq,
 {
     fn default() -> Self {
         BoundsTree {
@@ -188,7 +198,7 @@ where
 #[derive(Debug, Clone)]
 enum Node<U>
 where
-    U: Clone + Default + Debug,
+    U: Clone + Debug + Default + PartialEq,
 {
     Leaf {
         bounds: Bounds<U>,
@@ -204,7 +214,7 @@ where
 
 impl<U> Node<U>
 where
-    U: Clone + Default + Debug,
+    U: Clone + Debug + Default + PartialEq,
 {
     fn bounds(&self) -> &Bounds<U> {
         match self {
@@ -230,6 +240,7 @@ where
 mod tests {
     use super::*;
     use crate::{Bounds, Point, Size};
+    use rand::{Rng, SeedableRng};
 
     #[test]
     fn test_insert() {
@@ -287,4 +298,40 @@ mod tests {
         assert_eq!(tree.insert(bounds5), 1); // bounds5 does not overlap with any other bounds
         assert_eq!(tree.insert(bounds6), 2); // bounds6 overlaps with bounds4, so it should have a different order
     }
+
+    #[test]
+    fn test_random_iterations() {
+        let max_bounds = 100;
+        for seed in 1..=1000 {
+            // let seed = 44;
+            let mut tree = BoundsTree::default();
+            let mut rng = rand::rngs::StdRng::seed_from_u64(seed as u64);
+            let mut expected_quads: Vec<(Bounds<f32>, u32)> = Vec::new();
+
+            // Insert a random number of random AABBs into the tree.
+            let num_bounds = rng.gen_range(1..=max_bounds);
+            for _ in 0..num_bounds {
+                let min_x: f32 = rng.gen_range(-100.0..100.0);
+                let min_y: f32 = rng.gen_range(-100.0..100.0);
+                let width: f32 = rng.gen_range(0.0..50.0);
+                let height: f32 = rng.gen_range(0.0..50.0);
+                let bounds = Bounds {
+                    origin: Point { x: min_x, y: min_y },
+                    size: Size { width, height },
+                };
+
+                let expected_ordering = expected_quads
+                    .iter()
+                    .filter_map(|quad| quad.0.intersects(&bounds).then_some(quad.1))
+                    .max()
+                    .unwrap_or(0)
+                    + 1;
+                expected_quads.push((bounds, expected_ordering));
+
+                // Insert the AABB into the tree and collect intersections.
+                let actual_ordering = tree.insert(bounds);
+                assert_eq!(actual_ordering, expected_ordering);
+            }
+        }
+    }
 }

crates/gpui/src/color.rs 🔗

@@ -1,5 +1,9 @@
-use anyhow::{Context, bail};
-use serde::de::{self, Deserialize, Deserializer, Visitor};
+use anyhow::{Context as _, bail};
+use schemars::{JsonSchema, SchemaGenerator, schema::Schema};
+use serde::{
+    Deserialize, Deserializer, Serialize, Serializer,
+    de::{self, Visitor},
+};
 use std::{
     fmt::{self, Display, Formatter},
     hash::{Hash, Hasher},
@@ -94,12 +98,48 @@ impl Visitor<'_> for RgbaVisitor {
     }
 }
 
+impl JsonSchema for Rgba {
+    fn schema_name() -> String {
+        "Rgba".to_string()
+    }
+
+    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
+        use schemars::schema::{InstanceType, SchemaObject, StringValidation};
+
+        Schema::Object(SchemaObject {
+            instance_type: Some(InstanceType::String.into()),
+            string: Some(Box::new(StringValidation {
+                pattern: Some(
+                    r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$".to_string(),
+                ),
+                ..Default::default()
+            })),
+            ..Default::default()
+        })
+    }
+}
+
 impl<'de> Deserialize<'de> for Rgba {
     fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
         deserializer.deserialize_str(RgbaVisitor)
     }
 }
 
+impl Serialize for Rgba {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        let r = (self.r * 255.0).round() as u8;
+        let g = (self.g * 255.0).round() as u8;
+        let b = (self.b * 255.0).round() as u8;
+        let a = (self.a * 255.0).round() as u8;
+
+        let s = format!("#{r:02x}{g:02x}{b:02x}{a:02x}");
+        serializer.serialize_str(&s)
+    }
+}
+
 impl From<Hsla> for Rgba {
     fn from(color: Hsla) -> Self {
         let h = color.h;
@@ -588,20 +628,35 @@ impl From<Rgba> for Hsla {
     }
 }
 
+impl JsonSchema for Hsla {
+    fn schema_name() -> String {
+        Rgba::schema_name()
+    }
+
+    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
+        Rgba::json_schema(generator)
+    }
+}
+
+impl Serialize for Hsla {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        Rgba::from(*self).serialize(serializer)
+    }
+}
+
 impl<'de> Deserialize<'de> for Hsla {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     where
         D: Deserializer<'de>,
     {
-        // First, deserialize it into Rgba
-        let rgba = Rgba::deserialize(deserializer)?;
-
-        // Then, use the From<Rgba> for Hsla implementation to convert it
-        Ok(Hsla::from(rgba))
+        Ok(Rgba::deserialize(deserializer)?.into())
     }
 }
 
-#[derive(Debug, Clone, Copy, PartialEq)]
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
 #[repr(C)]
 pub(crate) enum BackgroundTag {
     Solid = 0,
@@ -614,7 +669,7 @@ pub(crate) enum BackgroundTag {
 /// References:
 /// - <https://developer.mozilla.org/en-US/docs/Web/CSS/color-interpolation-method>
 /// - <https://www.w3.org/TR/css-color-4/#typedef-color-space>
-#[derive(Debug, Clone, Copy, PartialEq, Default)]
+#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
 #[repr(C)]
 pub enum ColorSpace {
     #[default]
@@ -634,7 +689,7 @@ impl Display for ColorSpace {
 }
 
 /// A background color, which can be either a solid color or a linear gradient.
-#[derive(Clone, Copy, PartialEq)]
+#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
 #[repr(C)]
 pub struct Background {
     pub(crate) tag: BackgroundTag,
@@ -727,7 +782,7 @@ pub fn linear_gradient(
 /// A color stop in a linear gradient.
 ///
 /// <https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/linear-gradient#linear-color-stop>
-#[derive(Debug, Clone, Copy, Default, PartialEq)]
+#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
 #[repr(C)]
 pub struct LinearColorStop {
     /// The color of the color stop.

crates/gpui/src/colors.rs 🔗

@@ -0,0 +1,122 @@
+use crate::{App, Global, Rgba, Window, WindowAppearance, rgb};
+use std::ops::Deref;
+use std::sync::Arc;
+
+/// The default set of colors for gpui.
+///
+/// These are used for styling base components, examples and more.
+#[derive(Clone, Debug)]
+pub struct Colors {
+    /// Text color
+    pub text: Rgba,
+    /// Selected text color
+    pub selected_text: Rgba,
+    /// Background color
+    pub background: Rgba,
+    /// Disabled color
+    pub disabled: Rgba,
+    /// Selected color
+    pub selected: Rgba,
+    /// Border color
+    pub border: Rgba,
+    /// Separator color
+    pub separator: Rgba,
+    /// Container color
+    pub container: Rgba,
+}
+
+impl Default for Colors {
+    fn default() -> Self {
+        Self::light()
+    }
+}
+
+impl Colors {
+    /// Returns the default colors for the given window appearance.
+    pub fn for_appearance(window: &Window) -> Self {
+        match window.appearance() {
+            WindowAppearance::Light | WindowAppearance::VibrantLight => Self::light(),
+            WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::dark(),
+        }
+    }
+
+    /// Returns the default dark colors.
+    pub fn dark() -> Self {
+        Self {
+            text: rgb(0xffffff),
+            selected_text: rgb(0xffffff),
+            disabled: rgb(0x565656),
+            selected: rgb(0x2457ca),
+            background: rgb(0x222222),
+            border: rgb(0x000000),
+            separator: rgb(0xd9d9d9),
+            container: rgb(0x262626),
+        }
+    }
+
+    /// Returns the default light colors.
+    pub fn light() -> Self {
+        Self {
+            text: rgb(0x252525),
+            selected_text: rgb(0xffffff),
+            background: rgb(0xffffff),
+            disabled: rgb(0xb0b0b0),
+            selected: rgb(0x2a63d9),
+            border: rgb(0xd9d9d9),
+            separator: rgb(0xe6e6e6),
+            container: rgb(0xf4f5f5),
+        }
+    }
+
+    /// Get [Colors] from the global state
+    pub fn get_global(cx: &App) -> &Arc<Colors> {
+        &cx.global::<GlobalColors>().0
+    }
+}
+
+/// Get [Colors] from the global state
+#[derive(Clone, Debug)]
+pub struct GlobalColors(pub Arc<Colors>);
+
+impl Deref for GlobalColors {
+    type Target = Arc<Colors>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl Global for GlobalColors {}
+
+/// Implement this trait to allow global [Color] access via `cx.default_colors()`.
+pub trait DefaultColors {
+    /// Returns the default [`gpui::Colors`]
+    fn default_colors(&self) -> &Arc<Colors>;
+}
+
+impl DefaultColors for App {
+    fn default_colors(&self) -> &Arc<Colors> {
+        &self.global::<GlobalColors>().0
+    }
+}
+
+/// The appearance of the base GPUI colors, used to style GPUI elements
+///
+/// Varies based on the system's current [`WindowAppearance`].
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub enum DefaultAppearance {
+    /// Use the set of colors for light appearances.
+    #[default]
+    Light,
+    /// Use the set of colors for dark appearances.
+    Dark,
+}
+
+impl From<WindowAppearance> for DefaultAppearance {
+    fn from(appearance: WindowAppearance) -> Self {
+        match appearance {
+            WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
+            WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
+        }
+    }
+}

crates/gpui/src/element.rs 🔗

@@ -33,11 +33,16 @@
 
 use crate::{
     App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ELEMENT_ARENA, ElementId,
-    FocusHandle, LayoutId, Pixels, Point, Size, Style, Window, util::FluentBuilder,
+    FocusHandle, InspectorElementId, LayoutId, Pixels, Point, Size, Style, Window,
+    util::FluentBuilder,
 };
 use derive_more::{Deref, DerefMut};
 pub(crate) use smallvec::SmallVec;
-use std::{any::Any, fmt::Debug, mem};
+use std::{
+    any::Any,
+    fmt::{self, Debug, Display},
+    mem, panic,
+};
 
 /// Implemented by types that participate in laying out and painting the contents of a window.
 /// Elements form a tree and are laid out according to web-based layout rules, as implemented by Taffy.
@@ -59,11 +64,16 @@ pub trait Element: 'static + IntoElement {
     /// frames. This id must be unique among children of the first containing element with an id.
     fn id(&self) -> Option<ElementId>;
 
+    /// Source location where this element was constructed, used to disambiguate elements in the
+    /// inspector and navigate to their source code.
+    fn source_location(&self) -> Option<&'static panic::Location<'static>>;
+
     /// Before an element can be painted, we need to know where it's going to be and how big it is.
     /// Use this method to request a layout from Taffy and initialize the element's state.
     fn request_layout(
         &mut self,
         id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState);
@@ -73,6 +83,7 @@ pub trait Element: 'static + IntoElement {
     fn prepaint(
         &mut self,
         id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         request_layout: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -84,6 +95,7 @@ pub trait Element: 'static + IntoElement {
     fn paint(
         &mut self,
         id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         request_layout: &mut Self::RequestLayoutState,
         prepaint: &mut Self::PrepaintState,
@@ -167,12 +179,21 @@ pub trait ParentElement {
 /// An element for rendering components. An implementation detail of the [`IntoElement`] derive macro
 /// for [`RenderOnce`]
 #[doc(hidden)]
-pub struct Component<C: RenderOnce>(Option<C>);
+pub struct Component<C: RenderOnce> {
+    component: Option<C>,
+    #[cfg(debug_assertions)]
+    source: &'static core::panic::Location<'static>,
+}
 
 impl<C: RenderOnce> Component<C> {
     /// Create a new component from the given RenderOnce type.
+    #[track_caller]
     pub fn new(component: C) -> Self {
-        Component(Some(component))
+        Component {
+            component: Some(component),
+            #[cfg(debug_assertions)]
+            source: core::panic::Location::caller(),
+        }
     }
 }
 
@@ -184,13 +205,27 @@ impl<C: RenderOnce> Element for Component<C> {
         None
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        #[cfg(debug_assertions)]
+        return Some(self.source);
+
+        #[cfg(not(debug_assertions))]
+        return None;
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
-        let mut element = self.0.take().unwrap().render(window, cx).into_any_element();
+        let mut element = self
+            .component
+            .take()
+            .unwrap()
+            .render(window, cx)
+            .into_any_element();
         let layout_id = element.request_layout(window, cx);
         (layout_id, element)
     }
@@ -198,6 +233,7 @@ impl<C: RenderOnce> Element for Component<C> {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _: Bounds<Pixels>,
         element: &mut AnyElement,
         window: &mut Window,
@@ -209,6 +245,7 @@ impl<C: RenderOnce> Element for Component<C> {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _: Bounds<Pixels>,
         element: &mut Self::RequestLayoutState,
         _: &mut Self::PrepaintState,
@@ -231,6 +268,18 @@ impl<C: RenderOnce> IntoElement for Component<C> {
 #[derive(Deref, DerefMut, Default, Debug, Eq, PartialEq, Hash)]
 pub struct GlobalElementId(pub(crate) SmallVec<[ElementId; 32]>);
 
+impl Display for GlobalElementId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        for (i, element_id) in self.0.iter().enumerate() {
+            if i > 0 {
+                write!(f, ".")?;
+            }
+            write!(f, "{}", element_id)?;
+        }
+        Ok(())
+    }
+}
+
 trait ElementObject {
     fn inner_element(&mut self) -> &mut dyn Any;
 
@@ -262,17 +311,20 @@ enum ElementDrawPhase<RequestLayoutState, PrepaintState> {
     RequestLayout {
         layout_id: LayoutId,
         global_id: Option<GlobalElementId>,
+        inspector_id: Option<InspectorElementId>,
         request_layout: RequestLayoutState,
     },
     LayoutComputed {
         layout_id: LayoutId,
         global_id: Option<GlobalElementId>,
+        inspector_id: Option<InspectorElementId>,
         available_space: Size<AvailableSpace>,
         request_layout: RequestLayoutState,
     },
     Prepaint {
         node_id: DispatchNodeId,
         global_id: Option<GlobalElementId>,
+        inspector_id: Option<InspectorElementId>,
         bounds: Bounds<Pixels>,
         request_layout: RequestLayoutState,
         prepaint: PrepaintState,
@@ -297,8 +349,28 @@ impl<E: Element> Drawable<E> {
                     GlobalElementId(window.element_id_stack.clone())
                 });
 
-                let (layout_id, request_layout) =
-                    self.element.request_layout(global_id.as_ref(), window, cx);
+                let inspector_id;
+                #[cfg(any(feature = "inspector", debug_assertions))]
+                {
+                    inspector_id = self.element.source_location().map(|source| {
+                        let path = crate::InspectorElementPath {
+                            global_id: GlobalElementId(window.element_id_stack.clone()),
+                            source_location: source,
+                        };
+                        window.build_inspector_element_id(path)
+                    });
+                }
+                #[cfg(not(any(feature = "inspector", debug_assertions)))]
+                {
+                    inspector_id = None;
+                }
+
+                let (layout_id, request_layout) = self.element.request_layout(
+                    global_id.as_ref(),
+                    inspector_id.as_ref(),
+                    window,
+                    cx,
+                );
 
                 if global_id.is_some() {
                     window.element_id_stack.pop();
@@ -307,6 +379,7 @@ impl<E: Element> Drawable<E> {
                 self.phase = ElementDrawPhase::RequestLayout {
                     layout_id,
                     global_id,
+                    inspector_id,
                     request_layout,
                 };
                 layout_id
@@ -320,11 +393,13 @@ impl<E: Element> Drawable<E> {
             ElementDrawPhase::RequestLayout {
                 layout_id,
                 global_id,
+                inspector_id,
                 mut request_layout,
             }
             | ElementDrawPhase::LayoutComputed {
                 layout_id,
                 global_id,
+                inspector_id,
                 mut request_layout,
                 ..
             } => {
@@ -337,6 +412,7 @@ impl<E: Element> Drawable<E> {
                 let node_id = window.next_frame.dispatch_tree.push_node();
                 let prepaint = self.element.prepaint(
                     global_id.as_ref(),
+                    inspector_id.as_ref(),
                     bounds,
                     &mut request_layout,
                     window,
@@ -351,6 +427,7 @@ impl<E: Element> Drawable<E> {
                 self.phase = ElementDrawPhase::Prepaint {
                     node_id,
                     global_id,
+                    inspector_id,
                     bounds,
                     request_layout,
                     prepaint,
@@ -369,6 +446,7 @@ impl<E: Element> Drawable<E> {
             ElementDrawPhase::Prepaint {
                 node_id,
                 global_id,
+                inspector_id,
                 bounds,
                 mut request_layout,
                 mut prepaint,
@@ -382,6 +460,7 @@ impl<E: Element> Drawable<E> {
                 window.next_frame.dispatch_tree.set_active_node(node_id);
                 self.element.paint(
                     global_id.as_ref(),
+                    inspector_id.as_ref(),
                     bounds,
                     &mut request_layout,
                     &mut prepaint,
@@ -414,12 +493,14 @@ impl<E: Element> Drawable<E> {
             ElementDrawPhase::RequestLayout {
                 layout_id,
                 global_id,
+                inspector_id,
                 request_layout,
             } => {
                 window.compute_layout(layout_id, available_space, cx);
                 self.phase = ElementDrawPhase::LayoutComputed {
                     layout_id,
                     global_id,
+                    inspector_id,
                     available_space,
                     request_layout,
                 };
@@ -428,6 +509,7 @@ impl<E: Element> Drawable<E> {
             ElementDrawPhase::LayoutComputed {
                 layout_id,
                 global_id,
+                inspector_id,
                 available_space: prev_available_space,
                 request_layout,
             } => {
@@ -437,6 +519,7 @@ impl<E: Element> Drawable<E> {
                 self.phase = ElementDrawPhase::LayoutComputed {
                     layout_id,
                     global_id,
+                    inspector_id,
                     available_space,
                     request_layout,
                 };
@@ -570,9 +653,14 @@ impl Element for AnyElement {
         None
     }
 
+    fn source_location(&self) -> Option<&'static panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
@@ -583,6 +671,7 @@ impl Element for AnyElement {
     fn prepaint(
         &mut self,
         _: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _: Bounds<Pixels>,
         _: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -594,6 +683,7 @@ impl Element for AnyElement {
     fn paint(
         &mut self,
         _: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _: Bounds<Pixels>,
         _: &mut Self::RequestLayoutState,
         _: &mut Self::PrepaintState,
@@ -635,9 +725,14 @@ impl Element for Empty {
         None
     }
 
+    fn source_location(&self) -> Option<&'static panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
@@ -647,6 +742,7 @@ impl Element for Empty {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: Bounds<Pixels>,
         _state: &mut Self::RequestLayoutState,
         _window: &mut Window,
@@ -657,6 +753,7 @@ impl Element for Empty {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: Bounds<Pixels>,
         _request_layout: &mut Self::RequestLayoutState,
         _prepaint: &mut Self::PrepaintState,

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

@@ -1,9 +1,9 @@
 use smallvec::SmallVec;
-use taffy::style::{Display, Position};
 
 use crate::{
-    AnyElement, App, Axis, Bounds, Corner, Edges, Element, GlobalElementId, IntoElement, LayoutId,
-    ParentElement, Pixels, Point, Size, Style, Window, point, px,
+    AnyElement, App, Axis, Bounds, Corner, Display, Edges, Element, GlobalElementId,
+    InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
+    Window, point, px,
 };
 
 /// The state that the anchored element element uses to track its children.
@@ -91,9 +91,14 @@ impl Element for Anchored {
         None
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (crate::LayoutId, Self::RequestLayoutState) {
@@ -117,6 +122,7 @@ impl Element for Anchored {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         request_layout: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -213,6 +219,7 @@ impl Element for Anchored {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: crate::Bounds<crate::Pixels>,
         _request_layout: &mut Self::RequestLayoutState,
         _prepaint: &mut Self::PrepaintState,

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

@@ -1,6 +1,8 @@
 use std::time::{Duration, Instant};
 
-use crate::{AnyElement, App, Element, ElementId, GlobalElementId, IntoElement, Window};
+use crate::{
+    AnyElement, App, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, Window,
+};
 
 pub use easing::*;
 use smallvec::SmallVec;
@@ -121,9 +123,14 @@ impl<E: IntoElement + 'static> Element for AnimationElement<E> {
         Some(self.id.clone())
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (crate::LayoutId, Self::RequestLayoutState) {
@@ -172,6 +179,7 @@ impl<E: IntoElement + 'static> Element for AnimationElement<E> {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: crate::Bounds<crate::Pixels>,
         element: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -183,6 +191,7 @@ impl<E: IntoElement + 'static> Element for AnimationElement<E> {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: crate::Bounds<crate::Pixels>,
         element: &mut Self::RequestLayoutState,
         _: &mut Self::PrepaintState,

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

@@ -1,8 +1,8 @@
 use refineable::Refineable as _;
 
 use crate::{
-    App, Bounds, Element, ElementId, GlobalElementId, IntoElement, Pixels, Style, StyleRefinement,
-    Styled, Window,
+    App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, Pixels,
+    Style, StyleRefinement, Styled, Window,
 };
 
 /// Construct a canvas element with the given paint callback.
@@ -42,9 +42,14 @@ impl<T: 'static> Element for Canvas<T> {
         None
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (crate::LayoutId, Self::RequestLayoutState) {
@@ -57,6 +62,7 @@ impl<T: 'static> Element for Canvas<T> {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         _request_layout: &mut Style,
         window: &mut Window,
@@ -68,6 +74,7 @@ impl<T: 'static> Element for Canvas<T> {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         style: &mut Style,
         prepaint: &mut Self::PrepaintState,

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

@@ -1,115 +0,0 @@
-use crate::{Hsla, Rgba, WindowAppearance, rgb};
-
-/// The appearance of the base GPUI colors, used to style GPUI elements
-///
-/// Varies based on the system's current [`WindowAppearance`].
-#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
-pub enum DefaultThemeAppearance {
-    /// Use the set of colors for light appearances.
-    #[default]
-    Light,
-    /// Use the set of colors for dark appearances.
-    Dark,
-}
-
-impl From<WindowAppearance> for DefaultThemeAppearance {
-    fn from(appearance: WindowAppearance) -> Self {
-        match appearance {
-            WindowAppearance::Light | WindowAppearance::VibrantLight => Self::Light,
-            WindowAppearance::Dark | WindowAppearance::VibrantDark => Self::Dark,
-        }
-    }
-}
-
-/// Returns the default colors for the given appearance.
-pub fn colors(appearance: DefaultThemeAppearance) -> DefaultColors {
-    match appearance {
-        DefaultThemeAppearance::Light => DefaultColors::light(),
-        DefaultThemeAppearance::Dark => DefaultColors::dark(),
-    }
-}
-
-/// A collection of colors.
-#[derive(Debug, Clone, Copy, PartialEq)]
-pub struct DefaultColors {
-    text: Rgba,
-    selected_text: Rgba,
-    background: Rgba,
-    disabled: Rgba,
-    selected: Rgba,
-    border: Rgba,
-    separator: Rgba,
-    container: Rgba,
-}
-
-impl DefaultColors {
-    /// Returns the default dark colors.
-    pub fn dark() -> Self {
-        Self {
-            text: rgb(0xffffff),
-            selected_text: rgb(0xffffff),
-            disabled: rgb(0x565656),
-            selected: rgb(0x2457ca),
-            background: rgb(0x222222),
-            border: rgb(0x000000),
-            separator: rgb(0xd9d9d9),
-            container: rgb(0x262626),
-        }
-    }
-
-    /// Returns the default light colors.
-    pub fn light() -> Self {
-        Self {
-            text: rgb(0x252525),
-            selected_text: rgb(0xffffff),
-            background: rgb(0xffffff),
-            disabled: rgb(0xb0b0b0),
-            selected: rgb(0x2a63d9),
-            border: rgb(0xd9d9d9),
-            separator: rgb(0xe6e6e6),
-            container: rgb(0xf4f5f5),
-        }
-    }
-}
-
-/// A default GPUI color.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumIter)]
-pub enum DefaultColor {
-    /// Text color
-    Text,
-    /// Selected text color
-    SelectedText,
-    /// Background color
-    Background,
-    /// Disabled color
-    Disabled,
-    /// Selected color
-    Selected,
-    /// Border color
-    Border,
-    /// Separator color
-    Separator,
-    /// Container color
-    Container,
-}
-
-impl DefaultColor {
-    /// Returns the RGBA color for the given color type.
-    pub fn color(&self, colors: &DefaultColors) -> Rgba {
-        match self {
-            DefaultColor::Text => colors.text,
-            DefaultColor::SelectedText => colors.selected_text,
-            DefaultColor::Background => colors.background,
-            DefaultColor::Disabled => colors.disabled,
-            DefaultColor::Selected => colors.selected,
-            DefaultColor::Border => colors.border,
-            DefaultColor::Separator => colors.separator,
-            DefaultColor::Container => colors.container,
-        }
-    }
-
-    /// Returns the HSLA color for the given color type.
-    pub fn hsla(&self, colors: &DefaultColors) -> Hsla {
-        self.color(colors).into()
-    }
-}

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

@@ -1,5 +1,6 @@
 use crate::{
-    AnyElement, App, Bounds, Element, GlobalElementId, IntoElement, LayoutId, Pixels, Window,
+    AnyElement, App, Bounds, Element, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
+    Pixels, Window,
 };
 
 /// Builds a `Deferred` element, which delays the layout and paint of its child.
@@ -35,9 +36,14 @@ impl Element for Deferred {
         None
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, ()) {
@@ -48,6 +54,7 @@ impl Element for Deferred {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: Bounds<Pixels>,
         _request_layout: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -61,6 +68,7 @@ impl Element for Deferred {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: Bounds<Pixels>,
         _request_layout: &mut Self::RequestLayoutState,
         _prepaint: &mut Self::PrepaintState,

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

@@ -17,11 +17,12 @@
 
 use crate::{
     Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase,
-    Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, Hitbox, HitboxId,
-    IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, LayoutId, ModifiersChangedEvent,
-    MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point,
-    Render, ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId,
-    Visibility, Window, point, px, size,
+    Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior,
+    HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent,
+    LayoutId, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
+    Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style,
+    StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px,
+    size,
 };
 use collections::HashMap;
 use refineable::Refineable;
@@ -37,7 +38,6 @@ use std::{
     sync::Arc,
     time::Duration,
 };
-use taffy::style::Overflow;
 use util::ResultExt;
 
 use super::ImageCacheProvider;
@@ -83,6 +83,35 @@ impl<T: 'static> DragMoveEvent<T> {
 }
 
 impl Interactivity {
+    /// Create an `Interactivity`, capturing the caller location in debug mode.
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    #[track_caller]
+    pub fn new() -> Interactivity {
+        Interactivity {
+            source_location: Some(core::panic::Location::caller()),
+            ..Default::default()
+        }
+    }
+
+    /// Create an `Interactivity`, capturing the caller location in debug mode.
+    #[cfg(not(any(feature = "inspector", debug_assertions)))]
+    pub fn new() -> Interactivity {
+        Interactivity::default()
+    }
+
+    /// Gets the source location of construction. Returns `None` when not in debug mode.
+    pub fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
+        #[cfg(any(feature = "inspector", debug_assertions))]
+        {
+            self.source_location
+        }
+
+        #[cfg(not(any(feature = "inspector", debug_assertions)))]
+        {
+            None
+        }
+    }
+
     /// Bind the given callback to the mouse down event for the given mouse button, during the bubble phase
     /// The imperative API equivalent of [`InteractiveElement::on_mouse_down`]
     ///
@@ -285,7 +314,7 @@ impl Interactivity {
     ) {
         self.scroll_wheel_listeners
             .push(Box::new(move |event, phase, hitbox, window, cx| {
-                if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) {
+                if phase == DispatchPhase::Bubble && hitbox.should_handle_scroll(window) {
                     (listener)(event, window, cx);
                 }
             }));
@@ -539,19 +568,26 @@ impl Interactivity {
         });
     }
 
-    /// Block the mouse from interacting with this element or any of its children
+    /// Block the mouse from all interactions with elements behind this element's hitbox. Typically
+    /// `block_mouse_except_scroll` should be preferred.
+    ///
     /// The imperative API equivalent to [`InteractiveElement::occlude`]
     pub fn occlude_mouse(&mut self) {
-        self.occlude_mouse = true;
+        self.hitbox_behavior = HitboxBehavior::BlockMouse;
     }
 
-    /// Registers event handles that stop propagation of mouse events for non-scroll events.
+    /// Set the bounds of this element as a window control area for the platform window.
+    /// The imperative API equivalent to [`InteractiveElement::window_control_area`]
+    pub fn window_control_area(&mut self, area: WindowControlArea) {
+        self.window_control = Some(area);
+    }
+
+    /// Block non-scroll mouse interactions with elements behind this element's hitbox. See
+    /// [`Hitbox::is_hovered`] for details.
+    ///
     /// The imperative API equivalent to [`InteractiveElement::block_mouse_except_scroll`]
-    pub fn stop_mouse_events_except_scroll(&mut self) {
-        self.on_any_mouse_down(|_, _, cx| cx.stop_propagation());
-        self.on_any_mouse_up(|_, _, cx| cx.stop_propagation());
-        self.on_click(|_, _, cx| cx.stop_propagation());
-        self.on_hover(|_, _, cx| cx.stop_propagation());
+    pub fn block_mouse_except_scroll(&mut self) {
+        self.hitbox_behavior = HitboxBehavior::BlockMouseExceptScroll;
     }
 }
 
@@ -921,22 +957,27 @@ pub trait InteractiveElement: Sized {
         self
     }
 
-    /// Block the mouse from interacting with this element or any of its children
+    /// Block the mouse from all interactions with elements behind this element's hitbox. Typically
+    /// `block_mouse_except_scroll` should be preferred.
     /// The fluent API equivalent to [`Interactivity::occlude_mouse`]
     fn occlude(mut self) -> Self {
         self.interactivity().occlude_mouse();
         self
     }
 
-    /// Stops propagation of left mouse down event.
-    fn block_mouse_down(mut self) -> Self {
-        self.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
+    /// Set the bounds of this element as a window control area for the platform window.
+    /// The fluent API equivalent to [`Interactivity::window_control_area`]
+    fn window_control_area(mut self, area: WindowControlArea) -> Self {
+        self.interactivity().window_control_area(area);
+        self
     }
 
-    /// Registers event handles that stop propagation of mouse events for non-scroll events.
+    /// Block non-scroll mouse interactions with elements behind this element's hitbox. See
+    /// [`Hitbox::is_hovered`] for details.
+    ///
     /// The fluent API equivalent to [`Interactivity::block_mouse_except_scroll`]
-    fn stop_mouse_events_except_scroll(mut self) -> Self {
-        self.interactivity().stop_mouse_events_except_scroll();
+    fn block_mouse_except_scroll(mut self) -> Self {
+        self.interactivity().block_mouse_except_scroll();
         self
     }
 }
@@ -1138,17 +1179,8 @@ pub(crate) type ActionListener =
 /// Construct a new [`Div`] element
 #[track_caller]
 pub fn div() -> Div {
-    #[cfg(debug_assertions)]
-    let interactivity = Interactivity {
-        location: Some(*core::panic::Location::caller()),
-        ..Default::default()
-    };
-
-    #[cfg(not(debug_assertions))]
-    let interactivity = Interactivity::default();
-
     Div {
-        interactivity,
+        interactivity: Interactivity::new(),
         children: SmallVec::default(),
         prepaint_listener: None,
         image_cache: None,
@@ -1191,6 +1223,20 @@ pub struct DivFrameState {
     child_layout_ids: SmallVec<[LayoutId; 2]>,
 }
 
+/// Interactivity state displayed an manipulated in the inspector.
+#[derive(Clone)]
+pub struct DivInspectorState {
+    /// The inspected element's base style. This is used for both inspecting and modifying the
+    /// state. In the future it will make sense to separate the read and write, possibly tracking
+    /// the modifications.
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub base_style: Box<StyleRefinement>,
+    /// Inspects the bounds of the element.
+    pub bounds: Bounds<Pixels>,
+    /// Size of the children of the element, or `bounds.size` if it has no children.
+    pub content_size: Size<Pixels>,
+}
+
 impl Styled for Div {
     fn style(&mut self) -> &mut StyleRefinement {
         &mut self.interactivity.base_style
@@ -1217,9 +1263,14 @@ impl Element for Div {
         self.interactivity.element_id.clone()
     }
 
+    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
+        self.interactivity.source_location()
+    }
+
     fn request_layout(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
@@ -1230,8 +1281,12 @@ impl Element for Div {
             .map(|provider| provider.provide(window, cx));
 
         let layout_id = window.with_image_cache(image_cache, |window| {
-            self.interactivity
-                .request_layout(global_id, window, cx, |style, window, cx| {
+            self.interactivity.request_layout(
+                global_id,
+                inspector_id,
+                window,
+                cx,
+                |style, window, cx| {
                     window.with_text_style(style.text_style().cloned(), |window| {
                         child_layout_ids = self
                             .children
@@ -1240,7 +1295,8 @@ impl Element for Div {
                             .collect::<SmallVec<_>>();
                         window.request_layout(style, child_layout_ids.iter().copied(), cx)
                     })
-                })
+                },
+            )
         });
 
         (layout_id, DivFrameState { child_layout_ids })
@@ -1249,6 +1305,7 @@ impl Element for Div {
     fn prepaint(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         request_layout: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -1294,6 +1351,7 @@ impl Element for Div {
 
         self.interactivity.prepaint(
             global_id,
+            inspector_id,
             bounds,
             content_size,
             window,
@@ -1317,6 +1375,7 @@ impl Element for Div {
     fn paint(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         _request_layout: &mut Self::RequestLayoutState,
         hitbox: &mut Option<Hitbox>,
@@ -1331,6 +1390,7 @@ impl Element for Div {
         window.with_image_cache(image_cache, |window| {
             self.interactivity.paint(
                 global_id,
+                inspector_id,
                 bounds,
                 hitbox.as_ref(),
                 window,
@@ -1401,10 +1461,11 @@ pub struct Interactivity {
     pub(crate) drag_listener: Option<(Arc<dyn Any>, DragListener)>,
     pub(crate) hover_listener: Option<Box<dyn Fn(&bool, &mut Window, &mut App)>>,
     pub(crate) tooltip_builder: Option<TooltipBuilder>,
-    pub(crate) occlude_mouse: bool,
+    pub(crate) window_control: Option<WindowControlArea>,
+    pub(crate) hitbox_behavior: HitboxBehavior,
 
-    #[cfg(debug_assertions)]
-    pub(crate) location: Option<core::panic::Location<'static>>,
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub(crate) source_location: Option<&'static core::panic::Location<'static>>,
 
     #[cfg(any(test, feature = "test-support"))]
     pub(crate) debug_selector: Option<String>,
@@ -1415,10 +1476,28 @@ impl Interactivity {
     pub fn request_layout(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
         f: impl FnOnce(Style, &mut Window, &mut App) -> LayoutId,
     ) -> LayoutId {
+        #[cfg(any(feature = "inspector", debug_assertions))]
+        window.with_inspector_state(
+            _inspector_id,
+            cx,
+            |inspector_state: &mut Option<DivInspectorState>, _window| {
+                if let Some(inspector_state) = inspector_state {
+                    self.base_style = inspector_state.base_style.clone();
+                } else {
+                    *inspector_state = Some(DivInspectorState {
+                        base_style: self.base_style.clone(),
+                        bounds: Default::default(),
+                        content_size: Default::default(),
+                    })
+                }
+            },
+        );
+
         window.with_optional_element_state::<InteractiveElementState, _>(
             global_id,
             |element_state, window| {
@@ -1478,6 +1557,7 @@ impl Interactivity {
     pub fn prepaint<R>(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         content_size: Size<Pixels>,
         window: &mut Window,
@@ -1485,6 +1565,19 @@ impl Interactivity {
         f: impl FnOnce(&Style, Point<Pixels>, Option<Hitbox>, &mut Window, &mut App) -> R,
     ) -> R {
         self.content_size = content_size;
+
+        #[cfg(any(feature = "inspector", debug_assertions))]
+        window.with_inspector_state(
+            _inspector_id,
+            cx,
+            |inspector_state: &mut Option<DivInspectorState>, _window| {
+                if let Some(inspector_state) = inspector_state {
+                    inspector_state.bounds = bounds;
+                    inspector_state.content_size = content_size;
+                }
+            },
+        );
+
         if let Some(focus_handle) = self.tracked_focus_handle.as_ref() {
             window.set_focus_handle(focus_handle, cx);
         }
@@ -1514,8 +1607,8 @@ impl Interactivity {
                     window.with_content_mask(
                         style.overflow_mask(bounds, window.rem_size()),
                         |window| {
-                            let hitbox = if self.should_insert_hitbox(&style) {
-                                Some(window.insert_hitbox(bounds, self.occlude_mouse))
+                            let hitbox = if self.should_insert_hitbox(&style, window, cx) {
+                                Some(window.insert_hitbox(bounds, self.hitbox_behavior))
                             } else {
                                 None
                             };
@@ -1531,8 +1624,9 @@ impl Interactivity {
         )
     }
 
-    fn should_insert_hitbox(&self, style: &Style) -> bool {
-        self.occlude_mouse
+    fn should_insert_hitbox(&self, style: &Style, window: &Window, cx: &App) -> bool {
+        self.hitbox_behavior != HitboxBehavior::Normal
+            || self.window_control.is_some()
             || style.mouse_cursor.is_some()
             || self.group.is_some()
             || self.scroll_offset.is_some()
@@ -1548,6 +1642,7 @@ impl Interactivity {
             || self.drag_listener.is_some()
             || !self.drop_listeners.is_empty()
             || self.tooltip_builder.is_some()
+            || window.is_inspector_picking(cx)
     }
 
     fn clamp_scroll_position(
@@ -1605,6 +1700,7 @@ impl Interactivity {
     pub fn paint(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         hitbox: Option<&Hitbox>,
         window: &mut Window,
@@ -1648,11 +1744,11 @@ impl Interactivity {
 
                                         if let Some(drag) = cx.active_drag.as_ref() {
                                             if let Some(mouse_cursor) = drag.cursor_style {
-                                                window.set_cursor_style(mouse_cursor, None);
+                                                window.set_window_cursor_style(mouse_cursor);
                                             }
                                         } else {
                                             if let Some(mouse_cursor) = style.mouse_cursor {
-                                                window.set_cursor_style(mouse_cursor, Some(hitbox));
+                                                window.set_cursor_style(mouse_cursor, hitbox);
                                             }
                                         }
 
@@ -1660,6 +1756,11 @@ impl Interactivity {
                                             GroupHitboxes::push(group, hitbox.id, cx);
                                         }
 
+                                        if let Some(area) = self.window_control {
+                                            window
+                                                .insert_window_control_hitbox(area, hitbox.clone());
+                                        }
+
                                         self.paint_mouse_listeners(
                                             hitbox,
                                             element_state.as_mut(),
@@ -1672,7 +1773,14 @@ impl Interactivity {
                                     self.paint_keyboard_listeners(window, cx);
                                     f(&style, window, cx);
 
-                                    if hitbox.is_some() {
+                                    if let Some(_hitbox) = hitbox {
+                                        #[cfg(any(feature = "inspector", debug_assertions))]
+                                        window.insert_inspector_hitbox(
+                                            _hitbox.id,
+                                            _inspector_id,
+                                            cx,
+                                        );
+
                                         if let Some(group) = self.group.as_ref() {
                                             GroupHitboxes::pop(group, cx);
                                         }
@@ -1727,7 +1835,7 @@ impl Interactivity {
                         origin: hitbox.origin,
                         size: text.size(FONT_SIZE),
                     };
-                    if self.location.is_some()
+                    if self.source_location.is_some()
                         && text_bounds.contains(&window.mouse_position())
                         && window.modifiers().secondary()
                     {
@@ -1758,7 +1866,7 @@ impl Interactivity {
 
                         window.on_mouse_event({
                             let hitbox = hitbox.clone();
-                            let location = self.location.unwrap();
+                            let location = self.source_location.unwrap();
                             move |e: &crate::MouseDownEvent, phase, window, cx| {
                                 if text_bounds.contains(&e.position)
                                     && phase.capture()
@@ -2182,7 +2290,7 @@ impl Interactivity {
             let hitbox = hitbox.clone();
             let current_view = window.current_view();
             window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
-                if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) {
+                if phase == DispatchPhase::Bubble && hitbox.should_handle_scroll(window) {
                     let mut scroll_offset = scroll_offset.borrow_mut();
                     let old_scroll_offset = *scroll_offset;
                     let delta = event.delta.pixel_delta(line_height);
@@ -2212,7 +2320,6 @@ impl Interactivity {
                     }
                     scroll_offset.y += delta_y;
                     scroll_offset.x += delta_x;
-                    cx.stop_propagation();
                     if *scroll_offset != old_scroll_offset {
                         cx.notify(current_view);
                     }
@@ -2721,37 +2828,52 @@ where
         self.element.id()
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        self.element.source_location()
+    }
+
     fn request_layout(
         &mut self,
         id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
-        self.element.request_layout(id, window, cx)
+        self.element.request_layout(id, inspector_id, window, cx)
     }
 
     fn prepaint(
         &mut self,
         id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         state: &mut Self::RequestLayoutState,
         window: &mut Window,
         cx: &mut App,
     ) -> E::PrepaintState {
-        self.element.prepaint(id, bounds, state, window, cx)
+        self.element
+            .prepaint(id, inspector_id, bounds, state, window, cx)
     }
 
     fn paint(
         &mut self,
         id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         request_layout: &mut Self::RequestLayoutState,
         prepaint: &mut Self::PrepaintState,
         window: &mut Window,
         cx: &mut App,
     ) {
-        self.element
-            .paint(id, bounds, request_layout, prepaint, window, cx)
+        self.element.paint(
+            id,
+            inspector_id,
+            bounds,
+            request_layout,
+            prepaint,
+            window,
+            cx,
+        )
     }
 }
 
@@ -2818,37 +2940,52 @@ where
         self.element.id()
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        self.element.source_location()
+    }
+
     fn request_layout(
         &mut self,
         id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
-        self.element.request_layout(id, window, cx)
+        self.element.request_layout(id, inspector_id, window, cx)
     }
 
     fn prepaint(
         &mut self,
         id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         state: &mut Self::RequestLayoutState,
         window: &mut Window,
         cx: &mut App,
     ) -> E::PrepaintState {
-        self.element.prepaint(id, bounds, state, window, cx)
+        self.element
+            .prepaint(id, inspector_id, bounds, state, window, cx)
     }
 
     fn paint(
         &mut self,
         id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         request_layout: &mut Self::RequestLayoutState,
         prepaint: &mut Self::PrepaintState,
         window: &mut Window,
         cx: &mut App,
     ) {
-        self.element
-            .paint(id, bounds, request_layout, prepaint, window, cx);
+        self.element.paint(
+            id,
+            inspector_id,
+            bounds,
+            request_layout,
+            prepaint,
+            window,
+            cx,
+        );
     }
 }
 

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

@@ -1,7 +1,8 @@
 use crate::{
     AnyElement, AnyEntity, App, AppContext, Asset, AssetLogger, Bounds, Element, ElementId, Entity,
-    GlobalElementId, ImageAssetLoader, ImageCacheError, IntoElement, LayoutId, ParentElement,
-    Pixels, RenderImage, Resource, Style, StyleRefinement, Styled, Task, Window, hash,
+    GlobalElementId, ImageAssetLoader, ImageCacheError, InspectorElementId, IntoElement, LayoutId,
+    ParentElement, Pixels, RenderImage, Resource, Style, StyleRefinement, Styled, Task, Window,
+    hash,
 };
 
 use futures::{FutureExt, future::Shared};
@@ -102,9 +103,14 @@ impl Element for ImageCacheElement {
         None
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
@@ -125,6 +131,7 @@ impl Element for ImageCacheElement {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: Bounds<Pixels>,
         _request_layout: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -138,6 +145,7 @@ impl Element for ImageCacheElement {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: Bounds<Pixels>,
         _request_layout: &mut Self::RequestLayoutState,
         _prepaint: &mut Self::PrepaintState,

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

@@ -1,11 +1,11 @@
 use crate::{
     AbsoluteLength, AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength,
-    Element, ElementId, Entity, GlobalElementId, Hitbox, Image, ImageCache, InteractiveElement,
-    Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource,
-    SMOOTH_SVG_SCALE_FACTOR, SharedString, SharedUri, StyleRefinement, Styled, SvgSize, Task,
-    Window, px, swap_rgba_pa_to_bgra,
+    Element, ElementId, Entity, GlobalElementId, Hitbox, Image, ImageCache, InspectorElementId,
+    InteractiveElement, Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels,
+    RenderImage, Resource, SMOOTH_SVG_SCALE_FACTOR, SharedString, SharedUri, StyleRefinement,
+    Styled, SvgSize, Task, Window, px, swap_rgba_pa_to_bgra,
 };
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 
 use futures::{AsyncReadExt, Future};
 use image::{
@@ -194,9 +194,10 @@ pub struct Img {
 }
 
 /// Create a new image element.
+#[track_caller]
 pub fn img(source: impl Into<ImageSource>) -> Img {
     Img {
-        interactivity: Interactivity::default(),
+        interactivity: Interactivity::new(),
         source: source.into(),
         style: ImageStyle::default(),
         image_cache: None,
@@ -266,9 +267,14 @@ impl Element for Img {
         self.interactivity.element_id.clone()
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        self.interactivity.source_location()
+    }
+
     fn request_layout(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
@@ -290,6 +296,7 @@ impl Element for Img {
 
             let layout_id = self.interactivity.request_layout(
                 global_id,
+                inspector_id,
                 window,
                 cx,
                 |mut style, window, cx| {
@@ -408,6 +415,7 @@ impl Element for Img {
     fn prepaint(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         request_layout: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -415,6 +423,7 @@ impl Element for Img {
     ) -> Self::PrepaintState {
         self.interactivity.prepaint(
             global_id,
+            inspector_id,
             bounds,
             bounds.size,
             window,
@@ -432,6 +441,7 @@ impl Element for Img {
     fn paint(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         layout_state: &mut Self::RequestLayoutState,
         hitbox: &mut Self::PrepaintState,
@@ -441,6 +451,7 @@ impl Element for Img {
         let source = self.source.clone();
         self.interactivity.paint(
             global_id,
+            inspector_id,
             bounds,
             hitbox.as_ref(),
             window,
@@ -595,7 +606,7 @@ impl Asset for ImageAssetLoader {
                     let mut response = client
                         .get(uri.as_ref(), ().into(), true)
                         .await
-                        .map_err(|e| anyhow!(e))?;
+                        .with_context(|| format!("loading image asset from {uri:?}"))?;
                     let mut body = Vec::new();
                     response.body_mut().read_to_end(&mut body).await?;
                     if !response.status().is_success() {

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

@@ -9,14 +9,14 @@
 
 use crate::{
     AnyElement, App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Edges, Element, EntityId,
-    FocusHandle, GlobalElementId, Hitbox, IntoElement, Pixels, Point, ScrollWheelEvent, Size,
-    Style, StyleRefinement, Styled, Window, point, px, size,
+    FocusHandle, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, IntoElement,
+    Overflow, Pixels, Point, ScrollWheelEvent, Size, Style, StyleRefinement, Styled, Window, point,
+    px, size,
 };
 use collections::VecDeque;
 use refineable::Refineable as _;
 use std::{cell::RefCell, ops::Range, rc::Rc};
 use sum_tree::{Bias, SumTree};
-use taffy::style::Overflow;
 
 /// Construct a new list element
 pub fn list(state: ListState) -> List {
@@ -820,9 +820,14 @@ impl Element for List {
         None
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (crate::LayoutId, Self::RequestLayoutState) {
@@ -890,6 +895,7 @@ impl Element for List {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         _: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -901,7 +907,7 @@ impl Element for List {
         let mut style = Style::default();
         style.refine(&self.style);
 
-        let hitbox = window.insert_hitbox(bounds, false);
+        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
 
         // If the width of the list has changed, invalidate all cached item heights
         if state.last_layout_bounds.map_or(true, |last_bounds| {
@@ -938,6 +944,7 @@ impl Element for List {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<crate::Pixels>,
         _: &mut Self::RequestLayoutState,
         prepaint: &mut Self::PrepaintState,
@@ -956,7 +963,7 @@ impl Element for List {
         let scroll_top = prepaint.layout.scroll_top;
         let hitbox_id = prepaint.hitbox.id;
         window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
-            if phase == DispatchPhase::Bubble && hitbox_id.is_hovered(window) {
+            if phase == DispatchPhase::Bubble && hitbox_id.should_handle_scroll(window) {
                 list_state.0.borrow_mut().scroll(
                     &scroll_top,
                     height,

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

@@ -1,7 +1,6 @@
 mod anchored;
 mod animation;
 mod canvas;
-mod common;
 mod deferred;
 mod div;
 mod image_cache;
@@ -15,7 +14,6 @@ mod uniform_list;
 pub use anchored::*;
 pub use animation::*;
 pub use canvas::*;
-pub use common::*;
 pub use deferred::*;
 pub use div::*;
 pub use image_cache::*;

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

@@ -1,6 +1,6 @@
 use crate::{
-    App, Bounds, Element, ElementId, GlobalElementId, IntoElement, LayoutId, ObjectFit, Pixels,
-    Style, StyleRefinement, Styled, Window,
+    App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
+    ObjectFit, Pixels, Style, StyleRefinement, Styled, Window,
 };
 #[cfg(target_os = "macos")]
 use core_video::pixel_buffer::CVPixelBuffer;
@@ -53,9 +53,14 @@ impl Element for Surface {
         None
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _global_id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
@@ -68,6 +73,7 @@ impl Element for Surface {
     fn prepaint(
         &mut self,
         _global_id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: Bounds<Pixels>,
         _request_layout: &mut Self::RequestLayoutState,
         _window: &mut Window,
@@ -78,6 +84,7 @@ impl Element for Surface {
     fn paint(
         &mut self,
         _global_id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] bounds: Bounds<Pixels>,
         _: &mut Self::RequestLayoutState,
         _: &mut Self::PrepaintState,

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

@@ -1,7 +1,8 @@
 use crate::{
-    App, Bounds, Element, GlobalElementId, Hitbox, InteractiveElement, Interactivity, IntoElement,
-    LayoutId, Pixels, Point, Radians, SharedString, Size, StyleRefinement, Styled,
-    TransformationMatrix, Window, geometry::Negate as _, point, px, radians, size,
+    App, Bounds, Element, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement,
+    Interactivity, IntoElement, LayoutId, Pixels, Point, Radians, SharedString, Size,
+    StyleRefinement, Styled, TransformationMatrix, Window, geometry::Negate as _, point, px,
+    radians, size,
 };
 use util::ResultExt;
 
@@ -13,9 +14,10 @@ pub struct Svg {
 }
 
 /// Create a new SVG element.
+#[track_caller]
 pub fn svg() -> Svg {
     Svg {
-        interactivity: Interactivity::default(),
+        interactivity: Interactivity::new(),
         transformation: None,
         path: None,
     }
@@ -44,23 +46,31 @@ impl Element for Svg {
         self.interactivity.element_id.clone()
     }
 
+    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
+        self.interactivity.source_location()
+    }
+
     fn request_layout(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
-        let layout_id =
-            self.interactivity
-                .request_layout(global_id, window, cx, |style, window, cx| {
-                    window.request_layout(style, None, cx)
-                });
+        let layout_id = self.interactivity.request_layout(
+            global_id,
+            inspector_id,
+            window,
+            cx,
+            |style, window, cx| window.request_layout(style, None, cx),
+        );
         (layout_id, ())
     }
 
     fn prepaint(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         _request_layout: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -68,6 +78,7 @@ impl Element for Svg {
     ) -> Option<Hitbox> {
         self.interactivity.prepaint(
             global_id,
+            inspector_id,
             bounds,
             bounds.size,
             window,
@@ -79,6 +90,7 @@ impl Element for Svg {
     fn paint(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         _request_layout: &mut Self::RequestLayoutState,
         hitbox: &mut Option<Hitbox>,
@@ -89,6 +101,7 @@ impl Element for Svg {
     {
         self.interactivity.paint(
             global_id,
+            inspector_id,
             bounds,
             hitbox.as_ref(),
             window,

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

@@ -1,10 +1,11 @@
 use crate::{
     ActiveTooltip, AnyView, App, Bounds, DispatchPhase, Element, ElementId, GlobalElementId,
-    HighlightStyle, Hitbox, 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,
+    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,
 };
-use anyhow::anyhow;
+use anyhow::Context as _;
 use smallvec::SmallVec;
 use std::{
     cell::{Cell, RefCell},
@@ -23,9 +24,14 @@ impl Element for &'static str {
         None
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
@@ -37,6 +43,7 @@ impl Element for &'static str {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         text_layout: &mut Self::RequestLayoutState,
         _window: &mut Window,
@@ -48,6 +55,7 @@ impl Element for &'static str {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: Bounds<Pixels>,
         text_layout: &mut TextLayout,
         _: &mut (),
@@ -82,11 +90,14 @@ impl Element for SharedString {
         None
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
-
         _id: Option<&GlobalElementId>,
-
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
@@ -98,6 +109,7 @@ impl Element for SharedString {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         text_layout: &mut Self::RequestLayoutState,
         _window: &mut Window,
@@ -109,6 +121,7 @@ impl Element for SharedString {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: Bounds<Pixels>,
         text_layout: &mut Self::RequestLayoutState,
         _: &mut Self::PrepaintState,
@@ -225,9 +238,14 @@ impl Element for StyledText {
         None
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
@@ -244,6 +262,7 @@ impl Element for StyledText {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         _: &mut Self::RequestLayoutState,
         _window: &mut Window,
@@ -255,6 +274,7 @@ impl Element for StyledText {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: Bounds<Pixels>,
         _: &mut Self::RequestLayoutState,
         _: &mut Self::PrepaintState,
@@ -319,8 +339,8 @@ impl TextLayout {
                     None
                 };
 
-                let (truncate_width, ellipsis) =
-                    if let Some(text_overflow) = text_style.text_overflow {
+                let (truncate_width, truncation_suffix) =
+                    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 {
                                 Some(max_lines) => Some(x * max_lines),
@@ -330,10 +350,10 @@ impl TextLayout {
                         });
 
                         match text_overflow {
-                            TextOverflow::Ellipsis(s) => (width, Some(s)),
+                            TextOverflow::Truncate(s) => (width, s),
                         }
                     } else {
-                        (None, None)
+                        (None, "".into())
                     };
 
                 if let Some(text_layout) = element_state.0.borrow().as_ref() {
@@ -346,7 +366,12 @@ impl TextLayout {
 
                 let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size);
                 let text = if let Some(truncate_width) = truncate_width {
-                    line_wrapper.truncate_line(text.clone(), truncate_width, ellipsis, &mut runs)
+                    line_wrapper.truncate_line(
+                        text.clone(),
+                        truncate_width,
+                        &truncation_suffix,
+                        &mut runs,
+                    )
                 } else {
                     text.clone()
                 };
@@ -401,7 +426,7 @@ impl TextLayout {
         let mut element_state = self.0.borrow_mut();
         let element_state = element_state
             .as_mut()
-            .ok_or_else(|| anyhow!("measurement has not been performed on {}", text))
+            .with_context(|| format!("measurement has not been performed on {text}"))
             .unwrap();
         element_state.bounds = Some(bounds);
     }
@@ -410,11 +435,11 @@ impl TextLayout {
         let element_state = self.0.borrow();
         let element_state = element_state
             .as_ref()
-            .ok_or_else(|| anyhow!("measurement has not been performed on {}", text))
+            .with_context(|| format!("measurement has not been performed on {text}"))
             .unwrap();
         let bounds = element_state
             .bounds
-            .ok_or_else(|| anyhow!("prepaint has not been performed on {:?}", text))
+            .with_context(|| format!("prepaint has not been performed on {text}"))
             .unwrap();
 
         let line_height = element_state.line_height;
@@ -673,18 +698,24 @@ impl Element for InteractiveText {
         Some(self.element_id.clone())
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
-        self.text.request_layout(None, window, cx)
+        self.text.request_layout(None, inspector_id, window, cx)
     }
 
     fn prepaint(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         state: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -706,8 +737,9 @@ impl Element for InteractiveText {
                     }
                 }
 
-                self.text.prepaint(None, bounds, state, window, cx);
-                let hitbox = window.insert_hitbox(bounds, false);
+                self.text
+                    .prepaint(None, inspector_id, bounds, state, window, cx);
+                let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
                 (hitbox, interactive_state)
             },
         )
@@ -716,6 +748,7 @@ impl Element for InteractiveText {
     fn paint(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         _: &mut Self::RequestLayoutState,
         hitbox: &mut Hitbox,
@@ -736,7 +769,7 @@ impl Element for InteractiveText {
                             .iter()
                             .any(|range| range.contains(&ix))
                         {
-                            window.set_cursor_style(crate::CursorStyle::PointingHand, Some(hitbox))
+                            window.set_cursor_style(crate::CursorStyle::PointingHand, hitbox)
                         }
                     }
 
@@ -853,7 +886,8 @@ impl Element for InteractiveText {
                     );
                 }
 
-                self.text.paint(None, bounds, &mut (), &mut (), window, cx);
+                self.text
+                    .paint(None, inspector_id, bounds, &mut (), &mut (), window, cx);
 
                 ((), interactive_state)
             },

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

@@ -5,14 +5,13 @@
 //! elements with uniform height.
 
 use crate::{
-    AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, Element, ElementId, Entity,
-    GlobalElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId,
-    ListSizingBehavior, Pixels, Render, ScrollHandle, Size, StyleRefinement, Styled, Window, point,
-    size,
+    AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId,
+    Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId,
+    ListSizingBehavior, Overflow, Pixels, ScrollHandle, Size, StyleRefinement, Styled, Window,
+    point, size,
 };
 use smallvec::SmallVec;
 use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
-use taffy::style::Overflow;
 
 use super::ListHorizontalSizingBehavior;
 
@@ -20,28 +19,23 @@ use super::ListHorizontalSizingBehavior;
 /// When rendered into a container with overflow-y: hidden and a fixed (or max) height,
 /// uniform_list will only render the visible subset of items.
 #[track_caller]
-pub fn uniform_list<I, R, V>(
-    view: Entity<V>,
-    id: I,
+pub fn uniform_list<R>(
+    id: impl Into<ElementId>,
     item_count: usize,
-    f: impl 'static + Fn(&mut V, Range<usize>, &mut Window, &mut Context<V>) -> Vec<R>,
+    f: impl 'static + Fn(Range<usize>, &mut Window, &mut App) -> Vec<R>,
 ) -> UniformList
 where
-    I: Into<ElementId>,
     R: IntoElement,
-    V: Render,
 {
     let id = id.into();
     let mut base_style = StyleRefinement::default();
     base_style.overflow.y = Some(Overflow::Scroll);
 
-    let render_range = move |range, window: &mut Window, cx: &mut App| {
-        view.update(cx, |this, cx| {
-            f(this, range, window, cx)
-                .into_iter()
-                .map(|component| component.into_any_element())
-                .collect()
-        })
+    let render_range = move |range: Range<usize>, window: &mut Window, cx: &mut App| {
+        f(range, window, cx)
+            .into_iter()
+            .map(|component| component.into_any_element())
+            .collect()
     };
 
     UniformList {
@@ -52,11 +46,7 @@ where
         interactivity: Interactivity {
             element_id: Some(id),
             base_style: Box::new(base_style),
-
-            #[cfg(debug_assertions)]
-            location: Some(*core::panic::Location::caller()),
-
-            ..Default::default()
+            ..Interactivity::new()
         },
         scroll_handle: None,
         sizing_behavior: ListSizingBehavior::default(),
@@ -166,9 +156,14 @@ impl Element for UniformList {
         self.interactivity.element_id.clone()
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
@@ -176,6 +171,7 @@ impl Element for UniformList {
         let item_size = self.measure_item(None, window, cx);
         let layout_id = self.interactivity.request_layout(
             global_id,
+            inspector_id,
             window,
             cx,
             |style, window, cx| match self.sizing_behavior {
@@ -223,6 +219,7 @@ impl Element for UniformList {
     fn prepaint(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         frame_state: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -271,6 +268,7 @@ impl Element for UniformList {
 
         self.interactivity.prepaint(
             global_id,
+            inspector_id,
             bounds,
             content_size,
             window,
@@ -435,6 +433,7 @@ impl Element for UniformList {
     fn paint(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<crate::Pixels>,
         request_layout: &mut Self::RequestLayoutState,
         hitbox: &mut Option<Hitbox>,
@@ -443,6 +442,7 @@ impl Element for UniformList {
     ) {
         self.interactivity.paint(
             global_id,
+            inspector_id,
             bounds,
             hitbox.as_ref(),
             window,

crates/gpui/src/geometry.rs 🔗

@@ -2,13 +2,15 @@
 //! can be used to describe common units, concepts, and the relationships
 //! between them.
 
+use anyhow::{Context as _, anyhow};
 use core::fmt::Debug;
 use derive_more::{Add, AddAssign, Div, DivAssign, Mul, Neg, Sub, SubAssign};
 use refineable::Refineable;
-use serde_derive::{Deserialize, Serialize};
+use schemars::{JsonSchema, SchemaGenerator, schema::Schema};
+use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
 use std::{
     cmp::{self, PartialOrd},
-    fmt,
+    fmt::{self, Display},
     hash::Hash,
     ops::{Add, Div, Mul, MulAssign, Neg, Sub},
 };
@@ -71,11 +73,12 @@ pub trait Along {
     Eq,
     Serialize,
     Deserialize,
+    JsonSchema,
     Hash,
 )]
-#[refineable(Debug)]
+#[refineable(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 #[repr(C)]
-pub struct Point<T: Default + Clone + Debug> {
+pub struct Point<T: Clone + Debug + Default + PartialEq> {
     /// The x coordinate of the point.
     pub x: T,
     /// The y coordinate of the point.
@@ -101,11 +104,11 @@ pub struct Point<T: Default + Clone + Debug> {
 /// assert_eq!(p.x, 10);
 /// assert_eq!(p.y, 20);
 /// ```
-pub const fn point<T: Clone + Debug + Default>(x: T, y: T) -> Point<T> {
+pub const fn point<T: Clone + Debug + Default + PartialEq>(x: T, y: T) -> Point<T> {
     Point { x, y }
 }
 
-impl<T: Clone + Debug + Default> Point<T> {
+impl<T: Clone + Debug + Default + PartialEq> Point<T> {
     /// Creates a new `Point` with the specified `x` and `y` coordinates.
     ///
     /// # Arguments
@@ -142,7 +145,7 @@ impl<T: Clone + Debug + Default> Point<T> {
     /// let p_float = p.map(|coord| coord as f32);
     /// assert_eq!(p_float, Point { x: 3.0, y: 4.0 });
     /// ```
-    pub fn map<U: Clone + Default + Debug>(&self, f: impl Fn(T) -> U) -> Point<U> {
+    pub fn map<U: Clone + Debug + Default + PartialEq>(&self, f: impl Fn(T) -> U) -> Point<U> {
         Point {
             x: f(self.x.clone()),
             y: f(self.y.clone()),
@@ -150,7 +153,7 @@ impl<T: Clone + Debug + Default> Point<T> {
     }
 }
 
-impl<T: Clone + Debug + Default> Along for Point<T> {
+impl<T: Clone + Debug + Default + PartialEq> Along for Point<T> {
     type Unit = T;
 
     fn along(&self, axis: Axis) -> T {
@@ -174,7 +177,7 @@ impl<T: Clone + Debug + Default> Along for Point<T> {
     }
 }
 
-impl<T: Clone + Debug + Default + Negate> Negate for Point<T> {
+impl<T: Clone + Debug + Default + PartialEq + Negate> Negate for Point<T> {
     fn negate(self) -> Self {
         self.map(Negate::negate)
     }
@@ -219,7 +222,7 @@ impl Point<Pixels> {
 
 impl<T> Point<T>
 where
-    T: Sub<T, Output = T> + Debug + Clone + Default,
+    T: Sub<T, Output = T> + Clone + Debug + Default + PartialEq,
 {
     /// Get the position of this point, relative to the given origin
     pub fn relative_to(&self, origin: &Point<T>) -> Point<T> {
@@ -232,7 +235,7 @@ where
 
 impl<T, Rhs> Mul<Rhs> for Point<T>
 where
-    T: Mul<Rhs, Output = T> + Clone + Default + Debug,
+    T: Mul<Rhs, Output = T> + Clone + Debug + Default + PartialEq,
     Rhs: Clone + Debug,
 {
     type Output = Point<T>;
@@ -247,7 +250,7 @@ where
 
 impl<T, S> MulAssign<S> for Point<T>
 where
-    T: Clone + Mul<S, Output = T> + Default + Debug,
+    T: Mul<S, Output = T> + Clone + Debug + Default + PartialEq,
     S: Clone,
 {
     fn mul_assign(&mut self, rhs: S) {
@@ -258,7 +261,7 @@ where
 
 impl<T, S> Div<S> for Point<T>
 where
-    T: Div<S, Output = T> + Clone + Default + Debug,
+    T: Div<S, Output = T> + Clone + Debug + Default + PartialEq,
     S: Clone,
 {
     type Output = Self;
@@ -273,7 +276,7 @@ where
 
 impl<T> Point<T>
 where
-    T: PartialOrd + Clone + Default + Debug,
+    T: PartialOrd + Clone + Debug + Default + PartialEq,
 {
     /// Returns a new point with the maximum values of each dimension from `self` and `other`.
     ///
@@ -366,7 +369,7 @@ where
     }
 }
 
-impl<T: Clone + Default + Debug> Clone for Point<T> {
+impl<T: Clone + Debug + Default + PartialEq> Clone for Point<T> {
     fn clone(&self) -> Self {
         Self {
             x: self.x.clone(),
@@ -375,21 +378,27 @@ impl<T: Clone + Default + Debug> Clone for Point<T> {
     }
 }
 
+impl<T: Clone + Debug + Default + PartialEq + Display> Display for Point<T> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "({}, {})", self.x, self.y)
+    }
+}
+
 /// A structure representing a two-dimensional size with width and height in a given unit.
 ///
 /// This struct is generic over the type `T`, which can be any type that implements `Clone`, `Default`, and `Debug`.
 /// It is commonly used to specify dimensions for elements in a UI, such as a window or element.
 #[derive(Refineable, Default, Clone, Copy, PartialEq, Div, Hash, Serialize, Deserialize)]
-#[refineable(Debug)]
+#[refineable(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 #[repr(C)]
-pub struct Size<T: Clone + Default + Debug> {
+pub struct Size<T: Clone + Debug + Default + PartialEq> {
     /// The width component of the size.
     pub width: T,
     /// The height component of the size.
     pub height: T,
 }
 
-impl<T: Clone + Default + Debug> Size<T> {
+impl<T: Clone + Debug + Default + PartialEq> Size<T> {
     /// Create a new Size, a synonym for [`size`]
     pub fn new(width: T, height: T) -> Self {
         size(width, height)
@@ -413,14 +422,14 @@ impl<T: Clone + Default + Debug> Size<T> {
 /// ```
 pub const fn size<T>(width: T, height: T) -> Size<T>
 where
-    T: Clone + Default + Debug,
+    T: Clone + Debug + Default + PartialEq,
 {
     Size { width, height }
 }
 
 impl<T> Size<T>
 where
-    T: Clone + Default + Debug,
+    T: Clone + Debug + Default + PartialEq,
 {
     /// Applies a function to the width and height of the size, producing a new `Size<U>`.
     ///
@@ -442,7 +451,7 @@ where
     /// ```
     pub fn map<U>(&self, f: impl Fn(T) -> U) -> Size<U>
     where
-        U: Clone + Default + Debug,
+        U: Clone + Debug + Default + PartialEq,
     {
         Size {
             width: f(self.width.clone()),
@@ -453,7 +462,7 @@ where
 
 impl<T> Size<T>
 where
-    T: Clone + Default + Debug + Half,
+    T: Clone + Debug + Default + PartialEq + Half,
 {
     /// Compute the center point of the size.g
     pub fn center(&self) -> Point<T> {
@@ -493,7 +502,7 @@ impl Size<Pixels> {
 
 impl<T> Along for Size<T>
 where
-    T: Clone + Default + Debug,
+    T: Clone + Debug + Default + PartialEq,
 {
     type Unit = T;
 
@@ -521,7 +530,7 @@ where
 
 impl<T> Size<T>
 where
-    T: PartialOrd + Clone + Default + Debug,
+    T: PartialOrd + Clone + Debug + Default + PartialEq,
 {
     /// Returns a new `Size` with the maximum width and height from `self` and `other`.
     ///
@@ -586,7 +595,7 @@ where
 
 impl<T> Sub for Size<T>
 where
-    T: Sub<Output = T> + Clone + Default + Debug,
+    T: Sub<Output = T> + Clone + Debug + Default + PartialEq,
 {
     type Output = Size<T>;
 
@@ -600,7 +609,7 @@ where
 
 impl<T> Add for Size<T>
 where
-    T: Add<Output = T> + Clone + Default + Debug,
+    T: Add<Output = T> + Clone + Debug + Default + PartialEq,
 {
     type Output = Size<T>;
 
@@ -614,8 +623,8 @@ where
 
 impl<T, Rhs> Mul<Rhs> for Size<T>
 where
-    T: Mul<Rhs, Output = Rhs> + Clone + Default + Debug,
-    Rhs: Clone + Default + Debug,
+    T: Mul<Rhs, Output = Rhs> + Clone + Debug + Default + PartialEq,
+    Rhs: Clone + Debug + Default + PartialEq,
 {
     type Output = Size<Rhs>;
 
@@ -629,7 +638,7 @@ where
 
 impl<T, S> MulAssign<S> for Size<T>
 where
-    T: Mul<S, Output = T> + Clone + Default + Debug,
+    T: Mul<S, Output = T> + Clone + Debug + Default + PartialEq,
     S: Clone,
 {
     fn mul_assign(&mut self, rhs: S) {
@@ -638,18 +647,24 @@ where
     }
 }
 
-impl<T> Eq for Size<T> where T: Eq + Default + Debug + Clone {}
+impl<T> Eq for Size<T> where T: Eq + Clone + Debug + Default + PartialEq {}
 
 impl<T> Debug for Size<T>
 where
-    T: Clone + Default + Debug,
+    T: Clone + Debug + Default + PartialEq,
 {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
         write!(f, "Size {{ {:?} × {:?} }}", self.width, self.height)
     }
 }
 
-impl<T: Clone + Default + Debug> From<Point<T>> for Size<T> {
+impl<T: Clone + Debug + Default + PartialEq + Display> Display for Size<T> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{} × {}", self.width, self.height)
+    }
+}
+
+impl<T: Clone + Debug + Default + PartialEq> From<Point<T>> for Size<T> {
     fn from(point: Point<T>) -> Self {
         Self {
             width: point.x,
@@ -731,7 +746,7 @@ impl Size<Length> {
 #[derive(Refineable, Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)]
 #[refineable(Debug)]
 #[repr(C)]
-pub struct Bounds<T: Clone + Default + Debug> {
+pub struct Bounds<T: Clone + Debug + Default + PartialEq> {
     /// The origin point of this area.
     pub origin: Point<T>,
     /// The size of the rectangle.
@@ -739,7 +754,10 @@ pub struct Bounds<T: Clone + Default + Debug> {
 }
 
 /// Create a bounds with the given origin and size
-pub fn bounds<T: Clone + Default + Debug>(origin: Point<T>, size: Size<T>) -> Bounds<T> {
+pub fn bounds<T: Clone + Debug + Default + PartialEq>(
+    origin: Point<T>,
+    size: Size<T>,
+) -> Bounds<T> {
     Bounds { origin, size }
 }
 
@@ -775,7 +793,7 @@ impl Bounds<Pixels> {
 
 impl<T> Bounds<T>
 where
-    T: Clone + Debug + Default,
+    T: Clone + Debug + Default + PartialEq,
 {
     /// Creates a new `Bounds` with the specified origin and size.
     ///
@@ -794,7 +812,7 @@ where
 
 impl<T> Bounds<T>
 where
-    T: Clone + Debug + Sub<Output = T> + Default,
+    T: Sub<Output = T> + Clone + Debug + Default + PartialEq,
 {
     /// Constructs a `Bounds` from two corner points: the top left and bottom right corners.
     ///
@@ -860,7 +878,7 @@ where
 
 impl<T> Bounds<T>
 where
-    T: Clone + Debug + Sub<T, Output = T> + Default + Half,
+    T: Sub<T, Output = T> + Half + Clone + Debug + Default + PartialEq,
 {
     /// Creates a new bounds centered at the given point.
     pub fn centered_at(center: Point<T>, size: Size<T>) -> Self {
@@ -874,7 +892,7 @@ where
 
 impl<T> Bounds<T>
 where
-    T: Clone + Debug + PartialOrd + Add<T, Output = T> + Default,
+    T: PartialOrd + Add<T, Output = T> + Clone + Debug + Default + PartialEq,
 {
     /// Checks if this `Bounds` intersects with another `Bounds`.
     ///
@@ -922,7 +940,7 @@ where
 
 impl<T> Bounds<T>
 where
-    T: Clone + Debug + Add<T, Output = T> + Default + Half,
+    T: Add<T, Output = T> + Half + Clone + Debug + Default + PartialEq,
 {
     /// Returns the center point of the bounds.
     ///
@@ -955,7 +973,7 @@ where
 
 impl<T> Bounds<T>
 where
-    T: Clone + Debug + Add<T, Output = T> + Default,
+    T: Add<T, Output = T> + Clone + Debug + Default + PartialEq,
 {
     /// Calculates the half perimeter of a rectangle defined by the bounds.
     ///
@@ -982,7 +1000,7 @@ where
 
 impl<T> Bounds<T>
 where
-    T: Clone + Debug + Add<T, Output = T> + Sub<Output = T> + Default,
+    T: Add<T, Output = T> + Sub<Output = T> + Clone + Debug + Default + PartialEq,
 {
     /// Dilates the bounds by a specified amount in all directions.
     ///
@@ -1033,7 +1051,13 @@ where
 
 impl<T> Bounds<T>
 where
-    T: Clone + Debug + Add<T, Output = T> + Sub<T, Output = T> + Neg<Output = T> + Default,
+    T: Add<T, Output = T>
+        + Sub<T, Output = T>
+        + Neg<Output = T>
+        + Clone
+        + Debug
+        + Default
+        + PartialEq,
 {
     /// Inset the bounds by a specified amount. Equivalent to `dilate` with the amount negated.
     ///
@@ -1043,7 +1067,9 @@ where
     }
 }
 
-impl<T: Clone + Default + Debug + PartialOrd + Add<T, Output = T> + Sub<Output = T>> Bounds<T> {
+impl<T: PartialOrd + Add<T, Output = T> + Sub<Output = T> + Clone + Debug + Default + PartialEq>
+    Bounds<T>
+{
     /// Calculates the intersection of two `Bounds` objects.
     ///
     /// This method computes the overlapping region of two `Bounds`. If the bounds do not intersect,
@@ -1125,7 +1151,7 @@ impl<T: Clone + Default + Debug + PartialOrd + Add<T, Output = T> + Sub<Output =
 
 impl<T> Bounds<T>
 where
-    T: Clone + Debug + Add<T, Output = T> + Sub<T, Output = T> + Default,
+    T: Add<T, Output = T> + Sub<T, Output = T> + Clone + Debug + Default + PartialEq,
 {
     /// Computes the space available within outer bounds.
     pub fn space_within(&self, outer: &Self) -> Edges<T> {
@@ -1140,9 +1166,9 @@ where
 
 impl<T, Rhs> Mul<Rhs> for Bounds<T>
 where
-    T: Mul<Rhs, Output = Rhs> + Clone + Default + Debug,
+    T: Mul<Rhs, Output = Rhs> + Clone + Debug + Default + PartialEq,
     Point<T>: Mul<Rhs, Output = Point<Rhs>>,
-    Rhs: Clone + Default + Debug,
+    Rhs: Clone + Debug + Default + PartialEq,
 {
     type Output = Bounds<Rhs>;
 
@@ -1156,7 +1182,7 @@ where
 
 impl<T, S> MulAssign<S> for Bounds<T>
 where
-    T: Mul<S, Output = T> + Clone + Default + Debug,
+    T: Mul<S, Output = T> + Clone + Debug + Default + PartialEq,
     S: Clone,
 {
     fn mul_assign(&mut self, rhs: S) {
@@ -1168,7 +1194,7 @@ where
 impl<T, S> Div<S> for Bounds<T>
 where
     Size<T>: Div<S, Output = Size<T>>,
-    T: Div<S, Output = T> + Default + Clone + Debug,
+    T: Div<S, Output = T> + Clone + Debug + Default + PartialEq,
     S: Clone,
 {
     type Output = Self;
@@ -1183,7 +1209,7 @@ where
 
 impl<T> Add<Point<T>> for Bounds<T>
 where
-    T: Add<T, Output = T> + Default + Clone + Debug,
+    T: Add<T, Output = T> + Clone + Debug + Default + PartialEq,
 {
     type Output = Self;
 
@@ -1197,7 +1223,7 @@ where
 
 impl<T> Sub<Point<T>> for Bounds<T>
 where
-    T: Sub<T, Output = T> + Default + Clone + Debug,
+    T: Sub<T, Output = T> + Clone + Debug + Default + PartialEq,
 {
     type Output = Self;
 
@@ -1211,7 +1237,7 @@ where
 
 impl<T> Bounds<T>
 where
-    T: Add<T, Output = T> + Clone + Default + Debug,
+    T: Add<T, Output = T> + Clone + Debug + Default + PartialEq,
 {
     /// Returns the top edge of the bounds.
     ///
@@ -1350,7 +1376,7 @@ where
 
 impl<T> Bounds<T>
 where
-    T: Add<T, Output = T> + PartialOrd + Clone + Default + Debug,
+    T: Add<T, Output = T> + PartialOrd + Clone + Debug + Default + PartialEq,
 {
     /// Checks if the given point is within the bounds.
     ///
@@ -1388,6 +1414,44 @@ where
             && point.y <= self.origin.y.clone() + self.size.height.clone()
     }
 
+    /// Checks if this bounds is completely contained within another bounds.
+    ///
+    /// This method determines whether the current bounds is entirely enclosed by the given bounds.
+    /// A bounds is considered to be contained within another if its origin (top-left corner) and
+    /// its bottom-right corner are both contained within the other bounds.
+    ///
+    /// # Arguments
+    ///
+    /// * `other` - A reference to another `Bounds` that might contain this bounds.
+    ///
+    /// # Returns
+    ///
+    /// Returns `true` if this bounds is completely inside the other bounds, `false` otherwise.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// # use gpui::{Bounds, Point, Size};
+    /// let outer_bounds = Bounds {
+    ///     origin: Point { x: 0, y: 0 },
+    ///     size: Size { width: 20, height: 20 },
+    /// };
+    /// let inner_bounds = Bounds {
+    ///     origin: Point { x: 5, y: 5 },
+    ///     size: Size { width: 10, height: 10 },
+    /// };
+    /// let overlapping_bounds = Bounds {
+    ///     origin: Point { x: 15, y: 15 },
+    ///     size: Size { width: 10, height: 10 },
+    /// };
+    ///
+    /// assert!(inner_bounds.is_contained_within(&outer_bounds));
+    /// assert!(!overlapping_bounds.is_contained_within(&outer_bounds));
+    /// ```
+    pub fn is_contained_within(&self, other: &Self) -> bool {
+        other.contains(&self.origin) && other.contains(&self.bottom_right())
+    }
+
     /// Applies a function to the origin and size of the bounds, producing a new `Bounds<U>`.
     ///
     /// This method allows for converting a `Bounds<T>` to a `Bounds<U>` by specifying a closure
@@ -1419,7 +1483,7 @@ where
     /// ```
     pub fn map<U>(&self, f: impl Fn(T) -> U) -> Bounds<U>
     where
-        U: Clone + Default + Debug,
+        U: Clone + Debug + Default + PartialEq,
     {
         Bounds {
             origin: self.origin.map(&f),
@@ -1478,7 +1542,7 @@ where
 
 impl<T> Bounds<T>
 where
-    T: Add<T, Output = T> + PartialOrd + Clone + Default + Debug + Sub<T, Output = T>,
+    T: Add<T, Output = T> + Sub<T, Output = T> + PartialOrd + Clone + Debug + Default + PartialEq,
 {
     /// Convert a point to the coordinate space defined by this Bounds
     pub fn localize(&self, point: &Point<T>) -> Option<Point<T>> {
@@ -1492,7 +1556,7 @@ where
 /// # Returns
 ///
 /// Returns `true` if either the width or the height of the bounds is less than or equal to zero, indicating an empty area.
-impl<T: PartialOrd + Default + Debug + Clone> Bounds<T> {
+impl<T: PartialOrd + Clone + Debug + Default + PartialEq> Bounds<T> {
     /// Checks if the bounds represent an empty area.
     ///
     /// # Returns
@@ -1503,6 +1567,18 @@ impl<T: PartialOrd + Default + Debug + Clone> Bounds<T> {
     }
 }
 
+impl<T: Clone + Debug + Default + PartialEq + Display + Add<T, Output = T>> Display for Bounds<T> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "{} - {} (size {})",
+            self.origin,
+            self.bottom_right(),
+            self.size
+        )
+    }
+}
+
 impl Size<DevicePixels> {
     /// Converts the size from physical to logical pixels.
     pub(crate) fn to_pixels(self, scale_factor: f32) -> Size<Pixels> {
@@ -1514,11 +1590,11 @@ impl Size<DevicePixels> {
 }
 
 impl Size<Pixels> {
-    /// Converts the size from physical to logical pixels.
+    /// Converts the size from logical to physical pixels.
     pub(crate) fn to_device_pixels(self, scale_factor: f32) -> Size<DevicePixels> {
         size(
-            DevicePixels((self.width.0 * scale_factor) as i32),
-            DevicePixels((self.height.0 * scale_factor) as i32),
+            DevicePixels((self.width.0 * scale_factor).round() as i32),
+            DevicePixels((self.height.0 * scale_factor).round() as i32),
         )
     }
 }
@@ -1565,8 +1641,8 @@ impl Bounds<Pixels> {
     pub fn to_device_pixels(&self, factor: f32) -> Bounds<DevicePixels> {
         Bounds {
             origin: point(
-                DevicePixels((self.origin.x.0 * factor) as i32),
-                DevicePixels((self.origin.y.0 * factor) as i32),
+                DevicePixels((self.origin.x.0 * factor).round() as i32),
+                DevicePixels((self.origin.y.0 * factor).round() as i32),
             ),
             size: self.size.to_device_pixels(factor),
         }
@@ -1586,7 +1662,7 @@ impl Bounds<DevicePixels> {
     }
 }
 
-impl<T: Clone + Debug + Copy + Default> Copy for Bounds<T> {}
+impl<T: Copy + Clone + Debug + Default + PartialEq> Copy for Bounds<T> {}
 
 /// Represents the edges of a box in a 2D space, such as padding or margin.
 ///
@@ -1609,9 +1685,9 @@ impl<T: Clone + Debug + Copy + Default> Copy for Bounds<T> {}
 /// assert_eq!(edges.left, 40.0);
 /// ```
 #[derive(Refineable, Clone, Default, Debug, Eq, PartialEq)]
-#[refineable(Debug)]
+#[refineable(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 #[repr(C)]
-pub struct Edges<T: Clone + Default + Debug> {
+pub struct Edges<T: Clone + Debug + Default + PartialEq> {
     /// The size of the top edge.
     pub top: T,
     /// The size of the right edge.
@@ -1624,7 +1700,7 @@ pub struct Edges<T: Clone + Default + Debug> {
 
 impl<T> Mul for Edges<T>
 where
-    T: Mul<Output = T> + Clone + Default + Debug,
+    T: Mul<Output = T> + Clone + Debug + Default + PartialEq,
 {
     type Output = Self;
 
@@ -1640,7 +1716,7 @@ where
 
 impl<T, S> MulAssign<S> for Edges<T>
 where
-    T: Mul<S, Output = T> + Clone + Default + Debug,
+    T: Mul<S, Output = T> + Clone + Debug + Default + PartialEq,
     S: Clone,
 {
     fn mul_assign(&mut self, rhs: S) {
@@ -1651,9 +1727,9 @@ where
     }
 }
 
-impl<T: Clone + Default + Debug + Copy> Copy for Edges<T> {}
+impl<T: Clone + Debug + Default + PartialEq + Copy> Copy for Edges<T> {}
 
-impl<T: Clone + Default + Debug> Edges<T> {
+impl<T: Clone + Debug + Default + PartialEq> Edges<T> {
     /// Constructs `Edges` where all sides are set to the same specified value.
     ///
     /// This function creates an `Edges` instance with the `top`, `right`, `bottom`, and `left` fields all initialized
@@ -1711,7 +1787,7 @@ impl<T: Clone + Default + Debug> Edges<T> {
     /// ```
     pub fn map<U>(&self, f: impl Fn(&T) -> U) -> Edges<U>
     where
-        U: Clone + Default + Debug,
+        U: Clone + Debug + Default + PartialEq,
     {
         Edges {
             top: f(&self.top),
@@ -2086,9 +2162,9 @@ impl Corner {
 ///
 /// Each field represents the size of the corner on one side of the box: `top_left`, `top_right`, `bottom_right`, and `bottom_left`.
 #[derive(Refineable, Clone, Default, Debug, Eq, PartialEq)]
-#[refineable(Debug)]
+#[refineable(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 #[repr(C)]
-pub struct Corners<T: Clone + Default + Debug> {
+pub struct Corners<T: Clone + Debug + Default + PartialEq> {
     /// The value associated with the top left corner.
     pub top_left: T,
     /// The value associated with the top right corner.
@@ -2101,7 +2177,7 @@ pub struct Corners<T: Clone + Default + Debug> {
 
 impl<T> Corners<T>
 where
-    T: Clone + Default + Debug,
+    T: Clone + Debug + Default + PartialEq,
 {
     /// Constructs `Corners` where all sides are set to the same specified value.
     ///
@@ -2254,7 +2330,7 @@ impl Corners<Pixels> {
     }
 }
 
-impl<T: Div<f32, Output = T> + Ord + Clone + Default + Debug> Corners<T> {
+impl<T: Div<f32, Output = T> + Ord + Clone + Debug + Default + PartialEq> Corners<T> {
     /// Clamps corner radii to be less than or equal to half the shortest side of a quad.
     ///
     /// # Arguments
@@ -2275,7 +2351,7 @@ impl<T: Div<f32, Output = T> + Ord + Clone + Default + Debug> Corners<T> {
     }
 }
 
-impl<T: Clone + Default + Debug> Corners<T> {
+impl<T: Clone + Debug + Default + PartialEq> Corners<T> {
     /// Applies a function to each field of the `Corners`, producing a new `Corners<U>`.
     ///
     /// This method allows for converting a `Corners<T>` to a `Corners<U>` by specifying a closure
@@ -2310,7 +2386,7 @@ impl<T: Clone + Default + Debug> Corners<T> {
     /// ```
     pub fn map<U>(&self, f: impl Fn(&T) -> U) -> Corners<U>
     where
-        U: Clone + Default + Debug,
+        U: Clone + Debug + Default + PartialEq,
     {
         Corners {
             top_left: f(&self.top_left),
@@ -2323,7 +2399,7 @@ impl<T: Clone + Default + Debug> Corners<T> {
 
 impl<T> Mul for Corners<T>
 where
-    T: Mul<Output = T> + Clone + Default + Debug,
+    T: Mul<Output = T> + Clone + Debug + Default + PartialEq,
 {
     type Output = Self;
 
@@ -2339,7 +2415,7 @@ where
 
 impl<T, S> MulAssign<S> for Corners<T>
 where
-    T: Mul<S, Output = T> + Clone + Default + Debug,
+    T: Mul<S, Output = T> + Clone + Debug + Default + PartialEq,
     S: Clone,
 {
     fn mul_assign(&mut self, rhs: S) {
@@ -2350,7 +2426,7 @@ where
     }
 }
 
-impl<T> Copy for Corners<T> where T: Copy + Clone + Default + Debug {}
+impl<T> Copy for Corners<T> where T: Copy + Clone + Debug + Default + PartialEq {}
 
 impl From<f32> for Corners<Pixels> {
     fn from(val: f32) -> Self {
@@ -2470,16 +2546,11 @@ impl From<Percentage> for Radians {
     PartialEq,
     Serialize,
     Deserialize,
+    JsonSchema,
 )]
 #[repr(transparent)]
 pub struct Pixels(pub f32);
 
-impl std::fmt::Display for Pixels {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        f.write_fmt(format_args!("{}px", self.0))
-    }
-}
-
 impl Div for Pixels {
     type Output = f32;
 
@@ -2546,6 +2617,30 @@ impl MulAssign<f32> for Pixels {
     }
 }
 
+impl Display for Pixels {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}px", self.0)
+    }
+}
+
+impl Debug for Pixels {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        Display::fmt(self, f)
+    }
+}
+
+impl TryFrom<&'_ str> for Pixels {
+    type Error = anyhow::Error;
+
+    fn try_from(value: &'_ str) -> Result<Self, Self::Error> {
+        value
+            .strip_suffix("px")
+            .context("expected 'px' suffix")
+            .and_then(|number| Ok(number.parse()?))
+            .map(Self)
+    }
+}
+
 impl Pixels {
     /// Represents zero pixels.
     pub const ZERO: Pixels = Pixels(0.0);
@@ -2668,12 +2763,6 @@ impl From<f32> for Pixels {
     }
 }
 
-impl Debug for Pixels {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "{} px", self.0)
-    }
-}
-
 impl From<Pixels> for f32 {
     fn from(pixels: Pixels) -> Self {
         pixels.0
@@ -2872,7 +2961,7 @@ impl Ord for ScaledPixels {
 
 impl Debug for ScaledPixels {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "{} px (scaled)", self.0)
+        write!(f, "{}px (scaled)", self.0)
     }
 }
 
@@ -2994,9 +3083,27 @@ impl Mul<Pixels> for Rems {
     }
 }
 
+impl Display for Rems {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}rem", self.0)
+    }
+}
+
 impl Debug for Rems {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "{} rem", self.0)
+        Display::fmt(self, f)
+    }
+}
+
+impl TryFrom<&'_ str> for Rems {
+    type Error = anyhow::Error;
+
+    fn try_from(value: &'_ str) -> Result<Self, Self::Error> {
+        value
+            .strip_suffix("rem")
+            .context("expected 'rem' suffix")
+            .and_then(|number| Ok(number.parse()?))
+            .map(Self)
     }
 }
 
@@ -3006,7 +3113,7 @@ impl Debug for Rems {
 /// affected by the current font size, or a number of rems, which is relative to the font size of
 /// the root element. It is used for specifying dimensions that are either independent of or
 /// related to the typographic scale.
-#[derive(Clone, Copy, Debug, Neg, PartialEq)]
+#[derive(Clone, Copy, Neg, PartialEq)]
 pub enum AbsoluteLength {
     /// A length in pixels.
     Pixels(Pixels),
@@ -3088,6 +3195,87 @@ impl Default for AbsoluteLength {
     }
 }
 
+impl Display for AbsoluteLength {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Pixels(pixels) => write!(f, "{pixels}"),
+            Self::Rems(rems) => write!(f, "{rems}"),
+        }
+    }
+}
+
+impl Debug for AbsoluteLength {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        Display::fmt(self, f)
+    }
+}
+
+const EXPECTED_ABSOLUTE_LENGTH: &str = "number with 'px' or 'rem' suffix";
+
+impl TryFrom<&'_ str> for AbsoluteLength {
+    type Error = anyhow::Error;
+
+    fn try_from(value: &'_ str) -> Result<Self, Self::Error> {
+        if let Ok(pixels) = value.try_into() {
+            Ok(Self::Pixels(pixels))
+        } else if let Ok(rems) = value.try_into() {
+            Ok(Self::Rems(rems))
+        } else {
+            Err(anyhow!(
+                "invalid AbsoluteLength '{value}', expected {EXPECTED_ABSOLUTE_LENGTH}"
+            ))
+        }
+    }
+}
+
+impl JsonSchema for AbsoluteLength {
+    fn schema_name() -> String {
+        "AbsoluteLength".to_string()
+    }
+
+    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
+        use schemars::schema::{InstanceType, SchemaObject, StringValidation};
+
+        Schema::Object(SchemaObject {
+            instance_type: Some(InstanceType::String.into()),
+            string: Some(Box::new(StringValidation {
+                pattern: Some(r"^-?\d+(\.\d+)?(px|rem)$".to_string()),
+                ..Default::default()
+            })),
+            ..Default::default()
+        })
+    }
+}
+
+impl<'de> Deserialize<'de> for AbsoluteLength {
+    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+        struct StringVisitor;
+
+        impl de::Visitor<'_> for StringVisitor {
+            type Value = AbsoluteLength;
+
+            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+                write!(f, "{EXPECTED_ABSOLUTE_LENGTH}")
+            }
+
+            fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
+                AbsoluteLength::try_from(value).map_err(E::custom)
+            }
+        }
+
+        deserializer.deserialize_str(StringVisitor)
+    }
+}
+
+impl Serialize for AbsoluteLength {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        serializer.serialize_str(&format!("{self}"))
+    }
+}
+
 /// A non-auto length that can be defined in pixels, rems, or percent of parent.
 ///
 /// This enum represents lengths that have a specific value, as opposed to lengths that are automatically
@@ -3142,14 +3330,89 @@ impl DefiniteLength {
 }
 
 impl Debug for DefiniteLength {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        Display::fmt(self, f)
+    }
+}
+
+impl Display for DefiniteLength {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
-            DefiniteLength::Absolute(length) => Debug::fmt(length, f),
-            DefiniteLength::Fraction(fract) => write!(f, "{}%", (fract * 100.0) as i32),
+            DefiniteLength::Absolute(length) => write!(f, "{length}"),
+            DefiniteLength::Fraction(fraction) => write!(f, "{}%", (fraction * 100.0) as i32),
         }
     }
 }
 
+const EXPECTED_DEFINITE_LENGTH: &str = "expected number with 'px', 'rem', or '%' suffix";
+
+impl TryFrom<&'_ str> for DefiniteLength {
+    type Error = anyhow::Error;
+
+    fn try_from(value: &'_ str) -> Result<Self, Self::Error> {
+        if let Some(percentage) = value.strip_suffix('%') {
+            let fraction: f32 = percentage.parse::<f32>().with_context(|| {
+                format!("invalid DefiniteLength '{value}', expected {EXPECTED_DEFINITE_LENGTH}")
+            })?;
+            Ok(DefiniteLength::Fraction(fraction / 100.0))
+        } else if let Ok(absolute_length) = value.try_into() {
+            Ok(DefiniteLength::Absolute(absolute_length))
+        } else {
+            Err(anyhow!(
+                "invalid DefiniteLength '{value}', expected {EXPECTED_DEFINITE_LENGTH}"
+            ))
+        }
+    }
+}
+
+impl JsonSchema for DefiniteLength {
+    fn schema_name() -> String {
+        "DefiniteLength".to_string()
+    }
+
+    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
+        use schemars::schema::{InstanceType, SchemaObject, StringValidation};
+
+        Schema::Object(SchemaObject {
+            instance_type: Some(InstanceType::String.into()),
+            string: Some(Box::new(StringValidation {
+                pattern: Some(r"^-?\d+(\.\d+)?(px|rem|%)$".to_string()),
+                ..Default::default()
+            })),
+            ..Default::default()
+        })
+    }
+}
+
+impl<'de> Deserialize<'de> for DefiniteLength {
+    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+        struct StringVisitor;
+
+        impl de::Visitor<'_> for StringVisitor {
+            type Value = DefiniteLength;
+
+            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+                write!(f, "{EXPECTED_DEFINITE_LENGTH}")
+            }
+
+            fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
+                DefiniteLength::try_from(value).map_err(E::custom)
+            }
+        }
+
+        deserializer.deserialize_str(StringVisitor)
+    }
+}
+
+impl Serialize for DefiniteLength {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        serializer.serialize_str(&format!("{self}"))
+    }
+}
+
 impl From<Pixels> for DefiniteLength {
     fn from(pixels: Pixels) -> Self {
         Self::Absolute(pixels.into())

crates/gpui/src/gpui.rs 🔗

@@ -73,12 +73,15 @@ mod asset_cache;
 mod assets;
 mod bounds_tree;
 mod color;
+/// The default colors used by GPUI.
+pub mod colors;
 mod element;
 mod elements;
 mod executor;
 mod geometry;
 mod global;
 mod input;
+mod inspector;
 mod interactive;
 mod key_dispatch;
 mod keymap;
@@ -133,6 +136,7 @@ pub use global::*;
 pub use gpui_macros::{AppContext, IntoElement, Render, VisualContext, register_action, test};
 pub use http_client;
 pub use input::*;
+pub use inspector::*;
 pub use interactive::*;
 use key_dispatch::*;
 pub use keymap::*;
@@ -251,7 +255,7 @@ pub trait VisualContext: AppContext {
         update: impl FnOnce(&mut T, &mut Window, &mut Context<T>) -> R,
     ) -> Self::Result<R>;
 
-    /// Update a view with the given callback
+    /// Create a new entity, with access to `Window`.
     fn new_window_entity<T: 'static>(
         &mut self,
         build_entity: impl FnOnce(&mut Window, &mut Context<T>) -> T,

crates/gpui/src/inspector.rs 🔗

@@ -0,0 +1,254 @@
+/// A unique identifier for an element that can be inspected.
+#[derive(Debug, Eq, PartialEq, Hash, Clone)]
+pub struct InspectorElementId {
+    /// Stable part of the ID.
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub path: std::rc::Rc<InspectorElementPath>,
+    /// Disambiguates elements that have the same path.
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub instance_id: usize,
+}
+
+impl Into<InspectorElementId> for &InspectorElementId {
+    fn into(self) -> InspectorElementId {
+        self.clone()
+    }
+}
+
+#[cfg(any(feature = "inspector", debug_assertions))]
+pub use conditional::*;
+
+#[cfg(any(feature = "inspector", debug_assertions))]
+mod conditional {
+    use super::*;
+    use crate::{AnyElement, App, Context, Empty, IntoElement, Render, Window};
+    use collections::FxHashMap;
+    use std::any::{Any, TypeId};
+
+    /// `GlobalElementId` qualified by source location of element construction.
+    #[derive(Debug, Eq, PartialEq, Hash)]
+    pub struct InspectorElementPath {
+        /// The path to the nearest ancestor element that has an `ElementId`.
+        #[cfg(any(feature = "inspector", debug_assertions))]
+        pub global_id: crate::GlobalElementId,
+        /// Source location where this element was constructed.
+        #[cfg(any(feature = "inspector", debug_assertions))]
+        pub source_location: &'static std::panic::Location<'static>,
+    }
+
+    impl Clone for InspectorElementPath {
+        fn clone(&self) -> Self {
+            Self {
+                global_id: crate::GlobalElementId(self.global_id.0.clone()),
+                source_location: self.source_location,
+            }
+        }
+    }
+
+    impl Into<InspectorElementPath> for &InspectorElementPath {
+        fn into(self) -> InspectorElementPath {
+            self.clone()
+        }
+    }
+
+    /// Function set on `App` to render the inspector UI.
+    pub type InspectorRenderer =
+        Box<dyn Fn(&mut Inspector, &mut Window, &mut Context<Inspector>) -> AnyElement>;
+
+    /// Manages inspector state - which element is currently selected and whether the inspector is
+    /// in picking mode.
+    pub struct Inspector {
+        active_element: Option<InspectedElement>,
+        pub(crate) pick_depth: Option<f32>,
+    }
+
+    struct InspectedElement {
+        id: InspectorElementId,
+        states: FxHashMap<TypeId, Box<dyn Any>>,
+    }
+
+    impl InspectedElement {
+        fn new(id: InspectorElementId) -> Self {
+            InspectedElement {
+                id,
+                states: FxHashMap::default(),
+            }
+        }
+    }
+
+    impl Inspector {
+        pub(crate) fn new() -> Self {
+            Self {
+                active_element: None,
+                pick_depth: Some(0.0),
+            }
+        }
+
+        pub(crate) fn select(&mut self, id: InspectorElementId, window: &mut Window) {
+            self.set_active_element_id(id, window);
+            self.pick_depth = None;
+        }
+
+        pub(crate) fn hover(&mut self, id: InspectorElementId, window: &mut Window) {
+            if self.is_picking() {
+                let changed = self.set_active_element_id(id, window);
+                if changed {
+                    self.pick_depth = Some(0.0);
+                }
+            }
+        }
+
+        pub(crate) fn set_active_element_id(
+            &mut self,
+            id: InspectorElementId,
+            window: &mut Window,
+        ) -> bool {
+            let changed = Some(&id) != self.active_element_id();
+            if changed {
+                self.active_element = Some(InspectedElement::new(id));
+                window.refresh();
+            }
+            changed
+        }
+
+        /// ID of the currently hovered or selected element.
+        pub fn active_element_id(&self) -> Option<&InspectorElementId> {
+            self.active_element.as_ref().map(|e| &e.id)
+        }
+
+        pub(crate) fn with_active_element_state<T: 'static, R>(
+            &mut self,
+            window: &mut Window,
+            f: impl FnOnce(&mut Option<T>, &mut Window) -> R,
+        ) -> R {
+            let Some(active_element) = &mut self.active_element else {
+                return f(&mut None, window);
+            };
+
+            let type_id = TypeId::of::<T>();
+            let mut inspector_state = active_element
+                .states
+                .remove(&type_id)
+                .map(|state| *state.downcast().unwrap());
+
+            let result = f(&mut inspector_state, window);
+
+            if let Some(inspector_state) = inspector_state {
+                active_element
+                    .states
+                    .insert(type_id, Box::new(inspector_state));
+            }
+
+            result
+        }
+
+        /// Starts element picking mode, allowing the user to select elements by clicking.
+        pub fn start_picking(&mut self) {
+            self.pick_depth = Some(0.0);
+        }
+
+        /// Returns whether the inspector is currently in picking mode.
+        pub fn is_picking(&self) -> bool {
+            self.pick_depth.is_some()
+        }
+
+        /// Renders elements for all registered inspector states of the active inspector element.
+        pub fn render_inspector_states(
+            &mut self,
+            window: &mut Window,
+            cx: &mut Context<Self>,
+        ) -> Vec<AnyElement> {
+            let mut elements = Vec::new();
+            if let Some(active_element) = self.active_element.take() {
+                for (type_id, state) in &active_element.states {
+                    if let Some(render_inspector) = cx
+                        .inspector_element_registry
+                        .renderers_by_type_id
+                        .remove(&type_id)
+                    {
+                        let mut element = (render_inspector)(
+                            active_element.id.clone(),
+                            state.as_ref(),
+                            window,
+                            cx,
+                        );
+                        elements.push(element);
+                        cx.inspector_element_registry
+                            .renderers_by_type_id
+                            .insert(*type_id, render_inspector);
+                    }
+                }
+
+                self.active_element = Some(active_element);
+            }
+
+            elements
+        }
+    }
+
+    impl Render for Inspector {
+        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+            if let Some(inspector_renderer) = cx.inspector_renderer.take() {
+                let result = inspector_renderer(self, window, cx);
+                cx.inspector_renderer = Some(inspector_renderer);
+                result
+            } else {
+                Empty.into_any_element()
+            }
+        }
+    }
+
+    #[derive(Default)]
+    pub(crate) struct InspectorElementRegistry {
+        renderers_by_type_id: FxHashMap<
+            TypeId,
+            Box<dyn Fn(InspectorElementId, &dyn Any, &mut Window, &mut App) -> AnyElement>,
+        >,
+    }
+
+    impl InspectorElementRegistry {
+        pub fn register<T: 'static, R: IntoElement>(
+            &mut self,
+            f: impl 'static + Fn(InspectorElementId, &T, &mut Window, &mut App) -> R,
+        ) {
+            self.renderers_by_type_id.insert(
+                TypeId::of::<T>(),
+                Box::new(move |id, value, window, cx| {
+                    let value = value.downcast_ref().unwrap();
+                    f(id, value, window, cx).into_any_element()
+                }),
+            );
+        }
+    }
+}
+
+/// Provides definitions used by `#[derive_inspector_reflection]`.
+#[cfg(any(feature = "inspector", debug_assertions))]
+pub mod inspector_reflection {
+    use std::any::Any;
+
+    /// Reification of a function that has the signature `fn some_fn(T) -> T`. Provides the name,
+    /// documentation, and ability to invoke the function.
+    #[derive(Clone, Copy)]
+    pub struct FunctionReflection<T> {
+        /// The name of the function
+        pub name: &'static str,
+        /// The method
+        pub function: fn(Box<dyn Any>) -> Box<dyn Any>,
+        /// Documentation for the function
+        pub documentation: Option<&'static str>,
+        /// `PhantomData` for the type of the argument and result
+        pub _type: std::marker::PhantomData<T>,
+    }
+
+    impl<T: 'static> FunctionReflection<T> {
+        /// Invoke this method on a value and return the result.
+        pub fn invoke(&self, value: T) -> T {
+            let boxed = Box::new(value) as Box<dyn Any>;
+            let result = (self.function)(boxed);
+            *result
+                .downcast::<T>()
+                .expect("Type mismatch in reflection invoke")
+        }
+    }
+}

crates/gpui/src/interactive.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
-    Context, Empty, IntoElement, Keystroke, Modifiers, Pixels, Point, Render, Window, point,
-    seal::Sealed,
+    Capslock, Context, Empty, IntoElement, Keystroke, Modifiers, Pixels, Point, Render, Window,
+    point, seal::Sealed,
 };
 use smallvec::SmallVec;
 use std::{any::Any, fmt::Debug, ops::Deref, path::PathBuf};
@@ -55,6 +55,8 @@ impl KeyEvent for KeyUpEvent {}
 pub struct ModifiersChangedEvent {
     /// The new state of the modifier keys
     pub modifiers: Modifiers,
+    /// The new state of the capslock key
+    pub capslock: Capslock,
 }
 
 impl Sealed for ModifiersChangedEvent {}
@@ -491,7 +493,7 @@ mod test {
         focus_handle: FocusHandle,
     }
 
-    actions!(test, [TestAction]);
+    actions!(test_only, [TestAction]);
 
     impl Render for TestView {
         fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {

crates/gpui/src/key_dispatch.rs 🔗

@@ -27,7 +27,7 @@
 ///
 /// The keybindings themselves are managed independently by calling cx.bind_keys().
 /// (Though mostly when developing Zed itself, you just need to add a new line to
-///  assets/keymaps/default.json).
+///  assets/keymaps/default-{platform}.json).
 ///
 /// ```rust
 /// cx.bind_keys([
@@ -50,8 +50,8 @@
 ///  KeyBinding::new("cmd-k left", pane::SplitLeft, Some("Pane"))
 ///
 use crate::{
-    Action, ActionRegistry, App, DispatchPhase, EntityId, FocusId, KeyBinding, KeyContext, Keymap,
-    Keystroke, ModifiersChangedEvent, Window,
+    Action, ActionRegistry, App, BindingIndex, DispatchPhase, EntityId, FocusId, KeyBinding,
+    KeyContext, Keymap, Keystroke, ModifiersChangedEvent, Window,
 };
 use collections::FxHashMap;
 use smallvec::SmallVec;
@@ -63,6 +63,8 @@ use std::{
     rc::Rc,
 };
 
+/// ID of a node within `DispatchTree`. Note that these are **not** stable between frames, and so a
+/// `DispatchNodeId` should only be used with the `DispatchTree` that provided it.
 #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
 pub(crate) struct DispatchNodeId(usize);
 
@@ -392,22 +394,67 @@ impl DispatchTree {
 
     /// Returns key bindings that invoke an action on the currently focused element. Bindings are
     /// returned in the order they were added. For display, the last binding should take precedence.
+    ///
+    /// Bindings are only included if they are the highest precedence match for their keystrokes, so
+    /// shadowed bindings are not included.
     pub fn bindings_for_action(
         &self,
         action: &dyn Action,
         context_stack: &[KeyContext],
     ) -> Vec<KeyBinding> {
+        // Ideally this would return a `DoubleEndedIterator` to avoid `highest_precedence_*`
+        // methods, but this can't be done very cleanly since keymap must be borrowed.
         let keymap = self.keymap.borrow();
         keymap
-            .bindings_for_action(action)
-            .filter(|binding| {
-                let (bindings, _) = keymap.bindings_for_input(&binding.keystrokes, context_stack);
-                bindings.iter().any(|b| b.action.partial_eq(action))
+            .bindings_for_action_with_indices(action)
+            .filter(|(binding_index, binding)| {
+                Self::binding_matches_predicate_and_not_shadowed(
+                    &keymap,
+                    *binding_index,
+                    &binding.keystrokes,
+                    context_stack,
+                )
             })
-            .cloned()
+            .map(|(_, binding)| binding.clone())
             .collect()
     }
 
+    /// Returns the highest precedence binding for the given action and context stack. This is the
+    /// same as the last result of `bindings_for_action`, but more efficient than getting all bindings.
+    pub fn highest_precedence_binding_for_action(
+        &self,
+        action: &dyn Action,
+        context_stack: &[KeyContext],
+    ) -> Option<KeyBinding> {
+        let keymap = self.keymap.borrow();
+        keymap
+            .bindings_for_action_with_indices(action)
+            .rev()
+            .find_map(|(binding_index, binding)| {
+                let found = Self::binding_matches_predicate_and_not_shadowed(
+                    &keymap,
+                    binding_index,
+                    &binding.keystrokes,
+                    context_stack,
+                );
+                if found { Some(binding.clone()) } else { None }
+            })
+    }
+
+    fn binding_matches_predicate_and_not_shadowed(
+        keymap: &Keymap,
+        binding_index: BindingIndex,
+        keystrokes: &[Keystroke],
+        context_stack: &[KeyContext],
+    ) -> bool {
+        let (bindings, _) = keymap.bindings_for_input_with_indices(&keystrokes, context_stack);
+        if let Some((highest_precedence_index, _)) = bindings.iter().next() {
+            binding_index == *highest_precedence_index
+        } else {
+            false
+        }
+    }
+
     fn bindings_for_input(
         &self,
         input: &[Keystroke],
@@ -587,7 +634,7 @@ mod tests {
             "test::TestAction"
         }
 
-        fn debug_name() -> &'static str
+        fn name_for_type() -> &'static str
         where
             Self: ::std::marker::Sized,
         {

crates/gpui/src/keymap.rs 🔗

@@ -23,6 +23,10 @@ pub struct Keymap {
     version: KeymapVersion,
 }
 
+/// Index of a binding within a keymap.
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub struct BindingIndex(usize);
+
 impl Keymap {
     /// Create a new keymap with the given bindings.
     pub fn new(bindings: Vec<KeyBinding>) -> Self {
@@ -63,7 +67,7 @@ impl Keymap {
     }
 
     /// Iterate over all bindings, in the order they were added.
-    pub fn bindings(&self) -> impl DoubleEndedIterator<Item = &KeyBinding> {
+    pub fn bindings(&self) -> impl DoubleEndedIterator<Item = &KeyBinding> + ExactSizeIterator {
         self.bindings.iter()
     }
 
@@ -73,6 +77,15 @@ impl Keymap {
         &'a self,
         action: &'a dyn Action,
     ) -> impl 'a + DoubleEndedIterator<Item = &'a KeyBinding> {
+        self.bindings_for_action_with_indices(action)
+            .map(|(_, binding)| binding)
+    }
+
+    /// Like `bindings_for_action_with_indices`, but also returns the binding indices.
+    pub fn bindings_for_action_with_indices<'a>(
+        &'a self,
+        action: &'a dyn Action,
+    ) -> impl 'a + DoubleEndedIterator<Item = (BindingIndex, &'a KeyBinding)> {
         let action_id = action.type_id();
         let binding_indices = self
             .binding_indices_by_action_id
@@ -105,7 +118,7 @@ impl Keymap {
                 }
             }
 
-            Some(binding)
+            Some((BindingIndex(*ix), binding))
         })
     }
 
@@ -123,7 +136,7 @@ impl Keymap {
 
     /// Returns a list of bindings that match the given input, and a boolean indicating whether or
     /// not more bindings might match if the input was longer. Bindings are returned in precedence
-    /// order.
+    /// order (higher precedence first, reverse of the order they were added to the keymap).
     ///
     /// Precedence is defined by the depth in the tree (matches on the Editor take precedence over
     /// matches on the Pane, then the Workspace, etc.). Bindings with no context are treated as the
@@ -140,41 +153,95 @@ impl Keymap {
         input: &[Keystroke],
         context_stack: &[KeyContext],
     ) -> (SmallVec<[KeyBinding; 1]>, bool) {
-        let possibilities = self.bindings().rev().filter_map(|binding| {
-            binding
-                .match_keystrokes(input)
-                .map(|pending| (binding, pending))
-        });
+        let (bindings, pending) = self.bindings_for_input_with_indices(input, context_stack);
+        let bindings = bindings
+            .into_iter()
+            .map(|(_, binding)| binding)
+            .collect::<SmallVec<[KeyBinding; 1]>>();
+        (bindings, pending)
+    }
 
-        let mut bindings: SmallVec<[(KeyBinding, usize); 1]> = SmallVec::new();
-        let mut is_pending = None;
+    /// Like `bindings_for_input`, but also returns the binding indices.
+    pub fn bindings_for_input_with_indices(
+        &self,
+        input: &[Keystroke],
+        context_stack: &[KeyContext],
+    ) -> (SmallVec<[(BindingIndex, KeyBinding); 1]>, bool) {
+        let possibilities = self
+            .bindings()
+            .enumerate()
+            .rev()
+            .filter_map(|(ix, binding)| {
+                binding
+                    .match_keystrokes(input)
+                    .map(|pending| (BindingIndex(ix), binding, pending))
+            });
 
-        'outer: for (binding, pending) in possibilities {
+        let mut bindings: SmallVec<[(BindingIndex, KeyBinding, usize); 1]> = SmallVec::new();
+
+        // (pending, is_no_action, depth, keystrokes)
+        let mut pending_info_opt: Option<(bool, bool, usize, &[Keystroke])> = None;
+
+        'outer: for (binding_index, binding, pending) in possibilities {
             for depth in (0..=context_stack.len()).rev() {
                 if self.binding_enabled(binding, &context_stack[0..depth]) {
-                    if is_pending.is_none() {
-                        is_pending = Some(pending);
+                    let is_no_action = is_no_action(&*binding.action);
+                    // We only want to consider a binding pending if it has an action
+                    // This, however, means that if we have both a NoAction binding and a binding
+                    // with an action at the same depth, we should still set is_pending to true.
+                    if let Some(pending_info) = pending_info_opt.as_mut() {
+                        let (
+                            already_pending,
+                            pending_is_no_action,
+                            pending_depth,
+                            pending_keystrokes,
+                        ) = *pending_info;
+
+                        // We only want to change the pending status if it's not already pending AND if
+                        // the existing pending status was set by a NoAction binding. This avoids a NoAction
+                        // binding erroneously setting the pending status to true when a binding with an action
+                        // already set it to false
+                        //
+                        // We also want to change the pending status if the keystrokes don't match,
+                        // meaning it's different keystrokes than the NoAction that set pending to false
+                        if pending
+                            && !already_pending
+                            && pending_is_no_action
+                            && (pending_depth == depth
+                                || pending_keystrokes != binding.keystrokes())
+                        {
+                            pending_info.0 = !is_no_action;
+                        }
+                    } else {
+                        pending_info_opt = Some((
+                            pending && !is_no_action,
+                            is_no_action,
+                            depth,
+                            binding.keystrokes(),
+                        ));
                     }
+
                     if !pending {
-                        bindings.push((binding.clone(), depth));
+                        bindings.push((binding_index, binding.clone(), depth));
                         continue 'outer;
                     }
                 }
             }
         }
-        bindings.sort_by(|a, b| a.1.cmp(&b.1).reverse());
+        // sort by descending depth
+        bindings.sort_by(|a, b| a.2.cmp(&b.2).reverse());
         let bindings = bindings
             .into_iter()
-            .map_while(|(binding, _)| {
+            .map_while(|(binding_index, binding, _)| {
                 if is_no_action(&*binding.action) {
                     None
                 } else {
-                    Some(binding)
+                    Some((binding_index, binding))
                 }
             })
             .collect();
 
-        (bindings, is_pending.unwrap_or_default())
+        (bindings, pending_info_opt.unwrap_or_default().0)
     }
 
     /// Check if the given binding is enabled, given a certain key context.
@@ -188,41 +255,16 @@ impl Keymap {
 
         true
     }
-
-    /// WARN: Assumes the bindings are in the order they were added to the keymap
-    /// returns the last binding for the given bindings, which
-    /// should be the user's binding in their keymap.json if they've set one,
-    /// otherwise, the last declared binding for this action in the base keymaps
-    /// (with Vim mode bindings being considered as declared later if Vim mode
-    /// is enabled)
-    ///
-    /// If you are considering changing the behavior of this function
-    /// (especially to fix a user reported issue) see issues #23621, #24931,
-    /// and possibly others as evidence that it has swapped back and forth a
-    /// couple times. The decision as of now is to pick a side and leave it
-    /// as is, until we have a better way to decide which binding to display
-    /// that is consistent and not confusing.
-    pub fn binding_to_display_from_bindings(mut bindings: Vec<KeyBinding>) -> Option<KeyBinding> {
-        bindings.pop()
-    }
-
-    /// Like `bindings_to_display_from_bindings` but takes a `DoubleEndedIterator` and returns a
-    /// reference.
-    pub fn binding_to_display_from_bindings_iterator<'a>(
-        mut bindings: impl DoubleEndedIterator<Item = &'a KeyBinding>,
-    ) -> Option<&'a KeyBinding> {
-        bindings.next_back()
-    }
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
     use crate as gpui;
-    use gpui::{NoAction, actions};
+    use gpui::NoAction;
 
     actions!(
-        keymap_test,
+        test_only,
         [ActionAlpha, ActionBeta, ActionGamma, ActionDelta,]
     );
 
@@ -307,6 +349,102 @@ mod tests {
         );
     }
 
+    #[test]
+    /// Tests for https://github.com/zed-industries/zed/issues/30259
+    fn test_multiple_keystroke_binding_disabled() {
+        let bindings = [
+            KeyBinding::new("space w w", ActionAlpha {}, Some("workspace")),
+            KeyBinding::new("space w w", NoAction {}, Some("editor")),
+        ];
+
+        let mut keymap = Keymap::default();
+        keymap.add_bindings(bindings.clone());
+
+        let space = || Keystroke::parse("space").unwrap();
+        let w = || Keystroke::parse("w").unwrap();
+
+        let space_w = [space(), w()];
+        let space_w_w = [space(), w(), w()];
+
+        let workspace_context = || [KeyContext::parse("workspace").unwrap()];
+
+        let editor_workspace_context = || {
+            [
+                KeyContext::parse("workspace").unwrap(),
+                KeyContext::parse("editor").unwrap(),
+            ]
+        };
+
+        // Ensure `space` results in pending input on the workspace, but not editor
+        let space_workspace = keymap.bindings_for_input(&[space()], &workspace_context());
+        assert!(space_workspace.0.is_empty());
+        assert_eq!(space_workspace.1, true);
+
+        let space_editor = keymap.bindings_for_input(&[space()], &editor_workspace_context());
+        assert!(space_editor.0.is_empty());
+        assert_eq!(space_editor.1, false);
+
+        // Ensure `space w` results in pending input on the workspace, but not editor
+        let space_w_workspace = keymap.bindings_for_input(&space_w, &workspace_context());
+        assert!(space_w_workspace.0.is_empty());
+        assert_eq!(space_w_workspace.1, true);
+
+        let space_w_editor = keymap.bindings_for_input(&space_w, &editor_workspace_context());
+        assert!(space_w_editor.0.is_empty());
+        assert_eq!(space_w_editor.1, false);
+
+        // Ensure `space w w` results in the binding in the workspace, but not in the editor
+        let space_w_w_workspace = keymap.bindings_for_input(&space_w_w, &workspace_context());
+        assert!(!space_w_w_workspace.0.is_empty());
+        assert_eq!(space_w_w_workspace.1, false);
+
+        let space_w_w_editor = keymap.bindings_for_input(&space_w_w, &editor_workspace_context());
+        assert!(space_w_w_editor.0.is_empty());
+        assert_eq!(space_w_w_editor.1, false);
+
+        // Now test what happens if we have another binding defined AFTER the NoAction
+        // that should result in pending
+        let bindings = [
+            KeyBinding::new("space w w", ActionAlpha {}, Some("workspace")),
+            KeyBinding::new("space w w", NoAction {}, Some("editor")),
+            KeyBinding::new("space w x", ActionAlpha {}, Some("editor")),
+        ];
+        let mut keymap = Keymap::default();
+        keymap.add_bindings(bindings.clone());
+
+        let space_editor = keymap.bindings_for_input(&[space()], &editor_workspace_context());
+        assert!(space_editor.0.is_empty());
+        assert_eq!(space_editor.1, true);
+
+        // Now test what happens if we have another binding defined BEFORE the NoAction
+        // that should result in pending
+        let bindings = [
+            KeyBinding::new("space w w", ActionAlpha {}, Some("workspace")),
+            KeyBinding::new("space w x", ActionAlpha {}, Some("editor")),
+            KeyBinding::new("space w w", NoAction {}, Some("editor")),
+        ];
+        let mut keymap = Keymap::default();
+        keymap.add_bindings(bindings.clone());
+
+        let space_editor = keymap.bindings_for_input(&[space()], &editor_workspace_context());
+        assert!(space_editor.0.is_empty());
+        assert_eq!(space_editor.1, true);
+
+        // Now test what happens if we have another binding defined at a higher context
+        // that should result in pending
+        let bindings = [
+            KeyBinding::new("space w w", ActionAlpha {}, Some("workspace")),
+            KeyBinding::new("space w x", ActionAlpha {}, Some("workspace")),
+            KeyBinding::new("space w w", NoAction {}, Some("editor")),
+        ];
+        let mut keymap = Keymap::default();
+        keymap.add_bindings(bindings.clone());
+
+        let space_editor = keymap.bindings_for_input(&[space()], &editor_workspace_context());
+        assert!(space_editor.0.is_empty());
+        assert_eq!(space_editor.1, true);
+    }
+
     #[test]
     fn test_bindings_for_action() {
         let bindings = [

crates/gpui/src/keymap/binding.rs 🔗

@@ -10,6 +10,7 @@ pub struct KeyBinding {
     pub(crate) action: Box<dyn Action>,
     pub(crate) keystrokes: SmallVec<[Keystroke; 2]>,
     pub(crate) context_predicate: Option<Rc<KeyBindingContextPredicate>>,
+    pub(crate) meta: Option<KeyBindingMetaIndex>,
 }
 
 impl Clone for KeyBinding {
@@ -18,6 +19,7 @@ impl Clone for KeyBinding {
             action: self.action.boxed_clone(),
             keystrokes: self.keystrokes.clone(),
             context_predicate: self.context_predicate.clone(),
+            meta: self.meta,
         }
     }
 }
@@ -59,9 +61,21 @@ impl KeyBinding {
             keystrokes,
             action,
             context_predicate,
+            meta: None,
         })
     }
 
+    /// Set the metadata for this binding.
+    pub fn with_meta(mut self, meta: KeyBindingMetaIndex) -> Self {
+        self.meta = Some(meta);
+        self
+    }
+
+    /// Set the metadata for this binding.
+    pub fn set_meta(&mut self, meta: KeyBindingMetaIndex) {
+        self.meta = Some(meta);
+    }
+
     /// Check if the given keystrokes match this binding.
     pub fn match_keystrokes(&self, typed: &[Keystroke]) -> Option<bool> {
         if self.keystrokes.len() < typed.len() {
@@ -91,6 +105,11 @@ impl KeyBinding {
     pub fn predicate(&self) -> Option<Rc<KeyBindingContextPredicate>> {
         self.context_predicate.as_ref().map(|rc| rc.clone())
     }
+
+    /// Get the metadata for this binding
+    pub fn meta(&self) -> Option<KeyBindingMetaIndex> {
+        self.meta
+    }
 }
 
 impl std::fmt::Debug for KeyBinding {
@@ -102,3 +121,9 @@ impl std::fmt::Debug for KeyBinding {
             .finish()
     }
 }
+
+/// A unique identifier for retrieval of metadata associated with a key binding.
+/// Intended to be used as an index or key into a user-defined store of metadata
+/// associated with the binding, such as the source of the binding.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct KeyBindingMetaIndex(pub u32);

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

@@ -1,5 +1,5 @@
 use crate::SharedString;
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use std::fmt;
 
 /// A datastructure for resolving whether an action should be dispatched
@@ -243,7 +243,7 @@ impl KeyBindingContextPredicate {
         let source = skip_whitespace(source);
         let (predicate, rest) = Self::parse_expr(source, 0)?;
         if let Some(next) = rest.chars().next() {
-            Err(anyhow!("unexpected character '{next:?}'"))
+            anyhow::bail!("unexpected character '{next:?}'");
         } else {
             Ok(predicate)
         }
@@ -329,20 +329,14 @@ impl KeyBindingContextPredicate {
     }
 
     fn parse_primary(mut source: &str) -> anyhow::Result<(Self, &str)> {
-        let next = source
-            .chars()
-            .next()
-            .ok_or_else(|| anyhow!("unexpected end"))?;
+        let next = source.chars().next().context("unexpected end")?;
         match next {
             '(' => {
                 source = skip_whitespace(&source[1..]);
                 let (predicate, rest) = Self::parse_expr(source, 0)?;
-                if let Some(stripped) = rest.strip_prefix(')') {
-                    source = skip_whitespace(stripped);
-                    Ok((predicate, source))
-                } else {
-                    Err(anyhow!("expected a ')'"))
-                }
+                let stripped = rest.strip_prefix(')').context("expected a ')'")?;
+                source = skip_whitespace(stripped);
+                Ok((predicate, source))
             }
             '!' => {
                 let source = skip_whitespace(&source[1..]);
@@ -368,7 +362,7 @@ impl KeyBindingContextPredicate {
                     source,
                 ))
             }
-            _ => Err(anyhow!("unexpected character '{next:?}'")),
+            _ => anyhow::bail!("unexpected character '{next:?}'"),
         }
     }
 
@@ -388,7 +382,7 @@ impl KeyBindingContextPredicate {
         if let (Self::Identifier(left), Self::Identifier(right)) = (self, other) {
             Ok(Self::Equal(left, right))
         } else {
-            Err(anyhow!("operands of == must be identifiers"))
+            anyhow::bail!("operands of == must be identifiers");
         }
     }
 
@@ -396,7 +390,7 @@ impl KeyBindingContextPredicate {
         if let (Self::Identifier(left), Self::Identifier(right)) = (self, other) {
             Ok(Self::NotEqual(left, right))
         } else {
-            Err(anyhow!("operands of != must be identifiers"))
+            anyhow::bail!("operands of != must be identifiers");
         }
     }
 }
@@ -431,14 +425,14 @@ mod tests {
     #[test]
     fn test_actions_definition() {
         {
-            actions!(test, [A, B, C, D, E, F, G]);
+            actions!(test_only, [A, B, C, D, E, F, G]);
         }
 
         {
             actions!(
-                test,
+                test_only,
                 [
-                    A, B, C, D, E, F, G, // Don't wrap, test the trailing comma
+                    H, I, J, K, L, M, N, // Don't wrap, test the trailing comma
                 ]
             );
         }

crates/gpui/src/path_builder.rs 🔗

@@ -1,6 +1,9 @@
 use anyhow::Error;
-use etagere::euclid::Vector2D;
+use etagere::euclid::{Point2D, Vector2D};
 use lyon::geom::Angle;
+use lyon::math::{Vector, vector};
+use lyon::path::traits::SvgPathBuilder;
+use lyon::path::{ArcFlags, Polygon};
 use lyon::tessellation::{
     BuffersBuilder, FillTessellator, FillVertex, StrokeTessellator, StrokeVertex, VertexBuffers,
 };
@@ -24,6 +27,7 @@ pub struct PathBuilder {
     transform: Option<lyon::math::Transform>,
     /// PathStyle of the PathBuilder
     pub style: PathStyle,
+    dash_array: Option<Vec<Pixels>>,
 }
 
 impl From<lyon::path::Builder> for PathBuilder {
@@ -56,12 +60,25 @@ impl From<Point<Pixels>> for lyon::math::Point {
     }
 }
 
+impl From<Point<Pixels>> for Vector {
+    fn from(p: Point<Pixels>) -> Self {
+        vector(p.x.0, p.y.0)
+    }
+}
+
+impl From<Point<Pixels>> for Point2D<f32, Pixels> {
+    fn from(p: Point<Pixels>) -> Self {
+        Point2D::new(p.x.0, p.y.0)
+    }
+}
+
 impl Default for PathBuilder {
     fn default() -> Self {
         Self {
             raw: lyon::path::Path::builder().with_svg(),
             style: PathStyle::Fill(FillOptions::default()),
             transform: None,
+            dash_array: None,
         }
     }
 }
@@ -85,6 +102,24 @@ impl PathBuilder {
         Self { style, ..self }
     }
 
+    /// Sets the dash array of the [`PathBuilder`].
+    ///
+    /// [MDN](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/stroke-dasharray)
+    pub fn dash_array(mut self, dash_array: &[Pixels]) -> Self {
+        // If an odd number of values is provided, then the list of values is repeated to yield an even number of values.
+        // Thus, 5,3,2 is equivalent to 5,3,2,5,3,2.
+        let array = if dash_array.len() % 2 == 1 {
+            let mut new_dash_array = dash_array.to_vec();
+            new_dash_array.extend_from_slice(dash_array);
+            new_dash_array
+        } else {
+            dash_array.to_vec()
+        };
+
+        self.dash_array = Some(array);
+        self
+    }
+
     /// Move the current point to the given point.
     #[inline]
     pub fn move_to(&mut self, to: Point<Pixels>) {
@@ -116,6 +151,49 @@ impl PathBuilder {
             .cubic_bezier_to(control_a.into(), control_b.into(), to.into());
     }
 
+    /// Adds an elliptical arc.
+    pub fn arc_to(
+        &mut self,
+        radii: Point<Pixels>,
+        x_rotation: Pixels,
+        large_arc: bool,
+        sweep: bool,
+        to: Point<Pixels>,
+    ) {
+        self.raw.arc_to(
+            radii.into(),
+            Angle::degrees(x_rotation.into()),
+            ArcFlags { large_arc, sweep },
+            to.into(),
+        );
+    }
+
+    /// Equivalent to `arc_to` in relative coordinates.
+    pub fn relative_arc_to(
+        &mut self,
+        radii: Point<Pixels>,
+        x_rotation: Pixels,
+        large_arc: bool,
+        sweep: bool,
+        to: Point<Pixels>,
+    ) {
+        self.raw.relative_arc_to(
+            radii.into(),
+            Angle::degrees(x_rotation.into()),
+            ArcFlags { large_arc, sweep },
+            to.into(),
+        );
+    }
+
+    /// Adds a polygon.
+    pub fn add_polygon(&mut self, points: &[Point<Pixels>], closed: bool) {
+        let points = points.iter().copied().map(|p| p.into()).collect::<Vec<_>>();
+        self.raw.add_polygon(Polygon {
+            points: points.as_ref(),
+            closed,
+        });
+    }
+
     /// Close the current sub-path.
     #[inline]
     pub fn close(&mut self) {
@@ -171,7 +249,7 @@ impl PathBuilder {
         };
 
         match self.style {
-            PathStyle::Stroke(options) => Self::tessellate_stroke(&path, &options),
+            PathStyle::Stroke(options) => Self::tessellate_stroke(self.dash_array, &path, &options),
             PathStyle::Fill(options) => Self::tessellate_fill(&path, &options),
         }
     }
@@ -195,9 +273,37 @@ impl PathBuilder {
     }
 
     fn tessellate_stroke(
+        dash_array: Option<Vec<Pixels>>,
         path: &lyon::path::Path,
         options: &StrokeOptions,
     ) -> Result<Path<Pixels>, Error> {
+        let path = if let Some(dash_array) = dash_array {
+            let measurements = lyon::algorithms::measure::PathMeasurements::from_path(&path, 0.01);
+            let mut sampler = measurements
+                .create_sampler(path, lyon::algorithms::measure::SampleType::Normalized);
+            let mut builder = lyon::path::Path::builder();
+
+            let total_length = sampler.length();
+            let dash_array_len = dash_array.len();
+            let mut pos = 0.;
+            let mut dash_index = 0;
+            while pos < total_length {
+                let dash_length = dash_array[dash_index % dash_array_len].0;
+                let next_pos = (pos + dash_length).min(total_length);
+                if dash_index % 2 == 0 {
+                    let start = pos / total_length;
+                    let end = next_pos / total_length;
+                    sampler.split_range(start..end, &mut builder);
+                }
+                pos = next_pos;
+                dash_index += 1;
+            }
+
+            &builder.build()
+        } else {
+            path
+        };
+
         // Will contain the result of the tessellation.
         let mut buf: VertexBuffers<lyon::math::Point, u16> = VertexBuffers::new();
         let mut tessellator = StrokeTessellator::new();

crates/gpui/src/platform.rs 🔗

@@ -36,7 +36,7 @@ use crate::{
     ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput,
     Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, ScaledPixels, Scene,
     ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SvgSize, Task, TaskLabel, Window,
-    hash, point, px, size,
+    WindowControlArea, hash, point, px, size,
 };
 use anyhow::Result;
 use async_task::Runnable;
@@ -45,6 +45,7 @@ use image::codecs::gif::GifDecoder;
 use image::{AnimationDecoder as _, Frame};
 use parking::Unparker;
 use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
+use schemars::JsonSchema;
 use seahash::SeaHasher;
 use serde::{Deserialize, Serialize};
 use smallvec::SmallVec;
@@ -92,6 +93,9 @@ pub(crate) fn current_platform(headless: bool) -> Rc<dyn Platform> {
 
 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
 pub(crate) fn current_platform(headless: bool) -> Rc<dyn Platform> {
+    #[cfg(feature = "x11")]
+    use anyhow::Context as _;
+
     if headless {
         return Rc::new(HeadlessClient::new());
     }
@@ -101,7 +105,11 @@ pub(crate) fn current_platform(headless: bool) -> Rc<dyn Platform> {
         "Wayland" => Rc::new(WaylandClient::new()),
 
         #[cfg(feature = "x11")]
-        "X11" => Rc::new(X11Client::new()),
+        "X11" => Rc::new(
+            X11Client::new()
+                .context("Failed to initialize X11 client.")
+                .unwrap(),
+        ),
 
         "Headless" => Rc::new(HeadlessClient::new()),
         _ => unreachable!(),
@@ -141,7 +149,11 @@ pub fn guess_compositor() -> &'static str {
 
 #[cfg(target_os = "windows")]
 pub(crate) fn current_platform(_headless: bool) -> Rc<dyn Platform> {
-    Rc::new(WindowsPlatform::new())
+    Rc::new(
+        WindowsPlatform::new()
+            .inspect_err(|err| show_error("Error: Zed failed to launch", err.to_string()))
+            .unwrap(),
+    )
 }
 
 pub(crate) trait Platform: 'static {
@@ -410,6 +422,7 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
     fn display(&self) -> Option<Rc<dyn PlatformDisplay>>;
     fn mouse_position(&self) -> Point<Pixels>;
     fn modifiers(&self) -> Modifiers;
+    fn capslock(&self) -> Capslock;
     fn set_input_handler(&mut self, input_handler: PlatformInputHandler);
     fn take_input_handler(&mut self) -> Option<PlatformInputHandler>;
     fn prompt(
@@ -417,7 +430,7 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
         level: PromptLevel,
         msg: &str,
         detail: Option<&str>,
-        answers: &[&str],
+        answers: &[PromptButton],
     ) -> Option<oneshot::Receiver<usize>>;
     fn activate(&self);
     fn is_active(&self) -> bool;
@@ -435,6 +448,7 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
     fn on_resize(&self, callback: Box<dyn FnMut(Size<Pixels>, f32)>);
     fn on_moved(&self, callback: Box<dyn FnMut()>);
     fn on_should_close(&self, callback: Box<dyn FnMut() -> bool>);
+    fn on_hit_test_window_control(&self, callback: Box<dyn FnMut() -> Option<WindowControlArea>>);
     fn on_close(&self, callback: Box<dyn FnOnce()>);
     fn on_appearance_changed(&self, callback: Box<dyn FnMut()>);
     fn draw(&self, scene: &Scene);
@@ -444,6 +458,7 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
     // macOS specific methods
     fn set_edited(&mut self, _edited: bool) {}
     fn show_character_palette(&self) {}
+    fn titlebar_double_click(&self) {}
 
     #[cfg(target_os = "windows")]
     fn get_raw_handle(&self) -> windows::HWND;
@@ -595,7 +610,7 @@ impl PlatformTextSystem for NoopTextSystem {
                 .unwrap()
                 .width
             / metrics.units_per_em as f32;
-        let mut glyphs = SmallVec::default();
+        let mut glyphs = Vec::new();
         for (ix, c) in text.char_indices() {
             if let Some(glyph) = self.glyph_for_char(FontId(0), c) {
                 glyphs.push(ShapedGlyph {
@@ -715,7 +730,7 @@ impl<T> ops::Index<usize> for AtlasTextureList<T> {
 
 impl<T> AtlasTextureList<T> {
     #[allow(unused)]
-    fn drain(&mut self) -> std::vec::Drain<Option<T>> {
+    fn drain(&mut self) -> std::vec::Drain<'_, Option<T>> {
         self.free_list.clear();
         self.textures.drain(..)
     }
@@ -836,7 +851,7 @@ impl PlatformInputHandler {
             .ok();
     }
 
-    fn replace_and_mark_text_in_range(
+    pub fn replace_and_mark_text_in_range(
         &mut self,
         range_utf16: Option<Range<usize>>,
         new_text: &str,
@@ -1243,8 +1258,60 @@ pub enum PromptLevel {
     Critical,
 }
 
+/// Prompt Button
+#[derive(Clone, Debug, PartialEq)]
+pub enum PromptButton {
+    /// Ok button
+    Ok(SharedString),
+    /// Cancel button
+    Cancel(SharedString),
+    /// Other button
+    Other(SharedString),
+}
+
+impl PromptButton {
+    /// Create a button with label
+    pub fn new(label: impl Into<SharedString>) -> Self {
+        PromptButton::Other(label.into())
+    }
+
+    /// Create an Ok button
+    pub fn ok(label: impl Into<SharedString>) -> Self {
+        PromptButton::Ok(label.into())
+    }
+
+    /// Create a Cancel button
+    pub fn cancel(label: impl Into<SharedString>) -> Self {
+        PromptButton::Cancel(label.into())
+    }
+
+    #[allow(dead_code)]
+    pub(crate) fn is_cancel(&self) -> bool {
+        matches!(self, PromptButton::Cancel(_))
+    }
+
+    /// Returns the label of the button
+    pub fn label(&self) -> &SharedString {
+        match self {
+            PromptButton::Ok(label) => label,
+            PromptButton::Cancel(label) => label,
+            PromptButton::Other(label) => label,
+        }
+    }
+}
+
+impl From<&str> for PromptButton {
+    fn from(value: &str) -> Self {
+        match value.to_lowercase().as_str() {
+            "ok" => PromptButton::Ok("Ok".into()),
+            "cancel" => PromptButton::Cancel("Cancel".into()),
+            _ => PromptButton::Other(SharedString::from(value.to_owned())),
+        }
+    }
+}
+
 /// The style of the cursor (pointer)
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
 pub enum CursorStyle {
     /// The default cursor
     Arrow,

crates/gpui/src/platform/blade/apple_compat.rs 🔗

@@ -29,14 +29,14 @@ pub unsafe fn new_renderer(
     }
 
     impl rwh::HasWindowHandle for RawWindow {
-        fn window_handle(&self) -> Result<rwh::WindowHandle, rwh::HandleError> {
+        fn window_handle(&self) -> Result<rwh::WindowHandle<'_>, rwh::HandleError> {
             let view = NonNull::new(self.view).unwrap();
             let handle = rwh::AppKitWindowHandle::new(view);
             Ok(unsafe { rwh::WindowHandle::borrow_raw(handle.into()) })
         }
     }
     impl rwh::HasDisplayHandle for RawWindow {
-        fn display_handle(&self) -> Result<rwh::DisplayHandle, rwh::HandleError> {
+        fn display_handle(&self) -> Result<rwh::DisplayHandle<'_>, rwh::HandleError> {
             let handle = rwh::AppKitDisplayHandle::new();
             Ok(unsafe { rwh::DisplayHandle::borrow_raw(handle.into()) })
         }

crates/gpui/src/platform/blade/blade_context.rs 🔗

@@ -30,7 +30,7 @@ impl BladeContext {
                     ..Default::default()
                 })
             }
-            .map_err(|e| anyhow::anyhow!("{:?}", e))?,
+            .map_err(|e| anyhow::anyhow!("{e:?}"))?,
         );
         Ok(Self { gpu })
     }
@@ -49,8 +49,7 @@ fn parse_pci_id(id: &str) -> anyhow::Result<u32> {
         "Expected a 4 digit PCI ID in hexadecimal format"
     );
 
-    return u32::from_str_radix(id, 16)
-        .map_err(|_| anyhow::anyhow!("Failed to parse PCI ID as hex"));
+    return u32::from_str_radix(id, 16).context("parsing PCI ID as hex");
 }
 
 #[cfg(test)]

crates/gpui/src/platform/blade/blade_renderer.rs 🔗

@@ -342,7 +342,7 @@ impl BladeRenderer {
         let surface = context
             .gpu
             .create_surface_configured(window, surface_config)
-            .unwrap();
+            .map_err(|err| anyhow::anyhow!("Failed to create surface: {err:?}"))?;
 
         let command_encoder = context.gpu.create_command_encoder(gpu::CommandEncoderDesc {
             name: "main",
@@ -421,7 +421,10 @@ impl BladeRenderer {
     /// Like `update_drawable_size` but skips the check that the size has changed. This is useful in
     /// cases like restoring a window from minimization where the size is the same but the
     /// renderer's swap chain needs to be recreated.
-    #[cfg_attr(any(target_os = "macos", target_os = "linux"), allow(dead_code))]
+    #[cfg_attr(
+        any(target_os = "macos", target_os = "linux", target_os = "freebsd"),
+        allow(dead_code)
+    )]
     pub fn update_drawable_size_even_if_unchanged(&mut self, size: Size<DevicePixels>) {
         self.update_drawable_size_impl(size, true);
     }

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

@@ -56,6 +56,7 @@ impl Keystroke {
     /// This method assumes that `self` was typed and `target' is in the keymap, and checks
     /// both possibilities for self against the target.
     pub(crate) fn should_match(&self, target: &Keystroke) -> bool {
+        #[cfg(not(target_os = "windows"))]
         if let Some(key_char) = self
             .key_char
             .as_ref()
@@ -72,6 +73,18 @@ impl Keystroke {
             }
         }
 
+        #[cfg(target_os = "windows")]
+        if let Some(key_char) = self
+            .key_char
+            .as_ref()
+            .filter(|key_char| key_char != &&self.key)
+        {
+            // On Windows, if key_char is set, then the typed keystroke produced the key_char
+            if &target.key == key_char && target.modifiers == Modifiers::none() {
+                return true;
+            }
+        }
+
         target.modifiers == self.modifiers && target.key == self.key
     }
 
@@ -81,37 +94,33 @@ impl Keystroke {
     /// secondary means "cmd" on macOS and "ctrl" on other platforms
     /// when matching a key with an key_char set will be matched without it.
     pub fn parse(source: &str) -> std::result::Result<Self, InvalidKeystrokeError> {
-        let mut control = false;
-        let mut alt = false;
-        let mut shift = false;
-        let mut platform = false;
-        let mut function = false;
+        let mut modifiers = Modifiers::none();
         let mut key = None;
         let mut key_char = None;
 
         let mut components = source.split('-').peekable();
         while let Some(component) = components.next() {
             if component.eq_ignore_ascii_case("ctrl") {
-                control = true;
+                modifiers.control = true;
                 continue;
             }
             if component.eq_ignore_ascii_case("alt") {
-                alt = true;
+                modifiers.alt = true;
                 continue;
             }
             if component.eq_ignore_ascii_case("shift") {
-                shift = true;
+                modifiers.shift = true;
                 continue;
             }
             if component.eq_ignore_ascii_case("fn") {
-                function = true;
+                modifiers.function = true;
                 continue;
             }
             if component.eq_ignore_ascii_case("secondary") {
                 if cfg!(target_os = "macos") {
-                    platform = true;
+                    modifiers.platform = true;
                 } else {
-                    control = true;
+                    modifiers.control = true;
                 };
                 continue;
             }
@@ -121,7 +130,7 @@ impl Keystroke {
                 || component.eq_ignore_ascii_case("win");
 
             if is_platform {
-                platform = true;
+                modifiers.platform = true;
                 continue;
             }
 
@@ -145,7 +154,7 @@ impl Keystroke {
 
             if component.len() == 1 && component.as_bytes()[0].is_ascii_uppercase() {
                 // Convert to shift + lowercase char
-                shift = true;
+                modifiers.shift = true;
                 key_str.make_ascii_lowercase();
             } else {
                 // convert ascii chars to lowercase so that named keys like "tab" and "enter"
@@ -157,37 +166,30 @@ impl Keystroke {
 
         // Allow for the user to specify a keystroke modifier as the key itself
         // This sets the `key` to the modifier, and disables the modifier
-        if key.is_none() {
-            if shift {
-                key = Some("shift".to_string());
-                shift = false;
-            } else if control {
-                key = Some("control".to_string());
-                control = false;
-            } else if alt {
-                key = Some("alt".to_string());
-                alt = false;
-            } else if platform {
-                key = Some("platform".to_string());
-                platform = false;
-            } else if function {
-                key = Some("function".to_string());
-                function = false;
+        key = key.or_else(|| {
+            use std::mem;
+            // std::mem::take clears bool incase its true
+            if mem::take(&mut modifiers.shift) {
+                Some("shift".to_string())
+            } else if mem::take(&mut modifiers.control) {
+                Some("control".to_string())
+            } else if mem::take(&mut modifiers.alt) {
+                Some("alt".to_string())
+            } else if mem::take(&mut modifiers.platform) {
+                Some("platform".to_string())
+            } else if mem::take(&mut modifiers.function) {
+                Some("function".to_string())
+            } else {
+                None
             }
-        }
+        });
 
         let key = key.ok_or_else(|| InvalidKeystrokeError {
             keystroke: source.to_owned(),
         })?;
 
         Ok(Keystroke {
-            modifiers: Modifiers {
-                control,
-                alt,
-                shift,
-                platform,
-                function,
-            },
+            modifiers,
             key,
             key_char,
         })
@@ -318,18 +320,18 @@ fn is_printable_key(key: &str) -> bool {
 impl std::fmt::Display for Keystroke {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         if self.modifiers.control {
-            if cfg!(target_os = "macos") {
-                f.write_char('^')?;
-            } else {
-                write!(f, "ctrl-")?;
-            }
+            #[cfg(target_os = "macos")]
+            f.write_char('^')?;
+
+            #[cfg(not(target_os = "macos"))]
+            write!(f, "ctrl-")?;
         }
         if self.modifiers.alt {
-            if cfg!(target_os = "macos") {
-                f.write_char('⌥')?;
-            } else {
-                write!(f, "alt-")?;
-            }
+            #[cfg(target_os = "macos")]
+            f.write_char('⌥')?;
+
+            #[cfg(not(target_os = "macos"))]
+            write!(f, "alt-")?;
         }
         if self.modifiers.platform {
             #[cfg(target_os = "macos")]
@@ -342,31 +344,38 @@ impl std::fmt::Display for Keystroke {
             f.write_char('⊞')?;
         }
         if self.modifiers.shift {
-            if cfg!(target_os = "macos") {
-                f.write_char('⇧')?;
-            } else {
-                write!(f, "shift-")?;
-            }
+            #[cfg(target_os = "macos")]
+            f.write_char('⇧')?;
+
+            #[cfg(not(target_os = "macos"))]
+            write!(f, "shift-")?;
         }
         let key = match self.key.as_str() {
-            "backspace" if cfg!(target_os = "macos") => '⌫',
-            "up" if cfg!(target_os = "macos") => '↑',
-            "down" if cfg!(target_os = "macos") => '↓',
-            "left" if cfg!(target_os = "macos") => '←',
-            "right" if cfg!(target_os = "macos") => '→',
-            "tab" if cfg!(target_os = "macos") => '⇥',
-            "escape" if cfg!(target_os = "macos") => '⎋',
-            "shift" if cfg!(target_os = "macos") => '⇧',
-            "control" if cfg!(target_os = "macos") => '⌃',
-            "alt" if cfg!(target_os = "macos") => '⌥',
-            "platform" if cfg!(target_os = "macos") => '⌘',
-            key => {
-                if key.len() == 1 {
-                    key.chars().next().unwrap().to_ascii_uppercase()
-                } else {
-                    return f.write_str(key);
-                }
-            }
+            #[cfg(target_os = "macos")]
+            "backspace" => '⌫',
+            #[cfg(target_os = "macos")]
+            "up" => '↑',
+            #[cfg(target_os = "macos")]
+            "down" => '↓',
+            #[cfg(target_os = "macos")]
+            "left" => '←',
+            #[cfg(target_os = "macos")]
+            "right" => '→',
+            #[cfg(target_os = "macos")]
+            "tab" => '⇥',
+            #[cfg(target_os = "macos")]
+            "escape" => '⎋',
+            #[cfg(target_os = "macos")]
+            "shift" => '⇧',
+            #[cfg(target_os = "macos")]
+            "control" => '⌃',
+            #[cfg(target_os = "macos")]
+            "alt" => '⌥',
+            #[cfg(target_os = "macos")]
+            "platform" => '⌘',
+
+            key if key.len() == 1 => key.chars().next().unwrap().to_ascii_uppercase(),
+            key => return f.write_str(key),
         };
         f.write_char(key)
     }
@@ -529,3 +538,11 @@ impl Modifiers {
             && (other.function || !self.function)
     }
 }
+
+/// The state of the capslock key at some point in time
+#[derive(Copy, Clone, Debug, Eq, PartialEq, Default, Serialize, Deserialize, Hash, JsonSchema)]
+pub struct Capslock {
+    /// The capslock key is on
+    #[serde(default)]
+    pub on: bool,
+}

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

@@ -52,7 +52,7 @@ impl LinuxClient for HeadlessClient {
     }
 
     fn keyboard_layout(&self) -> Box<dyn PlatformKeyboardLayout> {
-        Box::new(LinuxKeyboardLayout::new("unknown".to_string()))
+        Box::new(LinuxKeyboardLayout::new("unknown".into()))
     }
 
     fn displays(&self) -> Vec<Rc<dyn PlatformDisplay>> {
@@ -95,9 +95,7 @@ impl LinuxClient for HeadlessClient {
         _handle: AnyWindowHandle,
         _params: WindowParams,
     ) -> anyhow::Result<Box<dyn PlatformWindow>> {
-        Err(anyhow::anyhow!(
-            "neither DISPLAY nor WAYLAND_DISPLAY is set. You can run in headless mode"
-        ))
+        anyhow::bail!("neither DISPLAY nor WAYLAND_DISPLAY is set. You can run in headless mode");
     }
 
     fn compositor_name(&self) -> &'static str {

crates/gpui/src/platform/linux/keyboard.rs 🔗

@@ -1,21 +1,22 @@
-use crate::PlatformKeyboardLayout;
+use crate::{PlatformKeyboardLayout, SharedString};
 
+#[derive(Clone)]
 pub(crate) struct LinuxKeyboardLayout {
-    id: String,
+    name: SharedString,
 }
 
 impl PlatformKeyboardLayout for LinuxKeyboardLayout {
     fn id(&self) -> &str {
-        &self.id
+        &self.name
     }
 
     fn name(&self) -> &str {
-        &self.id
+        &self.name
     }
 }
 
 impl LinuxKeyboardLayout {
-    pub(crate) fn new(id: String) -> Self {
-        Self { id }
+    pub(crate) fn new(name: SharedString) -> Self {
+        Self { name }
     }
 }

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

@@ -426,8 +426,8 @@ impl<P: LinuxClient + 'static> Platform for P {
 
     fn app_path(&self) -> Result<PathBuf> {
         // get the path of the executable of the current process
-        let exe_path = env::current_exe()?;
-        Ok(exe_path)
+        let app_path = env::current_exe()?;
+        return Ok(app_path);
     }
 
     fn set_menus(&self, menus: Vec<Menu>, _keymap: &Keymap) {
@@ -490,7 +490,8 @@ impl<P: LinuxClient + 'static> Platform for P {
                     let attributes = item.attributes().await?;
                     let username = attributes
                         .get("username")
-                        .ok_or_else(|| anyhow!("Cannot find username in stored credentials"))?;
+                        .context("Cannot find username in stored credentials")?;
+                    item.unlock().await?;
                     let secret = item.secret().await?;
 
                     // we lose the zeroizing capabilities at this boundary,
@@ -647,42 +648,57 @@ pub(super) unsafe fn read_fd(mut fd: filedescriptor::FileDescriptor) -> Result<V
     Ok(buffer)
 }
 
+#[cfg(any(feature = "wayland", feature = "x11"))]
+pub(super) const DEFAULT_CURSOR_ICON_NAME: &str = "left_ptr";
+
 impl CursorStyle {
-    #[allow(unused)]
-    pub(super) fn to_icon_name(&self) -> String {
-        // Based on cursor names from https://gitlab.gnome.org/GNOME/adwaita-icon-theme (GNOME)
-        // and https://github.com/KDE/breeze (KDE). Both of them seem to be also derived from
-        // Web CSS cursor names: https://developer.mozilla.org/en-US/docs/Web/CSS/cursor#values
+    #[cfg(any(feature = "wayland", feature = "x11"))]
+    pub(super) fn to_icon_names(&self) -> &'static [&'static str] {
+        // Based on cursor names from chromium:
+        // https://github.com/chromium/chromium/blob/d3069cf9c973dc3627fa75f64085c6a86c8f41bf/ui/base/cursor/cursor_factory.cc#L113
         match self {
-            CursorStyle::Arrow => "left_ptr",
-            CursorStyle::IBeam => "text",
-            CursorStyle::Crosshair => "crosshair",
-            CursorStyle::ClosedHand => "grabbing",
-            CursorStyle::OpenHand => "grab",
-            CursorStyle::PointingHand => "pointer",
-            CursorStyle::ResizeLeft => "w-resize",
-            CursorStyle::ResizeRight => "e-resize",
-            CursorStyle::ResizeLeftRight => "ew-resize",
-            CursorStyle::ResizeUp => "n-resize",
-            CursorStyle::ResizeDown => "s-resize",
-            CursorStyle::ResizeUpDown => "ns-resize",
-            CursorStyle::ResizeUpLeftDownRight => "nwse-resize",
-            CursorStyle::ResizeUpRightDownLeft => "nesw-resize",
-            CursorStyle::ResizeColumn => "col-resize",
-            CursorStyle::ResizeRow => "row-resize",
-            CursorStyle::IBeamCursorForVerticalLayout => "vertical-text",
-            CursorStyle::OperationNotAllowed => "not-allowed",
-            CursorStyle::DragLink => "alias",
-            CursorStyle::DragCopy => "copy",
-            CursorStyle::ContextualMenu => "context-menu",
+            CursorStyle::Arrow => &[DEFAULT_CURSOR_ICON_NAME],
+            CursorStyle::IBeam => &["text", "xterm"],
+            CursorStyle::Crosshair => &["crosshair", "cross"],
+            CursorStyle::ClosedHand => &["closedhand", "grabbing", "hand2"],
+            CursorStyle::OpenHand => &["openhand", "grab", "hand1"],
+            CursorStyle::PointingHand => &["pointer", "hand", "hand2"],
+            CursorStyle::ResizeLeft => &["w-resize", "left_side"],
+            CursorStyle::ResizeRight => &["e-resize", "right_side"],
+            CursorStyle::ResizeLeftRight => &["ew-resize", "sb_h_double_arrow"],
+            CursorStyle::ResizeUp => &["n-resize", "top_side"],
+            CursorStyle::ResizeDown => &["s-resize", "bottom_side"],
+            CursorStyle::ResizeUpDown => &["sb_v_double_arrow", "ns-resize"],
+            CursorStyle::ResizeUpLeftDownRight => &["size_fdiag", "bd_double_arrow", "nwse-resize"],
+            CursorStyle::ResizeUpRightDownLeft => &["size_bdiag", "nesw-resize", "fd_double_arrow"],
+            CursorStyle::ResizeColumn => &["col-resize", "sb_h_double_arrow"],
+            CursorStyle::ResizeRow => &["row-resize", "sb_v_double_arrow"],
+            CursorStyle::IBeamCursorForVerticalLayout => &["vertical-text"],
+            CursorStyle::OperationNotAllowed => &["not-allowed", "crossed_circle"],
+            CursorStyle::DragLink => &["alias"],
+            CursorStyle::DragCopy => &["copy"],
+            CursorStyle::ContextualMenu => &["context-menu"],
             CursorStyle::None => {
                 #[cfg(debug_assertions)]
                 panic!("CursorStyle::None should be handled separately in the client");
                 #[cfg(not(debug_assertions))]
-                "default"
+                &[DEFAULT_CURSOR_ICON_NAME]
             }
         }
-        .to_string()
+    }
+}
+
+#[cfg(any(feature = "wayland", feature = "x11"))]
+pub(super) fn log_cursor_icon_warning(message: impl std::fmt::Display) {
+    if let Ok(xcursor_path) = env::var("XCURSOR_PATH") {
+        log::warn!(
+            "{:#}\ncursor icon loading may be failing if XCURSOR_PATH environment variable is invalid. \
+                    XCURSOR_PATH overrides the default icon search. Its current value is '{}'",
+            message,
+            xcursor_path
+        );
+    } else {
+        log::warn!("{:#}", message);
     }
 }
 
@@ -858,6 +874,14 @@ impl crate::Modifiers {
     }
 }
 
+#[cfg(any(feature = "wayland", feature = "x11"))]
+impl crate::Capslock {
+    pub(super) fn from_xkb(keymap_state: &State) -> Self {
+        let on = keymap_state.mod_name_is_active(xkb::MOD_NAME_CAPS, xkb::STATE_MODS_EFFECTIVE);
+        Self { on }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

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

@@ -1,9 +1,9 @@
 use crate::{
     Bounds, DevicePixels, Font, FontFeatures, FontId, FontMetrics, FontRun, FontStyle, FontWeight,
     GlyphId, LineLayout, Pixels, PlatformTextSystem, Point, RenderGlyphParams, SUBPIXEL_VARIANTS,
-    ShapedGlyph, SharedString, Size, point, size,
+    ShapedGlyph, ShapedRun, SharedString, Size, point, size,
 };
-use anyhow::{Context as _, Ok, Result, anyhow};
+use anyhow::{Context as _, Ok, Result};
 use collections::HashMap;
 use cosmic_text::{
     Attrs, AttrsList, CacheKey, Family, Font as CosmicTextFont, FontFeatures as CosmicFontFeatures,
@@ -38,14 +38,16 @@ struct CosmicTextSystemState {
     font_system: FontSystem,
     scratch: ShapeBuffer,
     /// Contains all already loaded fonts, including all faces. Indexed by `FontId`.
-    loaded_fonts_store: Vec<Arc<CosmicTextFont>>,
-    /// Contains enabled font features for each loaded font.
-    features_store: Vec<CosmicFontFeatures>,
+    loaded_fonts: Vec<LoadedFont>,
     /// Caches the `FontId`s associated with a specific family to avoid iterating the font database
     /// for every font face in a family.
     font_ids_by_family_cache: HashMap<FontKey, SmallVec<[FontId; 4]>>,
-    /// The name of each font associated with the given font id
-    postscript_names: HashMap<FontId, String>,
+}
+
+struct LoadedFont {
+    font: Arc<CosmicTextFont>,
+    features: CosmicFontFeatures,
+    is_known_emoji_font: bool,
 }
 
 impl CosmicTextSystem {
@@ -57,10 +59,8 @@ impl CosmicTextSystem {
             font_system,
             swash_cache: SwashCache::new(),
             scratch: ShapeBuffer::default(),
-            loaded_fonts_store: Vec::new(),
-            features_store: Vec::new(),
+            loaded_fonts: Vec::new(),
             font_ids_by_family_cache: HashMap::default(),
-            postscript_names: HashMap::default(),
         }))
     }
 }
@@ -106,7 +106,7 @@ impl PlatformTextSystem for CosmicTextSystem {
         let candidate_properties = candidates
             .iter()
             .map(|font_id| {
-                let database_id = state.loaded_fonts_store[font_id.0].id();
+                let database_id = state.loaded_font(*font_id).font.id();
                 let face_info = state.font_system.db().face(database_id).expect("");
                 face_info_into_properties(face_info)
             })
@@ -120,7 +120,11 @@ impl PlatformTextSystem for CosmicTextSystem {
     }
 
     fn font_metrics(&self, font_id: FontId) -> FontMetrics {
-        let metrics = self.0.read().loaded_fonts_store[font_id.0]
+        let metrics = self
+            .0
+            .read()
+            .loaded_font(font_id)
+            .font
             .as_swash()
             .metrics(&[]);
 
@@ -143,9 +147,7 @@ impl PlatformTextSystem for CosmicTextSystem {
 
     fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result<Bounds<f32>> {
         let lock = self.0.read();
-        let glyph_metrics = lock.loaded_fonts_store[font_id.0]
-            .as_swash()
-            .glyph_metrics(&[]);
+        let glyph_metrics = lock.loaded_font(font_id).font.as_swash().glyph_metrics(&[]);
         let glyph_id = glyph_id.0 as u16;
         // todo(linux): Compute this correctly
         // see https://github.com/servo/font-kit/blob/master/src/loaders/freetype.rs#L614-L620
@@ -184,6 +186,10 @@ impl PlatformTextSystem for CosmicTextSystem {
 }
 
 impl CosmicTextSystemState {
+    fn loaded_font(&self, font_id: FontId) -> &LoadedFont {
+        &self.loaded_fonts[font_id.0]
+    }
+
     #[profiling::function]
     fn add_fonts(&mut self, fonts: Vec<Cow<'static, [u8]>>) -> Result<()> {
         let db = self.font_system.db_mut();
@@ -200,12 +206,11 @@ impl CosmicTextSystemState {
         Ok(())
     }
 
-    // todo(linux) handle `FontFeatures`
     #[profiling::function]
     fn load_family(
         &mut self,
         name: &str,
-        _features: &FontFeatures,
+        features: &FontFeatures,
     ) -> Result<SmallVec<[FontId; 4]>> {
         // TODO: Determine the proper system UI font.
         let name = if name == ".SystemUIFont" {
@@ -214,7 +219,6 @@ impl CosmicTextSystemState {
             name
         };
 
-        let mut font_ids = SmallVec::new();
         let families = self
             .font_system
             .db()
@@ -223,11 +227,12 @@ impl CosmicTextSystemState {
             .map(|face| (face.id, face.post_script_name.clone()))
             .collect::<SmallVec<[_; 4]>>();
 
+        let mut loaded_font_ids = SmallVec::new();
         for (font_id, postscript_name) in families {
             let font = self
                 .font_system
                 .get_font(font_id)
-                .ok_or_else(|| anyhow!("Could not load font"))?;
+                .context("Could not load font")?;
 
             // HACK: To let the storybook run and render Windows caption icons. We should actually do better font fallback.
             let allowed_bad_font_names = [
@@ -242,47 +247,28 @@ impl CosmicTextSystemState {
                 continue;
             };
 
-            // Convert features into cosmic_text struct.
-            let mut font_features = CosmicFontFeatures::new();
-            for feature in _features.0.iter() {
-                let name_bytes: [u8; 4] = feature
-                    .0
-                    .as_bytes()
-                    .try_into()
-                    .map_err(|_| anyhow!("Incorrect feature flag format"))?;
-
-                let tag = cosmic_text::FeatureTag::new(&name_bytes);
-
-                font_features.set(tag, feature.1);
-            }
-
-            let font_id = FontId(self.loaded_fonts_store.len());
-            font_ids.push(font_id);
-            self.loaded_fonts_store.push(font);
-            self.features_store.push(font_features);
-            self.postscript_names.insert(font_id, postscript_name);
+            let font_id = FontId(self.loaded_fonts.len());
+            loaded_font_ids.push(font_id);
+            self.loaded_fonts.push(LoadedFont {
+                font,
+                features: features.try_into()?,
+                is_known_emoji_font: check_is_known_emoji_font(&postscript_name),
+            });
         }
 
-        Ok(font_ids)
+        Ok(loaded_font_ids)
     }
 
     fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result<Size<f32>> {
-        let width = self.loaded_fonts_store[font_id.0]
-            .as_swash()
-            .glyph_metrics(&[])
-            .advance_width(glyph_id.0 as u16);
-        let height = self.loaded_fonts_store[font_id.0]
-            .as_swash()
-            .glyph_metrics(&[])
-            .advance_height(glyph_id.0 as u16);
-        Ok(Size { width, height })
+        let glyph_metrics = self.loaded_font(font_id).font.as_swash().glyph_metrics(&[]);
+        Ok(Size {
+            width: glyph_metrics.advance_width(glyph_id.0 as u16),
+            height: glyph_metrics.advance_height(glyph_id.0 as u16),
+        })
     }
 
     fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option<GlyphId> {
-        let glyph_id = self.loaded_fonts_store[font_id.0]
-            .as_swash()
-            .charmap()
-            .map(ch);
+        let glyph_id = self.loaded_font(font_id).font.as_swash().charmap().map(ch);
         if glyph_id == 0 {
             None
         } else {
@@ -290,15 +276,11 @@ impl CosmicTextSystemState {
         }
     }
 
-    fn is_emoji(&self, font_id: FontId) -> bool {
-        // TODO: Include other common emoji fonts
-        self.postscript_names
-            .get(&font_id)
-            .map_or(false, |postscript_name| postscript_name == "NotoColorEmoji")
-    }
-
     fn raster_bounds(&mut self, params: &RenderGlyphParams) -> Result<Bounds<DevicePixels>> {
-        let font = &self.loaded_fonts_store[params.font_id.0];
+        let font = &self.loaded_fonts[params.font_id.0].font;
+        let subpixel_shift = params
+            .subpixel_variant
+            .map(|v| v as f32 / (SUBPIXEL_VARIANTS as f32 * params.scale_factor));
         let image = self
             .swash_cache
             .get_image(
@@ -307,7 +289,7 @@ impl CosmicTextSystemState {
                     font.id(),
                     params.glyph_id.0 as u16,
                     (params.font_size * params.scale_factor).into(),
-                    (0.0, 0.0),
+                    (subpixel_shift.x, subpixel_shift.y.trunc()),
                     cosmic_text::CacheKeyFlags::empty(),
                 )
                 .0,
@@ -327,10 +309,10 @@ impl CosmicTextSystemState {
         glyph_bounds: Bounds<DevicePixels>,
     ) -> Result<(Size<DevicePixels>, Vec<u8>)> {
         if glyph_bounds.size.width.0 == 0 || glyph_bounds.size.height.0 == 0 {
-            Err(anyhow!("glyph bounds are empty"))
+            anyhow::bail!("glyph bounds are empty");
         } else {
             let bitmap_size = glyph_bounds.size;
-            let font = &self.loaded_fonts_store[params.font_id.0];
+            let font = &self.loaded_fonts[params.font_id.0].font;
             let subpixel_shift = params
                 .subpixel_variant
                 .map(|v| v as f32 / (SUBPIXEL_VARIANTS as f32 * params.scale_factor));
@@ -361,27 +343,31 @@ impl CosmicTextSystemState {
         }
     }
 
+    /// This is used when cosmic_text has chosen a fallback font instead of using the requested
+    /// font, typically to handle some unicode characters. When this happens, `loaded_fonts` may not
+    /// yet have an entry for this fallback font, and so one is added.
+    ///
+    /// Note that callers shouldn't use this `FontId` somewhere that will retrieve the corresponding
+    /// `LoadedFont.features`, as it will have an arbitrarily chosen or empty value. The only
+    /// current use of this field is for the *input* of `layout_line`, and so it's fine to use
+    /// `font_id_for_cosmic_id` when computing the *output* of `layout_line`.
     fn font_id_for_cosmic_id(&mut self, id: cosmic_text::fontdb::ID) -> FontId {
         if let Some(ix) = self
-            .loaded_fonts_store
+            .loaded_fonts
             .iter()
-            .position(|font| font.id() == id)
+            .position(|loaded_font| loaded_font.font.id() == id)
         {
             FontId(ix)
         } else {
-            // This matches the behavior of the mac text system
             let font = self.font_system.get_font(id).unwrap();
-            let face = self
-                .font_system
-                .db()
-                .faces()
-                .find(|info| info.id == id)
-                .unwrap();
+            let face = self.font_system.db().face(id).unwrap();
 
-            let font_id = FontId(self.loaded_fonts_store.len());
-            self.loaded_fonts_store.push(font);
-            self.postscript_names
-                .insert(font_id, face.post_script_name.clone());
+            let font_id = FontId(self.loaded_fonts.len());
+            self.loaded_fonts.push(LoadedFont {
+                font,
+                features: CosmicFontFeatures::new(),
+                is_known_emoji_font: check_is_known_emoji_font(&face.post_script_name),
+            });
 
             font_id
         }
@@ -392,62 +378,74 @@ impl CosmicTextSystemState {
         let mut attrs_list = AttrsList::new(&Attrs::new());
         let mut offs = 0;
         for run in font_runs {
-            let font = &self.loaded_fonts_store[run.font_id.0];
-            let font = self.font_system.db().face(font.id()).unwrap();
-
-            let features = self.features_store[run.font_id.0].clone();
+            let loaded_font = self.loaded_font(run.font_id);
+            let font = self.font_system.db().face(loaded_font.font.id()).unwrap();
 
             attrs_list.add_span(
                 offs..(offs + run.len),
                 &Attrs::new()
+                    .metadata(run.font_id.0)
                     .family(Family::Name(&font.families.first().unwrap().0))
                     .stretch(font.stretch)
                     .style(font.style)
                     .weight(font.weight)
-                    .font_features(features),
+                    .font_features(loaded_font.features.clone()),
             );
             offs += run.len;
         }
-        let mut line = ShapeLine::new(
+
+        let line = ShapeLine::new(
             &mut self.font_system,
             text,
             &attrs_list,
             cosmic_text::Shaping::Advanced,
             4,
         );
-        let mut layout = Vec::with_capacity(1);
+        let mut layout_lines = Vec::with_capacity(1);
         line.layout_to_buffer(
             &mut self.scratch,
             font_size.0,
             None, // We do our own wrapping
             cosmic_text::Wrap::None,
             None,
-            &mut layout,
+            &mut layout_lines,
             None,
         );
+        let layout = layout_lines.first().unwrap();
 
-        let mut runs = Vec::new();
-        let layout = layout.first().unwrap();
+        let mut runs: Vec<ShapedRun> = Vec::new();
         for glyph in &layout.glyphs {
-            let font_id = glyph.font_id;
-            let font_id = self.font_id_for_cosmic_id(font_id);
-            let is_emoji = self.is_emoji(font_id);
-            let mut glyphs = SmallVec::new();
+            let mut font_id = FontId(glyph.metadata);
+            let mut loaded_font = self.loaded_font(font_id);
+            if loaded_font.font.id() != glyph.font_id {
+                font_id = self.font_id_for_cosmic_id(glyph.font_id);
+                loaded_font = self.loaded_font(font_id);
+            }
+            let is_emoji = loaded_font.is_known_emoji_font;
 
             // HACK: Prevent crash caused by variation selectors.
             if glyph.glyph_id == 3 && is_emoji {
                 continue;
             }
 
-            // todo(linux) this is definitely wrong, each glyph in glyphs from cosmic-text is a cluster with one glyph, ShapedRun takes a run of glyphs with the same font and direction
-            glyphs.push(ShapedGlyph {
+            let shaped_glyph = ShapedGlyph {
                 id: GlyphId(glyph.glyph_id as u32),
                 position: point(glyph.x.into(), glyph.y.into()),
                 index: glyph.start,
                 is_emoji,
-            });
+            };
 
-            runs.push(crate::ShapedRun { font_id, glyphs });
+            if let Some(last_run) = runs
+                .last_mut()
+                .filter(|last_run| last_run.font_id == font_id)
+            {
+                last_run.glyphs.push(shaped_glyph);
+            } else {
+                runs.push(ShapedRun {
+                    font_id,
+                    glyphs: vec![shaped_glyph],
+                });
+            }
         }
 
         LineLayout {
@@ -461,6 +459,26 @@ impl CosmicTextSystemState {
     }
 }
 
+impl TryFrom<&FontFeatures> for CosmicFontFeatures {
+    type Error = anyhow::Error;
+
+    fn try_from(features: &FontFeatures) -> Result<Self> {
+        let mut result = CosmicFontFeatures::new();
+        for feature in features.0.iter() {
+            let name_bytes: [u8; 4] = feature
+                .0
+                .as_bytes()
+                .try_into()
+                .context("Incorrect feature flag format")?;
+
+            let tag = cosmic_text::FeatureTag::new(&name_bytes);
+
+            result.set(tag, feature.1);
+        }
+        Ok(result)
+    }
+}
+
 impl From<RectF> for Bounds<f32> {
     fn from(rect: RectF) -> Self {
         Bounds {
@@ -558,3 +576,8 @@ fn face_info_into_properties(
         },
     }
 }
+
+fn check_is_known_emoji_font(postscript_name: &str) -> bool {
+    // TODO: Include other common emoji fonts
+    postscript_name == "NotoColorEmoji"
+}

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

@@ -71,30 +71,35 @@ use super::{
     window::{ImeInput, WaylandWindowStatePtr},
 };
 
-use crate::platform::linux::{
-    LinuxClient, get_xkb_compose_state, is_within_click_distance, open_uri_internal, read_fd,
-    reveal_path_internal,
-    wayland::{
-        clipboard::{Clipboard, DataOffer, FILE_LIST_MIME_TYPE, TEXT_MIME_TYPE},
-        cursor::Cursor,
-        serial::{SerialKind, SerialTracker},
-        window::WaylandWindow,
-    },
-    xdg_desktop_portal::{Event as XDPEvent, XDPEventSource},
-};
 use crate::platform::{PlatformWindow, blade::BladeContext};
 use crate::{
-    AnyWindowHandle, Bounds, CursorStyle, DOUBLE_CLICK_INTERVAL, DevicePixels, DisplayId,
+    AnyWindowHandle, Bounds, Capslock, CursorStyle, DOUBLE_CLICK_INTERVAL, DevicePixels, DisplayId,
     FileDropEvent, ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, LinuxCommon,
     LinuxKeyboardLayout, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent,
     MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay,
     PlatformInput, PlatformKeyboardLayout, Point, SCROLL_LINES, ScaledPixels, ScreenCaptureSource,
     ScrollDelta, ScrollWheelEvent, Size, TouchPhase, WindowParams, point, px, size,
 };
+use crate::{
+    SharedString,
+    platform::linux::{
+        LinuxClient, get_xkb_compose_state, is_within_click_distance, open_uri_internal, read_fd,
+        reveal_path_internal,
+        wayland::{
+            clipboard::{Clipboard, DataOffer, FILE_LIST_MIME_TYPE, TEXT_MIME_TYPES},
+            cursor::Cursor,
+            serial::{SerialKind, SerialTracker},
+            window::WaylandWindow,
+        },
+        xdg_desktop_portal::{Event as XDPEvent, XDPEventSource},
+    },
+};
 
 /// Used to convert evdev scancode to xkb scancode
 const MIN_KEYCODE: u32 = 8;
 
+const UNKNOWN_KEYBOARD_LAYOUT_NAME: SharedString = SharedString::new_static("unknown");
+
 #[derive(Clone)]
 pub struct Globals {
     pub qh: QueueHandle<WaylandClientStatePtr>,
@@ -205,12 +210,14 @@ pub(crate) struct WaylandClientState {
     // Output to scale mapping
     outputs: HashMap<ObjectId, Output>,
     in_progress_outputs: HashMap<ObjectId, InProgressOutput>,
+    keyboard_layout: LinuxKeyboardLayout,
     keymap_state: Option<xkb::State>,
     compose_state: Option<xkb::compose::State>,
     drag: DragState,
     click: ClickState,
     repeat: KeyRepeat,
     pub modifiers: Modifiers,
+    pub capslock: Capslock,
     axis_source: AxisSource,
     pub mouse_location: Option<Point<Pixels>>,
     continuous_scroll_delta: Option<Point<Pixels>>,
@@ -335,6 +342,35 @@ impl WaylandClientStatePtr {
         text_input.commit();
     }
 
+    pub fn handle_keyboard_layout_change(&self) {
+        let client = self.get_client();
+        let mut state = client.borrow_mut();
+        let changed = if let Some(keymap_state) = &state.keymap_state {
+            let layout_idx = keymap_state.serialize_layout(xkbcommon::xkb::STATE_LAYOUT_EFFECTIVE);
+            let keymap = keymap_state.get_keymap();
+            let layout_name = keymap.layout_get_name(layout_idx);
+            let changed = layout_name != state.keyboard_layout.name();
+            if changed {
+                state.keyboard_layout = LinuxKeyboardLayout::new(layout_name.to_string().into());
+            }
+            changed
+        } else {
+            let changed = &UNKNOWN_KEYBOARD_LAYOUT_NAME != state.keyboard_layout.name();
+            if changed {
+                state.keyboard_layout = LinuxKeyboardLayout::new(UNKNOWN_KEYBOARD_LAYOUT_NAME);
+            }
+            changed
+        };
+        if changed {
+            if let Some(mut callback) = state.common.callbacks.keyboard_layout_change.take() {
+                drop(state);
+                callback();
+                state = client.borrow_mut();
+                state.common.callbacks.keyboard_layout_change = Some(callback);
+            }
+        }
+    }
+
     pub fn drop_window(&self, surface_id: &ObjectId) {
         let mut client = self.get_client();
         let mut state = client.borrow_mut();
@@ -502,7 +538,7 @@ impl WaylandClient {
                     XDPEvent::CursorTheme(theme) => {
                         if let Some(client) = client.0.upgrade() {
                             let mut client = client.borrow_mut();
-                            client.cursor.set_theme(theme.as_str());
+                            client.cursor.set_theme(theme);
                         }
                     }
                     XDPEvent::CursorSize(size) => {
@@ -533,6 +569,7 @@ impl WaylandClient {
             in_progress_outputs,
             windows: HashMap::default(),
             common,
+            keyboard_layout: LinuxKeyboardLayout::new(UNKNOWN_KEYBOARD_LAYOUT_NAME),
             keymap_state: None,
             compose_state: None,
             drag: DragState {
@@ -559,6 +596,7 @@ impl WaylandClient {
                 function: false,
                 platform: false,
             },
+            capslock: Capslock { on: false },
             scroll_event_received: false,
             axis_source: AxisSource::Wheel,
             mouse_location: None,
@@ -590,17 +628,7 @@ impl WaylandClient {
 
 impl LinuxClient for WaylandClient {
     fn keyboard_layout(&self) -> Box<dyn PlatformKeyboardLayout> {
-        let state = self.0.borrow();
-        let id = if let Some(keymap_state) = &state.keymap_state {
-            let layout_idx = keymap_state.serialize_layout(xkbcommon::xkb::STATE_LAYOUT_EFFECTIVE);
-            keymap_state
-                .get_keymap()
-                .layout_get_name(layout_idx)
-                .to_string()
-        } else {
-            "unknown".to_string()
-        };
-        Box::new(LinuxKeyboardLayout::new(id))
+        Box::new(self.0.borrow().keyboard_layout.clone())
     }
 
     fn displays(&self) -> Vec<Rc<dyn PlatformDisplay>> {
@@ -704,7 +732,7 @@ impl LinuxClient for WaylandClient {
                 let scale = focused_window.primary_output_scale();
                 state
                     .cursor
-                    .set_icon(&wl_pointer, serial, &style.to_icon_name(), scale);
+                    .set_icon(&wl_pointer, serial, style.to_icon_names(), scale);
             }
         }
     }
@@ -778,8 +806,10 @@ impl LinuxClient for WaylandClient {
             state.clipboard.set_primary(item);
             let serial = state.serial_tracker.get(SerialKind::KeyPress);
             let data_source = primary_selection_manager.create_source(&state.globals.qh, ());
+            for mime_type in TEXT_MIME_TYPES {
+                data_source.offer(mime_type.to_string());
+            }
             data_source.offer(state.clipboard.self_mime());
-            data_source.offer(TEXT_MIME_TYPE.to_string());
             primary_selection.set_selection(Some(&data_source), serial);
         }
     }
@@ -796,8 +826,10 @@ impl LinuxClient for WaylandClient {
             state.clipboard.set(item);
             let serial = state.serial_tracker.get(SerialKind::KeyPress);
             let data_source = data_device_manager.create_data_source(&state.globals.qh, ());
+            for mime_type in TEXT_MIME_TYPES {
+                data_source.offer(mime_type.to_string());
+            }
             data_source.offer(state.clipboard.self_mime());
-            data_source.offer(TEXT_MIME_TYPE.to_string());
             data_device.set_selection(Some(&data_source), serial);
         }
     }
@@ -1177,13 +1209,9 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
                 };
                 state.keymap_state = Some(xkb::State::new(&keymap));
                 state.compose_state = get_xkb_compose_state(&xkb_context);
+                drop(state);
 
-                if let Some(mut callback) = state.common.callbacks.keyboard_layout_change.take() {
-                    drop(state);
-                    callback();
-                    state = client.borrow_mut();
-                    state.common.callbacks.keyboard_layout_change = Some(callback);
-                }
+                this.handle_keyboard_layout_change();
             }
             wl_keyboard::Event::Enter { surface, .. } => {
                 state.keyboard_focused_window = get_window(&mut state, &surface.id());
@@ -1225,27 +1253,22 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
                     keymap_state.serialize_layout(xkbcommon::xkb::STATE_LAYOUT_EFFECTIVE);
                 keymap_state.update_mask(mods_depressed, mods_latched, mods_locked, 0, 0, group);
                 state.modifiers = Modifiers::from_xkb(keymap_state);
-
-                if group != old_layout {
-                    if let Some(mut callback) = state.common.callbacks.keyboard_layout_change.take()
-                    {
-                        drop(state);
-                        callback();
-                        state = client.borrow_mut();
-                        state.common.callbacks.keyboard_layout_change = Some(callback);
-                    }
-                }
-
-                let Some(focused_window) = focused_window else {
-                    return;
-                };
+                let keymap_state = state.keymap_state.as_mut().unwrap();
+                state.capslock = Capslock::from_xkb(keymap_state);
 
                 let input = PlatformInput::ModifiersChanged(ModifiersChangedEvent {
                     modifiers: state.modifiers,
+                    capslock: state.capslock,
                 });
-
                 drop(state);
-                focused_window.handle_input(input);
+
+                if let Some(focused_window) = focused_window {
+                    focused_window.handle_input(input);
+                }
+
+                if group != old_layout {
+                    this.handle_keyboard_layout_change();
+                }
             }
             wl_keyboard::Event::Key {
                 serial,
@@ -1370,6 +1393,7 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
         }
     }
 }
+
 impl Dispatch<zwp_text_input_v3::ZwpTextInputV3, ()> for WaylandClientStatePtr {
     fn event(
         this: &mut Self,
@@ -1514,7 +1538,7 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
                             state.cursor.set_icon(
                                 &wl_pointer,
                                 serial,
-                                &style.to_icon_name(),
+                                style.to_icon_names(),
                                 scale,
                             );
                         }

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

@@ -15,7 +15,9 @@ use crate::{
     platform::linux::platform::read_fd,
 };
 
-pub(crate) const TEXT_MIME_TYPE: &str = "text/plain;charset=utf-8";
+/// Text mime types that we'll offer to other programs.
+pub(crate) const TEXT_MIME_TYPES: [&str; 3] =
+    ["text/plain;charset=utf-8", "UTF8_STRING", "text/plain"];
 pub(crate) const FILE_LIST_MIME_TYPE: &str = "text/uri-list";
 
 /// Text mime types that we'll accept from other programs.

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

@@ -1,4 +1,6 @@
 use crate::Globals;
+use crate::platform::linux::{DEFAULT_CURSOR_ICON_NAME, log_cursor_icon_warning};
+use anyhow::{Context as _, anyhow};
 use util::ResultExt;
 
 use wayland_client::Connection;
@@ -7,122 +9,143 @@ use wayland_client::protocol::{wl_pointer::WlPointer, wl_shm::WlShm};
 use wayland_cursor::{CursorImageBuffer, CursorTheme};
 
 pub(crate) struct Cursor {
-    theme: Option<CursorTheme>,
-    theme_name: Option<String>,
-    theme_size: u32,
-    surface: WlSurface,
+    loaded_theme: Option<LoadedTheme>,
     size: u32,
+    scaled_size: u32,
+    surface: WlSurface,
     shm: WlShm,
     connection: Connection,
 }
 
+pub(crate) struct LoadedTheme {
+    theme: CursorTheme,
+    name: Option<String>,
+    scaled_size: u32,
+}
+
 impl Drop for Cursor {
     fn drop(&mut self) {
-        self.theme.take();
+        self.loaded_theme.take();
         self.surface.destroy();
     }
 }
 
 impl Cursor {
     pub fn new(connection: &Connection, globals: &Globals, size: u32) -> Self {
-        Self {
-            theme: CursorTheme::load(&connection, globals.shm.clone(), size).log_err(),
-            theme_name: None,
-            theme_size: size,
+        let mut this = Self {
+            loaded_theme: None,
+            size,
+            scaled_size: size,
             surface: globals.compositor.create_surface(&globals.qh, ()),
             shm: globals.shm.clone(),
             connection: connection.clone(),
-            size,
-        }
+        };
+        this.set_theme_internal(None);
+        this
     }
 
-    pub fn set_theme(&mut self, theme_name: &str) {
-        if let Some(theme) = CursorTheme::load_from_name(
-            &self.connection,
-            self.shm.clone(),
-            theme_name,
-            self.theme_size,
-        )
-        .log_err()
-        {
-            self.theme = Some(theme);
-            self.theme_name = Some(theme_name.to_string());
-        } else if let Some(theme) =
-            CursorTheme::load(&self.connection, self.shm.clone(), self.theme_size).log_err()
+    fn set_theme_internal(&mut self, theme_name: Option<String>) {
+        if let Some(loaded_theme) = self.loaded_theme.as_ref() {
+            if loaded_theme.name == theme_name && loaded_theme.scaled_size == self.scaled_size {
+                return;
+            }
+        }
+        let result = if let Some(theme_name) = theme_name.as_ref() {
+            CursorTheme::load_from_name(
+                &self.connection,
+                self.shm.clone(),
+                theme_name,
+                self.scaled_size,
+            )
+        } else {
+            CursorTheme::load(&self.connection, self.shm.clone(), self.scaled_size)
+        };
+        if let Some(theme) = result
+            .context("Wayland: Failed to load cursor theme")
+            .log_err()
         {
-            self.theme = Some(theme);
-            self.theme_name = None;
+            self.loaded_theme = Some(LoadedTheme {
+                theme,
+                name: theme_name.map(|name| name.to_string()),
+                scaled_size: self.scaled_size,
+            });
         }
     }
 
-    fn set_theme_size(&mut self, theme_size: u32) {
-        self.theme = self
-            .theme_name
+    pub fn set_theme(&mut self, theme_name: String) {
+        self.set_theme_internal(Some(theme_name));
+    }
+
+    fn set_scaled_size(&mut self, scaled_size: u32) {
+        self.scaled_size = scaled_size;
+        let theme_name = self
+            .loaded_theme
             .as_ref()
-            .and_then(|name| {
-                CursorTheme::load_from_name(
-                    &self.connection,
-                    self.shm.clone(),
-                    name.as_str(),
-                    theme_size,
-                )
-                .log_err()
-            })
-            .or_else(|| {
-                CursorTheme::load(&self.connection, self.shm.clone(), theme_size).log_err()
-            });
+            .and_then(|loaded_theme| loaded_theme.name.clone());
+        self.set_theme_internal(theme_name);
     }
 
     pub fn set_size(&mut self, size: u32) {
         self.size = size;
-        self.set_theme_size(size);
+        self.set_scaled_size(size);
     }
 
     pub fn set_icon(
         &mut self,
         wl_pointer: &WlPointer,
         serial_id: u32,
-        mut cursor_icon_name: &str,
+        mut cursor_icon_names: &[&str],
         scale: i32,
     ) {
-        self.set_theme_size(self.size * scale as u32);
-
-        if let Some(theme) = &mut self.theme {
-            let mut buffer: Option<&CursorImageBuffer>;
-
-            if let Some(cursor) = theme.get_cursor(&cursor_icon_name) {
-                buffer = Some(&cursor[0]);
-            } else if let Some(cursor) = theme.get_cursor("default") {
-                buffer = Some(&cursor[0]);
-                cursor_icon_name = "default";
-                log::warn!(
-                    "Linux: Wayland: Unable to get cursor icon: {}. Using default cursor icon",
-                    cursor_icon_name
-                );
+        self.set_scaled_size(self.size * scale as u32);
+
+        let Some(loaded_theme) = &mut self.loaded_theme else {
+            log::warn!("Wayland: Unable to load cursor themes");
+            return;
+        };
+        let mut theme = &mut loaded_theme.theme;
+
+        let mut buffer: &CursorImageBuffer;
+        'outer: {
+            for cursor_icon_name in cursor_icon_names {
+                if let Some(cursor) = theme.get_cursor(cursor_icon_name) {
+                    buffer = &cursor[0];
+                    break 'outer;
+                }
+            }
+
+            if let Some(cursor) = theme.get_cursor(DEFAULT_CURSOR_ICON_NAME) {
+                buffer = &cursor[0];
+                log_cursor_icon_warning(anyhow!(
+                    "wayland: Unable to get cursor icon {:?}. \
+                    Using default cursor icon: '{}'",
+                    cursor_icon_names,
+                    DEFAULT_CURSOR_ICON_NAME
+                ));
             } else {
-                buffer = None;
-                log::warn!("Linux: Wayland: Unable to get default cursor too!");
+                log_cursor_icon_warning(anyhow!(
+                    "wayland: Unable to fallback on default cursor icon '{}' for theme '{}'",
+                    DEFAULT_CURSOR_ICON_NAME,
+                    loaded_theme.name.as_deref().unwrap_or("default")
+                ));
+                return;
             }
+        }
 
-            if let Some(buffer) = &mut buffer {
-                let (width, height) = buffer.dimensions();
-                let (hot_x, hot_y) = buffer.hotspot();
+        let (width, height) = buffer.dimensions();
+        let (hot_x, hot_y) = buffer.hotspot();
 
-                self.surface.set_buffer_scale(scale);
+        self.surface.set_buffer_scale(scale);
 
-                wl_pointer.set_cursor(
-                    serial_id,
-                    Some(&self.surface),
-                    hot_x as i32 / scale,
-                    hot_y as i32 / scale,
-                );
+        wl_pointer.set_cursor(
+            serial_id,
+            Some(&self.surface),
+            hot_x as i32 / scale,
+            hot_y as i32 / scale,
+        );
 
-                self.surface.attach(Some(&buffer), 0, 0);
-                self.surface.damage(0, 0, width as i32, height as i32);
-                self.surface.commit();
-            }
-        } else {
-            log::warn!("Linux: Wayland: Unable to load cursor themes");
-        }
+        self.surface.attach(Some(&buffer), 0, 0);
+        self.surface.damage(0, 0, width as i32, height as i32);
+        self.surface.commit();
     }
 }

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

@@ -3,6 +3,7 @@ use std::{
     hash::{Hash, Hasher},
 };
 
+use anyhow::Context as _;
 use uuid::Uuid;
 use wayland_backend::client::ObjectId;
 
@@ -28,11 +29,11 @@ impl PlatformDisplay for WaylandDisplay {
     }
 
     fn uuid(&self) -> anyhow::Result<Uuid> {
-        if let Some(name) = &self.name {
-            Ok(Uuid::new_v5(&Uuid::NAMESPACE_DNS, name.as_bytes()))
-        } else {
-            Err(anyhow::anyhow!("Wayland display does not have a name"))
-        }
+        let name = self
+            .name
+            .as_ref()
+            .context("Wayland display does not have a name")?;
+        Ok(Uuid::new_v5(&Uuid::NAMESPACE_DNS, name.as_bytes()))
     }
 
     fn bounds(&self) -> Bounds<Pixels> {

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

@@ -21,18 +21,21 @@ use wayland_protocols::xdg::shell::client::xdg_surface;
 use wayland_protocols::xdg::shell::client::xdg_toplevel::{self};
 use wayland_protocols_plasma::blur::client::org_kde_kwin_blur;
 
-use crate::platform::{
-    PlatformAtlas, PlatformInputHandler, PlatformWindow,
-    blade::{BladeContext, BladeRenderer, BladeSurfaceConfig},
-    linux::wayland::{display::WaylandDisplay, serial::SerialKind},
-};
 use crate::scene::Scene;
 use crate::{
     AnyWindowHandle, Bounds, Decorations, Globals, GpuSpecs, Modifiers, Output, Pixels,
-    PlatformDisplay, PlatformInput, Point, PromptLevel, RequestFrameOptions, ResizeEdge,
-    ScaledPixels, Size, Tiling, WaylandClientStatePtr, WindowAppearance,
-    WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, WindowParams, px,
-    size,
+    PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions,
+    ResizeEdge, ScaledPixels, Size, Tiling, WaylandClientStatePtr, WindowAppearance,
+    WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations,
+    WindowParams, px, size,
+};
+use crate::{
+    Capslock,
+    platform::{
+        PlatformAtlas, PlatformInputHandler, PlatformWindow,
+        blade::{BladeContext, BladeRenderer, BladeSurfaceConfig},
+        linux::wayland::{display::WaylandDisplay, serial::SerialKind},
+    },
 };
 
 #[derive(Default)]
@@ -249,11 +252,11 @@ impl Drop for WaylandWindow {
 }
 
 impl WaylandWindow {
-    fn borrow(&self) -> Ref<WaylandWindowState> {
+    fn borrow(&self) -> Ref<'_, WaylandWindowState> {
         self.0.state.borrow()
     }
 
-    fn borrow_mut(&self) -> RefMut<WaylandWindowState> {
+    fn borrow_mut(&self) -> RefMut<'_, WaylandWindowState> {
         self.0.state.borrow_mut()
     }
 
@@ -635,12 +638,8 @@ impl WaylandWindowStatePtr {
         let mut bounds: Option<Bounds<Pixels>> = None;
         if let Some(mut input_handler) = state.input_handler.take() {
             drop(state);
-            if let Some(selection) = input_handler.selected_text_range(true) {
-                bounds = input_handler.bounds_for_range(if selection.reversed {
-                    selection.range.start..selection.range.start
-                } else {
-                    selection.range.end..selection.range.end
-                });
+            if let Some(selection) = input_handler.marked_text_range() {
+                bounds = input_handler.bounds_for_range(selection.start..selection.start);
             }
             self.state.borrow_mut().input_handler = Some(input_handler);
         }
@@ -700,12 +699,14 @@ impl WaylandWindowStatePtr {
             }
         }
         if let PlatformInput::KeyDown(event) = input {
-            if let Some(key_char) = &event.keystroke.key_char {
-                let mut state = self.state.borrow_mut();
-                if let Some(mut input_handler) = state.input_handler.take() {
-                    drop(state);
-                    input_handler.replace_text_in_range(None, key_char);
-                    self.state.borrow_mut().input_handler = Some(input_handler);
+            if event.keystroke.modifiers.is_subset_of(&Modifiers::shift()) {
+                if let Some(key_char) = &event.keystroke.key_char {
+                    let mut state = self.state.borrow_mut();
+                    if let Some(mut input_handler) = state.input_handler.take() {
+                        drop(state);
+                        input_handler.replace_text_in_range(None, key_char);
+                        self.state.borrow_mut().input_handler = Some(input_handler);
+                    }
                 }
             }
         }
@@ -751,12 +752,28 @@ where
 
 impl rwh::HasWindowHandle for WaylandWindow {
     fn window_handle(&self) -> Result<rwh::WindowHandle<'_>, rwh::HandleError> {
-        unimplemented!()
+        let surface = self.0.surface().id().as_ptr() as *mut libc::c_void;
+        let c_ptr = NonNull::new(surface).ok_or(rwh::HandleError::Unavailable)?;
+        let handle = rwh::WaylandWindowHandle::new(c_ptr);
+        let raw_handle = rwh::RawWindowHandle::Wayland(handle);
+        Ok(unsafe { rwh::WindowHandle::borrow_raw(raw_handle) })
     }
 }
+
 impl rwh::HasDisplayHandle for WaylandWindow {
     fn display_handle(&self) -> Result<rwh::DisplayHandle<'_>, rwh::HandleError> {
-        unimplemented!()
+        let display = self
+            .0
+            .surface()
+            .backend()
+            .upgrade()
+            .ok_or(rwh::HandleError::Unavailable)?
+            .display_ptr() as *mut libc::c_void;
+
+        let c_ptr = NonNull::new(display).ok_or(rwh::HandleError::Unavailable)?;
+        let handle = rwh::WaylandDisplayHandle::new(c_ptr);
+        let raw_handle = rwh::RawDisplayHandle::Wayland(handle);
+        Ok(unsafe { rwh::DisplayHandle::borrow_raw(raw_handle) })
     }
 }
 
@@ -849,6 +866,10 @@ impl PlatformWindow for WaylandWindow {
         self.borrow().client.get_client().borrow().modifiers
     }
 
+    fn capslock(&self) -> Capslock {
+        self.borrow().client.get_client().borrow().capslock
+    }
+
     fn set_input_handler(&mut self, input_handler: PlatformInputHandler) {
         self.borrow_mut().input_handler = Some(input_handler);
     }
@@ -862,7 +883,7 @@ impl PlatformWindow for WaylandWindow {
         _level: PromptLevel,
         _msg: &str,
         _detail: Option<&str>,
-        _answers: &[&str],
+        _answers: &[PromptButton],
     ) -> Option<Receiver<usize>> {
         None
     }
@@ -966,6 +987,9 @@ impl PlatformWindow for WaylandWindow {
         self.0.callbacks.borrow_mut().close = Some(callback);
     }
 
+    fn on_hit_test_window_control(&self, _callback: Box<dyn FnMut() -> Option<WindowControlArea>>) {
+    }
+
     fn on_appearance_changed(&self, callback: Box<dyn FnMut()>) {
         self.0.callbacks.borrow_mut().appearance_changed = Some(callback);
     }

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

@@ -1,3 +1,4 @@
+use crate::{Capslock, xcb_flush};
 use core::str;
 use std::{
     cell::RefCell,
@@ -8,7 +9,7 @@ use std::{
     time::{Duration, Instant},
 };
 
-use anyhow::Context as _;
+use anyhow::{Context as _, anyhow};
 use calloop::{
     EventLoop, LoopHandle, RegistrationToken,
     generic::{FdWrapper, Generic},
@@ -16,6 +17,7 @@ use calloop::{
 use collections::HashMap;
 use futures::channel::oneshot;
 use http_client::Url;
+use log::Level;
 use smallvec::SmallVec;
 use util::ResultExt;
 
@@ -28,7 +30,7 @@ use x11rb::{
     protocol::xkb::ConnectionExt as _,
     protocol::xproto::{
         AtomEnum, ChangeWindowAttributesAux, ClientMessageData, ClientMessageEvent,
-        ConnectionExt as _, EventMask, KeyPressEvent,
+        ConnectionExt as _, EventMask, KeyPressEvent, Visibility,
     },
     protocol::{Event, randr, render, xinput, xkb, xproto},
     resource_manager::Database,
@@ -40,18 +42,19 @@ use xkbc::x11::ffi::{XKB_X11_MIN_MAJOR_XKB_VERSION, XKB_X11_MIN_MINOR_XKB_VERSIO
 use xkbcommon::xkb::{self as xkbc, LayoutIndex, ModMask, STATE_LAYOUT_EFFECTIVE};
 
 use super::{
-    ButtonOrScroll, ScrollDirection, button_or_scroll_from_event_detail,
+    ButtonOrScroll, ScrollDirection, X11Display, X11WindowStatePtr, XcbAtoms, XimCallbackEvent,
+    XimHandler, button_or_scroll_from_event_detail, check_reply,
     clipboard::{self, Clipboard},
-    get_valuator_axis_index, modifiers_from_state, pressed_button_from_mask,
+    get_reply, get_valuator_axis_index, handle_connection_error, modifiers_from_state,
+    pressed_button_from_mask,
 };
-use super::{X11Display, X11WindowStatePtr, XcbAtoms};
-use super::{XimCallbackEvent, XimHandler};
 
 use crate::platform::{
     LinuxCommon, PlatformWindow,
     blade::BladeContext,
     linux::{
-        LinuxClient, get_xkb_compose_state, is_within_click_distance, open_uri_internal,
+        DEFAULT_CURSOR_ICON_NAME, LinuxClient, get_xkb_compose_state, is_within_click_distance,
+        log_cursor_icon_warning, open_uri_internal,
         platform::{DOUBLE_CLICK_INTERVAL, SCROLL_LINES},
         reveal_path_internal,
         xdg_desktop_portal::{Event as XDPEvent, XDPEventSource},
@@ -78,7 +81,10 @@ pub(crate) const XINPUT_ALL_DEVICE_GROUPS: xinput::DeviceId = 1;
 
 pub(crate) struct WindowRef {
     window: X11WindowStatePtr,
-    refresh_event_token: RegistrationToken,
+    refresh_state: Option<RefreshState>,
+    expose_event_received: bool,
+    last_visibility: Visibility,
+    is_mapped: bool,
 }
 
 impl WindowRef {
@@ -95,6 +101,16 @@ impl Deref for WindowRef {
     }
 }
 
+enum RefreshState {
+    Hidden {
+        refresh_rate: Duration,
+    },
+    PeriodicRefresh {
+        refresh_rate: Duration,
+        event_loop_token: RegistrationToken,
+    },
+}
+
 #[derive(Debug)]
 #[non_exhaustive]
 pub enum EventHandlerError {
@@ -185,11 +201,15 @@ pub struct X11ClientState {
     pub(crate) keyboard_focused_window: Option<xproto::Window>,
     pub(crate) xkb: xkbc::State,
     previous_xkb_state: XKBStateNotiy,
+    keyboard_layout: LinuxKeyboardLayout,
     pub(crate) ximc: Option<X11rbClient<Rc<XCBConnection>>>,
     pub(crate) xim_handler: Option<XimHandler>,
     pub modifiers: Modifiers,
+    pub capslock: Capslock,
     // TODO: Can the other updates to `modifiers` be removed so that this is unnecessary?
+    // capslock logic was done analog to modifiers
     pub last_modifiers_changed_event: Modifiers,
+    pub last_capslock_changed_event: Capslock,
 
     pub(crate) compose_state: Option<xkbc::compose::State>,
     pub(crate) pre_edit_text: Option<String>,
@@ -197,7 +217,7 @@ pub struct X11ClientState {
     pub(crate) pre_key_char_down: Option<Keystroke>,
     pub(crate) cursor_handle: cursor::Handle,
     pub(crate) cursor_styles: HashMap<xproto::Window, CursorStyle>,
-    pub(crate) cursor_cache: HashMap<CursorStyle, xproto::Cursor>,
+    pub(crate) cursor_cache: HashMap<CursorStyle, Option<xproto::Cursor>>,
 
     pointer_device_states: BTreeMap<xinput::DeviceId, PointerDeviceState>,
 
@@ -211,16 +231,25 @@ pub struct X11ClientState {
 pub struct X11ClientStatePtr(pub Weak<RefCell<X11ClientState>>);
 
 impl X11ClientStatePtr {
-    fn get_client(&self) -> X11Client {
-        X11Client(self.0.upgrade().expect("client already dropped"))
+    fn get_client(&self) -> Option<X11Client> {
+        self.0.upgrade().map(X11Client)
     }
 
     pub fn drop_window(&self, x_window: u32) {
-        let client = self.get_client();
+        let Some(client) = self.get_client() else {
+            return;
+        };
         let mut state = client.0.borrow_mut();
 
         if let Some(window_ref) = state.windows.remove(&x_window) {
-            state.loop_handle.remove(window_ref.refresh_event_token);
+            match window_ref.refresh_state {
+                Some(RefreshState::PeriodicRefresh {
+                    event_loop_token, ..
+                }) => {
+                    state.loop_handle.remove(event_loop_token);
+                }
+                _ => {}
+            }
         }
         if state.mouse_focused_window == Some(x_window) {
             state.mouse_focused_window = None;
@@ -236,14 +265,23 @@ impl X11ClientStatePtr {
     }
 
     pub fn update_ime_position(&self, bounds: Bounds<ScaledPixels>) {
-        let client = self.get_client();
+        let Some(client) = self.get_client() else {
+            return;
+        };
         let mut state = client.0.borrow_mut();
         if state.composing || state.ximc.is_none() {
             return;
         }
 
-        let mut ximc = state.ximc.take().unwrap();
-        let xim_handler = state.xim_handler.take().unwrap();
+        let Some(mut ximc) = state.ximc.take() else {
+            log::error!("bug: xim connection not set");
+            return;
+        };
+        let Some(xim_handler) = state.xim_handler.take() else {
+            log::error!("bug: xim handler not set");
+            state.ximc = Some(ximc);
+            return;
+        };
         let ic_attributes = ximc
             .build_ic_attributes()
             .push(
@@ -274,8 +312,8 @@ impl X11ClientStatePtr {
 pub(crate) struct X11Client(Rc<RefCell<X11ClientState>>);
 
 impl X11Client {
-    pub(crate) fn new() -> Self {
-        let event_loop = EventLoop::try_new().unwrap();
+    pub(crate) fn new() -> anyhow::Result<Self> {
+        let event_loop = EventLoop::try_new()?;
 
         let (common, main_receiver) = LinuxCommon::new(event_loop.get_signal());
 
@@ -295,39 +333,34 @@ impl X11Client {
                     }
                 }
             })
-            .unwrap();
-
-        let (xcb_connection, x_root_index) = XCBConnection::connect(None).unwrap();
-        xcb_connection
-            .prefetch_extension_information(xkb::X11_EXTENSION_NAME)
-            .unwrap();
-        xcb_connection
-            .prefetch_extension_information(randr::X11_EXTENSION_NAME)
-            .unwrap();
-        xcb_connection
-            .prefetch_extension_information(render::X11_EXTENSION_NAME)
-            .unwrap();
-        xcb_connection
-            .prefetch_extension_information(xinput::X11_EXTENSION_NAME)
-            .unwrap();
+            .map_err(|err| {
+                anyhow!("Failed to initialize event loop handling of foreground tasks: {err:?}")
+            })?;
+
+        let (xcb_connection, x_root_index) = XCBConnection::connect(None)?;
+        xcb_connection.prefetch_extension_information(xkb::X11_EXTENSION_NAME)?;
+        xcb_connection.prefetch_extension_information(randr::X11_EXTENSION_NAME)?;
+        xcb_connection.prefetch_extension_information(render::X11_EXTENSION_NAME)?;
+        xcb_connection.prefetch_extension_information(xinput::X11_EXTENSION_NAME)?;
 
         // Announce to X server that XInput up to 2.1 is supported. To increase this to 2.2 and
         // beyond, support for touch events would need to be added.
-        let xinput_version = xcb_connection
-            .xinput_xi_query_version(2, 1)
-            .unwrap()
-            .reply()
-            .unwrap();
-        // XInput 1.x is not supported.
+        let xinput_version = get_reply(
+            || "XInput XiQueryVersion failed",
+            xcb_connection.xinput_xi_query_version(2, 1),
+        )?;
         assert!(
             xinput_version.major_version >= 2,
             "XInput version >= 2 required."
         );
 
         let pointer_device_states =
-            get_new_pointer_device_states(&xcb_connection, &BTreeMap::new());
+            current_pointer_device_states(&xcb_connection, &BTreeMap::new()).unwrap_or_default();
 
-        let atoms = XcbAtoms::new(&xcb_connection).unwrap().reply().unwrap();
+        let atoms = XcbAtoms::new(&xcb_connection)
+            .context("Failed to get XCB atoms")?
+            .reply()
+            .context("Failed to get XCB atoms")?;
 
         let root = xcb_connection.setup().roots[0].root;
         let compositor_present = check_compositor_present(&xcb_connection, root);
@@ -340,26 +373,35 @@ impl X11Client {
             gtk_frame_extents_supported
         );
 
-        let xkb = xcb_connection
-            .xkb_use_extension(XKB_X11_MIN_MAJOR_XKB_VERSION, XKB_X11_MIN_MINOR_XKB_VERSION)
-            .unwrap()
-            .reply()
-            .unwrap();
+        let xkb = get_reply(
+            || "Failed to initialize XKB extension",
+            xcb_connection
+                .xkb_use_extension(XKB_X11_MIN_MAJOR_XKB_VERSION, XKB_X11_MIN_MINOR_XKB_VERSION),
+        )?;
+        assert!(xkb.supported);
 
         let events = xkb::EventType::STATE_NOTIFY
             | xkb::EventType::MAP_NOTIFY
             | xkb::EventType::NEW_KEYBOARD_NOTIFY;
-        xcb_connection
-            .xkb_select_events(
+        let map_notify_parts = xkb::MapPart::KEY_TYPES
+            | xkb::MapPart::KEY_SYMS
+            | xkb::MapPart::MODIFIER_MAP
+            | xkb::MapPart::EXPLICIT_COMPONENTS
+            | xkb::MapPart::KEY_ACTIONS
+            | xkb::MapPart::KEY_BEHAVIORS
+            | xkb::MapPart::VIRTUAL_MODS
+            | xkb::MapPart::VIRTUAL_MOD_MAP;
+        check_reply(
+            || "Failed to select XKB events",
+            xcb_connection.xkb_select_events(
                 xkb::ID::USE_CORE_KBD.into(),
                 0u8.into(),
                 events,
-                0u8.into(),
-                0u8.into(),
+                map_notify_parts,
+                map_notify_parts,
                 &xkb::SelectEventsAux::new(),
-            )
-            .unwrap();
-        assert!(xkb.supported);
+            ),
+        )?;
 
         let xkb_context = xkbc::Context::new(xkbc::CONTEXT_NO_FLAGS);
         let xkb_device_id = xkbc::x11::get_core_keyboard_device_id(&xcb_connection);
@@ -373,23 +415,29 @@ impl X11Client {
             xkbc::x11::state_new_from_device(&xkb_keymap, &xcb_connection, xkb_device_id)
         };
         let compose_state = get_xkb_compose_state(&xkb_context);
-        let resource_database = x11rb::resource_manager::new_from_default(&xcb_connection).unwrap();
+        let layout_idx = xkb_state.serialize_layout(STATE_LAYOUT_EFFECTIVE);
+        let layout_name = xkb_state
+            .get_keymap()
+            .layout_get_name(layout_idx)
+            .to_string();
+        let keyboard_layout = LinuxKeyboardLayout::new(layout_name.into());
 
-        let gpu_context = BladeContext::new().expect("Unable to init GPU context");
+        let gpu_context = BladeContext::new().context("Unable to init GPU context")?;
 
+        let resource_database = x11rb::resource_manager::new_from_default(&xcb_connection)
+            .context("Failed to create resource database")?;
         let scale_factor = resource_database
             .get_value("Xft.dpi", "Xft.dpi")
             .ok()
             .flatten()
             .map(|dpi: f32| dpi / 96.0)
             .unwrap_or(1.0);
-
         let cursor_handle = cursor::Handle::new(&xcb_connection, x_root_index, &resource_database)
-            .unwrap()
+            .context("Failed to initialize cursor theme handler")?
             .reply()
-            .unwrap();
+            .context("Failed to initialize cursor theme handler")?;
 
-        let clipboard = Clipboard::new().unwrap();
+        let clipboard = Clipboard::new().context("Failed to initialize clipboard")?;
 
         let xcb_connection = Rc::new(xcb_connection);
 
@@ -418,7 +466,7 @@ impl X11Client {
                     }
                 },
             )
-            .expect("Failed to initialize x11 event source");
+            .map_err(|err| anyhow!("Failed to initialize X11 event source: {err:?}"))?;
 
         handle
             .insert_source(XDPEventSource::new(&common.background_executor), {
@@ -434,11 +482,15 @@ impl X11Client {
                     }
                 }
             })
-            .unwrap();
+            .map_err(|err| anyhow!("Failed to initialize XDP event source: {err:?}"))?;
+
+        xcb_flush(&xcb_connection);
 
-        X11Client(Rc::new(RefCell::new(X11ClientState {
+        Ok(X11Client(Rc::new(RefCell::new(X11ClientState {
             modifiers: Modifiers::default(),
+            capslock: Capslock::default(),
             last_modifiers_changed_event: Modifiers::default(),
+            last_capslock_changed_event: Capslock::default(),
             event_loop: Some(event_loop),
             loop_handle: handle,
             common,
@@ -461,6 +513,7 @@ impl X11Client {
             keyboard_focused_window: None,
             xkb: xkb_state,
             previous_xkb_state: XKBStateNotiy::default(),
+            keyboard_layout,
             ximc,
             xim_handler,
 
@@ -478,7 +531,7 @@ impl X11Client {
             clipboard,
             clipboard_item: None,
             xdnd_state: Xdnd::default(),
-        })))
+        }))))
     }
 
     pub fn process_x11_events(
@@ -492,6 +545,10 @@ impl X11Client {
             let mut last_key_release = None;
             let mut last_key_press: Option<KeyPressEvent> = None;
 
+            // event handlers for new keyboard / remapping refresh the state without using event
+            // details, this deduplicates them.
+            let mut last_keymap_change_event: Option<Event> = None;
+
             loop {
                 match xcb_connection.poll_for_event() {
                     Ok(Some(event)) => {
@@ -500,9 +557,29 @@ impl X11Client {
                                 windows_to_refresh.insert(expose_event.window);
                             }
                             Event::KeyRelease(_) => {
+                                if let Some(last_keymap_change_event) =
+                                    last_keymap_change_event.take()
+                                {
+                                    if let Some(last_key_release) = last_key_release.take() {
+                                        events.push(last_key_release);
+                                    }
+                                    last_key_press = None;
+                                    events.push(last_keymap_change_event);
+                                }
+
                                 last_key_release = Some(event);
                             }
                             Event::KeyPress(key_press) => {
+                                if let Some(last_keymap_change_event) =
+                                    last_keymap_change_event.take()
+                                {
+                                    if let Some(last_key_release) = last_key_release.take() {
+                                        events.push(last_key_release);
+                                    }
+                                    last_key_press = None;
+                                    events.push(last_keymap_change_event);
+                                }
+
                                 if let Some(last_press) = last_key_press.as_ref() {
                                     if last_press.detail == key_press.detail {
                                         continue;
@@ -523,6 +600,12 @@ impl X11Client {
                                 events.push(Event::KeyPress(key_press));
                                 last_key_press = Some(key_press);
                             }
+                            Event::XkbNewKeyboardNotify(_) | Event::XkbMapNotify(_) => {
+                                if let Some(release_event) = last_key_release.take() {
+                                    events.push(release_event);
+                                }
+                                last_keymap_change_event = Some(event);
+                            }
                             _ => {
                                 if let Some(release_event) = last_key_release.take() {
                                     events.push(release_event);
@@ -532,41 +615,45 @@ impl X11Client {
                         }
                     }
                     Ok(None) => {
-                        // Add any remaining stored KeyRelease event
-                        if let Some(release_event) = last_key_release.take() {
-                            events.push(release_event);
-                        }
                         break;
                     }
-                    Err(e) => {
-                        log::warn!("error polling for X11 events: {e:?}");
+                    Err(err) => {
+                        let err = handle_connection_error(err);
+                        log::warn!("error while polling for X11 events: {err:?}");
                         break;
                     }
                 }
             }
 
+            if let Some(release_event) = last_key_release.take() {
+                events.push(release_event);
+            }
+            if let Some(keymap_change_event) = last_keymap_change_event.take() {
+                events.push(keymap_change_event);
+            }
+
             if events.is_empty() && windows_to_refresh.is_empty() {
                 break;
             }
 
             for window in windows_to_refresh.into_iter() {
-                if let Some(window) = self.get_window(window) {
-                    window.refresh(RequestFrameOptions {
-                        require_presentation: true,
-                    });
+                let mut state = self.0.borrow_mut();
+                if let Some(window) = state.windows.get_mut(&window) {
+                    window.expose_event_received = true;
                 }
             }
 
             for event in events.into_iter() {
                 let mut state = self.0.borrow_mut();
-                if state.ximc.is_none() || state.xim_handler.is_none() {
+                if !state.has_xim() {
                     drop(state);
                     self.handle_event(event);
                     continue;
                 }
 
-                let mut ximc = state.ximc.take().unwrap();
-                let mut xim_handler = state.xim_handler.take().unwrap();
+                let Some((mut ximc, mut xim_handler)) = state.take_xim() else {
+                    continue;
+                };
                 let xim_connected = xim_handler.connected;
                 drop(state);
 
@@ -580,8 +667,7 @@ impl X11Client {
                 let xim_callback_event = xim_handler.last_callback_event.take();
 
                 let mut state = self.0.borrow_mut();
-                state.ximc = Some(ximc);
-                state.xim_handler = Some(xim_handler);
+                state.restore_xim(ximc, xim_handler);
                 drop(state);
 
                 if let Some(event) = xim_callback_event {
@@ -604,12 +690,13 @@ impl X11Client {
 
     pub fn enable_ime(&self) {
         let mut state = self.0.borrow_mut();
-        if state.ximc.is_none() {
+        if !state.has_xim() {
             return;
         }
 
-        let mut ximc = state.ximc.take().unwrap();
-        let mut xim_handler = state.xim_handler.take().unwrap();
+        let Some((mut ximc, mut xim_handler)) = state.take_xim() else {
+            return;
+        };
         let mut ic_attributes = ximc
             .build_ic_attributes()
             .push(AttributeName::InputStyle, InputStyle::PREEDIT_CALLBACKS)
@@ -619,7 +706,13 @@ impl X11Client {
         let window_id = state.keyboard_focused_window;
         drop(state);
         if let Some(window_id) = window_id {
-            let window = self.get_window(window_id).unwrap();
+            let Some(window) = self.get_window(window_id) else {
+                log::error!("Failed to get window for IME positioning");
+                let mut state = self.0.borrow_mut();
+                state.ximc = Some(ximc);
+                state.xim_handler = Some(xim_handler);
+                return;
+            };
             if let Some(area) = window.get_ime_area() {
                 ic_attributes =
                     ic_attributes.nested_list(xim::AttributeName::PreeditAttributes, |b| {
@@ -635,17 +728,19 @@ impl X11Client {
         }
         ximc.create_ic(xim_handler.im_id, ic_attributes.build())
             .ok();
-        state = self.0.borrow_mut();
-        state.xim_handler = Some(xim_handler);
-        state.ximc = Some(ximc);
+        let mut state = self.0.borrow_mut();
+        state.restore_xim(ximc, xim_handler);
     }
 
     pub fn reset_ime(&self) {
         let mut state = self.0.borrow_mut();
         state.composing = false;
         if let Some(mut ximc) = state.ximc.take() {
-            let xim_handler = state.xim_handler.as_ref().unwrap();
-            ximc.reset_ic(xim_handler.im_id, xim_handler.ic_id).ok();
+            if let Some(xim_handler) = state.xim_handler.as_ref() {
+                ximc.reset_ic(xim_handler.im_id, xim_handler.ic_id).ok();
+            } else {
+                log::error!("bug: xim handler not set in reset_ime");
+            }
             state.ximc = Some(ximc);
         }
     }
@@ -661,6 +756,27 @@ impl X11Client {
 
     fn handle_event(&self, event: Event) -> Option<()> {
         match event {
+            Event::UnmapNotify(event) => {
+                let mut state = self.0.borrow_mut();
+                if let Some(window_ref) = state.windows.get_mut(&event.window) {
+                    window_ref.is_mapped = false;
+                }
+                state.update_refresh_loop(event.window);
+            }
+            Event::MapNotify(event) => {
+                let mut state = self.0.borrow_mut();
+                if let Some(window_ref) = state.windows.get_mut(&event.window) {
+                    window_ref.is_mapped = true;
+                }
+                state.update_refresh_loop(event.window);
+            }
+            Event::VisibilityNotify(event) => {
+                let mut state = self.0.borrow_mut();
+                if let Some(window_ref) = state.windows.get_mut(&event.window) {
+                    window_ref.last_visibility = event.state;
+                }
+                state.update_refresh_loop(event.window);
+            }
             Event::ClientMessage(event) => {
                 let window = self.get_window(event.window)?;
                 let [atom, arg1, arg2, arg3, arg4] = event.data.as_data32();
@@ -704,26 +820,25 @@ impl X11Client {
                     window.handle_input(PlatformInput::FileDrop(FileDropEvent::Exited {}));
                     self.0.borrow_mut().xdnd_state = Xdnd::default();
                 } else if event.type_ == state.atoms.XdndPosition {
-                    if let Ok(pos) = state
-                        .xcb_connection
-                        .query_pointer(event.window)
-                        .unwrap()
-                        .reply()
-                    {
+                    if let Ok(pos) = get_reply(
+                        || "Failed to query pointer position",
+                        state.xcb_connection.query_pointer(event.window),
+                    ) {
                         state.xdnd_state.position =
                             Point::new(Pixels(pos.win_x as f32), Pixels(pos.win_y as f32));
                     }
                     if !state.xdnd_state.retrieved {
-                        state
-                            .xcb_connection
-                            .convert_selection(
+                        check_reply(
+                            || "Failed to convert selection for drag and drop",
+                            state.xcb_connection.convert_selection(
                                 event.window,
                                 state.atoms.XdndSelection,
                                 state.xdnd_state.drag_type,
                                 state.atoms.XDND_DATA,
                                 arg3,
-                            )
-                            .unwrap();
+                            ),
+                        )
+                        .log_err();
                     }
                     xdnd_send_status(
                         &state.xcb_connection,
@@ -753,35 +868,37 @@ impl X11Client {
             Event::SelectionNotify(event) => {
                 let window = self.get_window(event.requestor)?;
                 let mut state = self.0.borrow_mut();
-                let property = state.xcb_connection.get_property(
-                    false,
-                    event.requestor,
-                    state.atoms.XDND_DATA,
-                    AtomEnum::ANY,
-                    0,
-                    1024,
-                );
-                if property.as_ref().log_err().is_none() {
+                let reply = get_reply(
+                    || "Failed to get XDND_DATA",
+                    state.xcb_connection.get_property(
+                        false,
+                        event.requestor,
+                        state.atoms.XDND_DATA,
+                        AtomEnum::ANY,
+                        0,
+                        1024,
+                    ),
+                )
+                .log_err();
+                let Some(reply) = reply else {
                     return Some(());
-                }
-                if let Ok(reply) = property.unwrap().reply() {
-                    match str::from_utf8(&reply.value) {
-                        Ok(file_list) => {
-                            let paths: SmallVec<[_; 2]> = file_list
-                                .lines()
-                                .filter_map(|path| Url::parse(path).log_err())
-                                .filter_map(|url| url.to_file_path().log_err())
-                                .collect();
-                            let input = PlatformInput::FileDrop(FileDropEvent::Entered {
-                                position: state.xdnd_state.position,
-                                paths: crate::ExternalPaths(paths),
-                            });
-                            drop(state);
-                            window.handle_input(input);
-                            self.0.borrow_mut().xdnd_state.retrieved = true;
-                        }
-                        Err(_) => {}
+                };
+                match str::from_utf8(&reply.value) {
+                    Ok(file_list) => {
+                        let paths: SmallVec<[_; 2]> = file_list
+                            .lines()
+                            .filter_map(|path| Url::parse(path).log_err())
+                            .filter_map(|url| url.to_file_path().log_err())
+                            .collect();
+                        let input = PlatformInput::FileDrop(FileDropEvent::Entered {
+                            position: state.xdnd_state.position,
+                            paths: crate::ExternalPaths(paths),
+                        });
+                        drop(state);
+                        window.handle_input(input);
+                        self.0.borrow_mut().xdnd_state.retrieved = true;
                     }
+                    Err(_) => {}
                 }
             }
             Event::ConfigureNotify(event) => {
@@ -796,11 +913,17 @@ impl X11Client {
                     },
                 };
                 let window = self.get_window(event.window)?;
-                window.configure(bounds).unwrap();
+                window
+                    .set_bounds(bounds)
+                    .context("X11: Failed to set window bounds")
+                    .log_err();
             }
             Event::PropertyNotify(event) => {
                 let window = self.get_window(event.window)?;
-                window.property_notify(event).unwrap();
+                window
+                    .property_notify(event)
+                    .context("X11: Failed to handle property notify")
+                    .log_err();
             }
             Event::FocusIn(event) => {
                 let window = self.get_window(event.event)?;
@@ -826,7 +949,7 @@ impl X11Client {
                 self.reset_ime();
                 window.handle_ime_delete();
             }
-            Event::XkbNewKeyboardNotify(_) | Event::MapNotify(_) => {
+            Event::XkbNewKeyboardNotify(_) | Event::XkbMapNotify(_) => {
                 let mut state = self.0.borrow_mut();
                 let xkb_state = {
                     let xkb_keymap = xkbc::x11::keymap_new_from_device(
@@ -841,7 +964,17 @@ impl X11Client {
                         state.xkb_device_id,
                     )
                 };
+                let depressed_layout = xkb_state.serialize_layout(xkbc::STATE_LAYOUT_DEPRESSED);
+                let latched_layout = xkb_state.serialize_layout(xkbc::STATE_LAYOUT_LATCHED);
+                let locked_layout = xkb_state.serialize_layout(xkbc::ffi::XKB_STATE_LAYOUT_LOCKED);
+                state.previous_xkb_state = XKBStateNotiy {
+                    depressed_layout,
+                    latched_layout,
+                    locked_layout,
+                };
                 state.xkb = xkb_state;
+                drop(state);
+                self.handle_keyboard_layout_change();
             }
             Event::XkbStateNotify(event) => {
                 let mut state = self.0.borrow_mut();
@@ -861,30 +994,32 @@ impl X11Client {
                     locked_layout: event.locked_group.into(),
                 };
 
-                if new_layout != old_layout {
-                    if let Some(mut callback) = state.common.callbacks.keyboard_layout_change.take()
-                    {
-                        drop(state);
-                        callback();
-                        state = self.0.borrow_mut();
-                        state.common.callbacks.keyboard_layout_change = Some(callback);
-                    }
-                }
-
                 let modifiers = Modifiers::from_xkb(&state.xkb);
-                if state.last_modifiers_changed_event == modifiers {
+                let capslock = Capslock::from_xkb(&state.xkb);
+                if state.last_modifiers_changed_event == modifiers
+                    && state.last_capslock_changed_event == capslock
+                {
                     drop(state);
                 } else {
                     let focused_window_id = state.keyboard_focused_window?;
                     state.modifiers = modifiers;
                     state.last_modifiers_changed_event = modifiers;
+                    state.capslock = capslock;
+                    state.last_capslock_changed_event = capslock;
                     drop(state);
 
                     let focused_window = self.get_window(focused_window_id)?;
                     focused_window.handle_input(PlatformInput::ModifiersChanged(
-                        ModifiersChangedEvent { modifiers },
+                        ModifiersChangedEvent {
+                            modifiers,
+                            capslock,
+                        },
                     ));
                 }
+
+                if new_layout != old_layout {
+                    self.handle_keyboard_layout_change();
+                }
             }
             Event::KeyPress(event) => {
                 let window = self.get_window(event.event)?;
@@ -1155,10 +1290,12 @@ impl X11Client {
                         state.pointer_device_states.remove(&info.deviceid);
                     }
                 }
-                state.pointer_device_states = get_new_pointer_device_states(
+                if let Some(pointer_device_states) = current_pointer_device_states(
                     &state.xcb_connection,
                     &state.pointer_device_states,
-                );
+                ) {
+                    state.pointer_device_states = pointer_device_states;
+                }
             }
             Event::XinputDeviceChanged(event) => {
                 let mut state = self.0.borrow_mut();
@@ -1195,8 +1332,7 @@ impl X11Client {
                     state.modifiers,
                     event.detail.into(),
                 ));
-                let mut ximc = state.ximc.take().unwrap();
-                let mut xim_handler = state.xim_handler.take().unwrap();
+                let (mut ximc, mut xim_handler) = state.take_xim()?;
                 drop(state);
                 xim_handler.window = event.event;
                 ximc.forward_event(
@@ -1205,10 +1341,10 @@ impl X11Client {
                     xim::ForwardEventFlag::empty(),
                     &event,
                 )
-                .unwrap();
+                .context("X11: Failed to forward XIM event")
+                .log_err();
                 let mut state = self.0.borrow_mut();
-                state.ximc = Some(ximc);
-                state.xim_handler = Some(xim_handler);
+                state.restore_xim(ximc, xim_handler);
                 drop(state);
             }
             event => {
@@ -1219,7 +1355,10 @@ impl X11Client {
     }
 
     fn xim_handle_commit(&self, window: xproto::Window, text: String) -> Option<()> {
-        let window = self.get_window(window).unwrap();
+        let Some(window) = self.get_window(window) else {
+            log::error!("bug: Failed to get window for XIM commit");
+            return None;
+        };
         let mut state = self.0.borrow_mut();
         let keystroke = state.pre_key_char_down.take();
         state.composing = false;
@@ -1236,11 +1375,13 @@ impl X11Client {
     }
 
     fn xim_handle_preedit(&self, window: xproto::Window, text: String) -> Option<()> {
-        let window = self.get_window(window).unwrap();
+        let Some(window) = self.get_window(window) else {
+            log::error!("bug: Failed to get window for XIM preedit");
+            return None;
+        };
 
         let mut state = self.0.borrow_mut();
-        let mut ximc = state.ximc.take().unwrap();
-        let mut xim_handler = state.xim_handler.take().unwrap();
+        let (mut ximc, mut xim_handler) = state.take_xim()?;
         state.composing = !text.is_empty();
         drop(state);
         window.handle_ime_preedit(text);
@@ -1268,11 +1409,26 @@ impl X11Client {
                 .ok();
         }
         let mut state = self.0.borrow_mut();
-        state.ximc = Some(ximc);
-        state.xim_handler = Some(xim_handler);
+        state.restore_xim(ximc, xim_handler);
         drop(state);
         Some(())
     }
+
+    fn handle_keyboard_layout_change(&self) {
+        let mut state = self.0.borrow_mut();
+        let layout_idx = state.xkb.serialize_layout(STATE_LAYOUT_EFFECTIVE);
+        let keymap = state.xkb.get_keymap();
+        let layout_name = keymap.layout_get_name(layout_idx);
+        if layout_name != state.keyboard_layout.name() {
+            state.keyboard_layout = LinuxKeyboardLayout::new(layout_name.to_string().into());
+            if let Some(mut callback) = state.common.callbacks.keyboard_layout_change.take() {
+                drop(state);
+                callback();
+                state = self.0.borrow_mut();
+                state.common.callbacks.keyboard_layout_change = Some(callback);
+            }
+        }
+    }
 }
 
 impl LinuxClient for X11Client {
@@ -1286,14 +1442,7 @@ impl LinuxClient for X11Client {
 
     fn keyboard_layout(&self) -> Box<dyn PlatformKeyboardLayout> {
         let state = self.0.borrow();
-        let layout_idx = state.xkb.serialize_layout(STATE_LAYOUT_EFFECTIVE);
-        Box::new(LinuxKeyboardLayout::new(
-            state
-                .xkb
-                .get_keymap()
-                .layout_get_name(layout_idx)
-                .to_string(),
-        ))
+        Box::new(state.keyboard_layout.clone())
     }
 
     fn displays(&self) -> Vec<Rc<dyn PlatformDisplay>> {
@@ -1313,15 +1462,13 @@ impl LinuxClient for X11Client {
 
     fn primary_display(&self) -> Option<Rc<dyn PlatformDisplay>> {
         let state = self.0.borrow();
-
-        Some(Rc::new(
-            X11Display::new(
-                &state.xcb_connection,
-                state.scale_factor,
-                state.x_root_index,
-            )
-            .expect("There should always be a root index"),
-        ))
+        X11Display::new(
+            &state.xcb_connection,
+            state.scale_factor,
+            state.x_root_index,
+        )
+        .log_err()
+        .map(|display| Rc::new(display) as Rc<dyn PlatformDisplay>)
     }
 
     fn display(&self, id: DisplayId) -> Option<Rc<dyn PlatformDisplay>> {
@@ -1348,7 +1495,10 @@ impl LinuxClient for X11Client {
         params: WindowParams,
     ) -> anyhow::Result<Box<dyn PlatformWindow>> {
         let mut state = self.0.borrow_mut();
-        let x_window = state.xcb_connection.generate_id().unwrap();
+        let x_window = state
+            .xcb_connection
+            .generate_id()
+            .context("X11: Failed to generate window ID")?;
 
         let window = X11Window::new(
             handle,
@@ -1364,72 +1514,25 @@ impl LinuxClient for X11Client {
             state.scale_factor,
             state.common.appearance,
         )?;
-        state
-            .xcb_connection
-            .change_property32(
+        check_reply(
+            || "Failed to set XdndAware property",
+            state.xcb_connection.change_property32(
                 xproto::PropMode::REPLACE,
                 x_window,
                 state.atoms.XdndAware,
                 state.atoms.XA_ATOM,
                 &[5],
-            )
-            .unwrap();
-
-        let screen_resources = state
-            .xcb_connection
-            .randr_get_screen_resources(x_window)
-            .unwrap()
-            .reply()
-            .expect("Could not find available screens");
-
-        let mode = screen_resources
-            .crtcs
-            .iter()
-            .find_map(|crtc| {
-                let crtc_info = state
-                    .xcb_connection
-                    .randr_get_crtc_info(*crtc, x11rb::CURRENT_TIME)
-                    .ok()?
-                    .reply()
-                    .ok()?;
-
-                screen_resources
-                    .modes
-                    .iter()
-                    .find(|m| m.id == crtc_info.mode)
-            })
-            .expect("Unable to find screen refresh rate");
-
-        let refresh_event_token = state
-            .loop_handle
-            .insert_source(calloop::timer::Timer::immediate(), {
-                let refresh_duration = mode_refresh_rate(mode);
-                move |mut instant, (), client| {
-                    let xcb_connection = {
-                        let state = client.0.borrow_mut();
-                        let xcb_connection = state.xcb_connection.clone();
-                        if let Some(window) = state.windows.get(&x_window) {
-                            let window = window.window.clone();
-                            drop(state);
-                            window.refresh(Default::default());
-                        }
-                        xcb_connection
-                    };
-                    client.process_x11_events(&xcb_connection).log_err();
-
-                    // Take into account that some frames have been skipped
-                    let now = Instant::now();
-                    while instant < now {
-                        instant += refresh_duration;
-                    }
-                    calloop::timer::TimeoutAction::ToInstant(instant)
-                }
-            })
-            .expect("Failed to initialize refresh timer");
+            ),
+        )
+        .log_err();
+        xcb_flush(&state.xcb_connection);
 
         let window_ref = WindowRef {
             window: window.0.clone(),
-            refresh_event_token,
+            refresh_state: None,
+            expose_event_received: false,
+            last_visibility: Visibility::UNOBSCURED,
+            is_mapped: false,
         };
 
         state.windows.insert(x_window, window_ref);
@@ -1449,38 +1552,23 @@ impl LinuxClient for X11Client {
             return;
         }
 
-        let cursor = match state.cursor_cache.get(&style) {
-            Some(cursor) => *cursor,
-            None => {
-                let Some(cursor) = (match style {
-                    CursorStyle::None => create_invisible_cursor(&state.xcb_connection).log_err(),
-                    _ => state
-                        .cursor_handle
-                        .load_cursor(&state.xcb_connection, &style.to_icon_name())
-                        .log_err(),
-                }) else {
-                    return;
-                };
-
-                state.cursor_cache.insert(style, cursor);
-                cursor
-            }
+        let Some(cursor) = state.get_cursor_icon(style) else {
+            return;
         };
 
         state.cursor_styles.insert(focused_window, style);
-        state
-            .xcb_connection
-            .change_window_attributes(
+        check_reply(
+            || "Failed to set cursor style",
+            state.xcb_connection.change_window_attributes(
                 focused_window,
                 &ChangeWindowAttributesAux {
                     cursor: Some(cursor),
                     ..Default::default()
                 },
-            )
-            .anyhow()
-            .and_then(|cookie| cookie.check().anyhow())
-            .context("setting cursor style")
-            .log_err();
+            ),
+        )
+        .log_err();
+        state.xcb_connection.flush().log_err();
     }
 
     fn open_uri(&self, uri: &str) {

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

@@ -200,7 +200,7 @@ struct ClipboardData {
 }
 
 enum ReadSelNotifyResult {
-    GotData(Vec<u8>),
+    GotData(ClipboardData),
     IncrStarted,
     EventNotRecognized,
 }
@@ -297,30 +297,83 @@ impl Inner {
         }
         let reader = XContext::new()?;
 
-        log::trace!("Trying to get the clipboard data.");
+        let highest_precedence_format =
+            match self.read_single(&reader, selection, self.atoms.TARGETS) {
+                Err(err) => {
+                    log::trace!("Clipboard TARGETS query failed with {err:?}");
+                    None
+                }
+                Ok(ClipboardData { bytes, format }) => {
+                    if format == self.atoms.ATOM {
+                        let available_formats = Self::parse_formats(&bytes);
+                        formats
+                            .iter()
+                            .find(|format| available_formats.contains(format))
+                    } else {
+                        log::trace!(
+                            "Unexpected clipboard TARGETS format {}",
+                            self.atom_name(format)
+                        );
+                        None
+                    }
+                }
+            };
+
+        if let Some(&format) = highest_precedence_format {
+            let data = self.read_single(&reader, selection, format)?;
+            if !formats.contains(&data.format) {
+                // This shouldn't happen since the format is from the TARGETS list.
+                log::trace!(
+                    "Conversion to {} responded with {} which is not supported",
+                    self.atom_name(format),
+                    self.atom_name(data.format),
+                );
+                return Err(Error::ConversionFailure);
+            }
+            return Ok(data);
+        }
+
+        log::trace!("Falling back on attempting to convert clipboard to each format.");
         for format in formats {
             match self.read_single(&reader, selection, *format) {
-                Ok(bytes) => {
-                    return Ok(ClipboardData {
-                        bytes,
-                        format: *format,
-                    });
+                Ok(data) => {
+                    if formats.contains(&data.format) {
+                        return Ok(data);
+                    } else {
+                        log::trace!(
+                            "Conversion to {} responded with {} which is not supported",
+                            self.atom_name(*format),
+                            self.atom_name(data.format),
+                        );
+                        continue;
+                    }
                 }
                 Err(Error::ContentNotAvailable) => {
                     continue;
                 }
-                Err(e) => return Err(e),
+                Err(e) => {
+                    log::trace!("Conversion to {} failed: {}", self.atom_name(*format), e);
+                    return Err(e);
+                }
             }
         }
+        log::trace!("All conversions to supported formats failed.");
         Err(Error::ContentNotAvailable)
     }
 
+    fn parse_formats(bytes: &[u8]) -> Vec<Atom> {
+        bytes
+            .chunks_exact(4)
+            .map(|chunk| u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
+            .collect()
+    }
+
     fn read_single(
         &self,
         reader: &XContext,
         selection: ClipboardKind,
         target_format: Atom,
-    ) -> Result<Vec<u8>> {
+    ) -> Result<ClipboardData> {
         // Delete the property so that we can detect (using property notify)
         // when the selection owner receives our request.
         reader
@@ -392,10 +445,16 @@ impl Inner {
                         event,
                     )?;
                     if result {
-                        return Ok(incr_data);
+                        return Ok(ClipboardData {
+                            bytes: incr_data,
+                            format: target_format,
+                        });
                     }
                 }
-                _ => log::trace!("An unexpected event arrived while reading the clipboard."),
+                _ => log::trace!(
+                    "An unexpected event arrived while reading the clipboard: {:?}",
+                    event
+                ),
             }
         }
         log::info!("Time-out hit while reading the clipboard.");
@@ -440,7 +499,7 @@ impl Inner {
         Ok(current == self.server.win_id)
     }
 
-    fn atom_name(&self, atom: x11rb::protocol::xproto::Atom) -> Result<String> {
+    fn query_atom_name(&self, atom: x11rb::protocol::xproto::Atom) -> Result<String> {
         String::from_utf8(
             self.server
                 .conn
@@ -453,14 +512,14 @@ impl Inner {
         .map_err(into_unknown)
     }
 
-    fn atom_name_dbg(&self, atom: x11rb::protocol::xproto::Atom) -> &'static str {
+    fn atom_name(&self, atom: x11rb::protocol::xproto::Atom) -> &'static str {
         ATOM_NAME_CACHE.with(|cache| {
             let mut cache = cache.borrow_mut();
             match cache.entry(atom) {
                 Entry::Occupied(entry) => *entry.get(),
                 Entry::Vacant(entry) => {
                     let s = self
-                        .atom_name(atom)
+                        .query_atom_name(atom)
                         .map(|s| Box::leak(s.into_boxed_str()) as &str)
                         .unwrap_or("FAILED-TO-GET-THE-ATOM-NAME");
                     entry.insert(s);
@@ -496,6 +555,12 @@ impl Inner {
             log::warn!("Received a SelectionNotify while already expecting INCR segments.");
             return Ok(ReadSelNotifyResult::EventNotRecognized);
         }
+        // Accept any property type. The property type will typically match the format type except
+        // when it is `TARGETS` in which case it is `ATOM`. `ANY` is provided to handle the case
+        // where the clipboard is not convertible to the requested format. In this case
+        // `reply.type_` will have format information, but `bytes` will only be non-empty if `ANY`
+        // is provided.
+        let property_type = AtomEnum::ANY;
         // request the selection
         let mut reply = reader
             .conn
@@ -503,7 +568,7 @@ impl Inner {
                 true,
                 event.requestor,
                 event.property,
-                event.target,
+                property_type,
                 0,
                 u32::MAX / 4,
             )
@@ -511,12 +576,8 @@ impl Inner {
             .reply()
             .map_err(into_unknown)?;
 
-        //log::trace!("Property.type: {:?}", self.atom_name(reply.type_));
-
         // we found something
-        if reply.type_ == target_format {
-            Ok(ReadSelNotifyResult::GotData(reply.value))
-        } else if reply.type_ == self.atoms.INCR {
+        if reply.type_ == self.atoms.INCR {
             // Note that we call the get_property again because we are
             // indicating that we are ready to receive the data by deleting the
             // property, however deleting only works if the type matches the
@@ -545,8 +606,10 @@ impl Inner {
             }
             Ok(ReadSelNotifyResult::IncrStarted)
         } else {
-            // this should never happen, we have sent a request only for supported types
-            Err(Error::unknown("incorrect type received from clipboard"))
+            Ok(ReadSelNotifyResult::GotData(ClipboardData {
+                bytes: reply.value,
+                format: reply.type_,
+            }))
         }
     }
 
@@ -574,7 +637,11 @@ impl Inner {
                 true,
                 event.window,
                 event.atom,
-                target_format,
+                if target_format == self.atoms.TARGETS {
+                    self.atoms.ATOM
+                } else {
+                    target_format
+                },
                 0,
                 u32::MAX / 4,
             )
@@ -612,7 +679,7 @@ impl Inner {
         if event.target == self.atoms.TARGETS {
             log::trace!(
                 "Handling TARGETS, dst property is {}",
-                self.atom_name_dbg(event.property)
+                self.atom_name(event.property)
             );
             let mut targets = Vec::with_capacity(10);
             targets.push(self.atoms.TARGETS);
@@ -812,8 +879,8 @@ fn serve_requests(context: Arc<Inner>) -> Result<(), Box<dyn std::error::Error>>
             Event::SelectionRequest(event) => {
                 log::trace!(
                     "SelectionRequest - selection is: {}, target is {}",
-                    context.atom_name_dbg(event.selection),
-                    context.atom_name_dbg(event.target),
+                    context.atom_name(event.selection),
+                    context.atom_name(event.target),
                 );
                 // Someone is requesting the clipboard content from us.
                 context
@@ -983,10 +1050,15 @@ impl Clipboard {
         // format that the contents can be converted to
         format_atoms[0..IMAGE_FORMAT_COUNT].copy_from_slice(&image_format_atoms);
         format_atoms[IMAGE_FORMAT_COUNT..].copy_from_slice(&text_format_atoms);
-        debug_assert!(!format_atoms.iter().any(|&a| a == atom_none));
+        debug_assert!(!format_atoms.contains(&atom_none));
 
         let result = self.inner.read(&format_atoms, selection)?;
 
+        log::trace!(
+            "read clipboard as format {:?}",
+            self.inner.atom_name(result.format)
+        );
+
         for (format_atom, image_format) in image_format_atoms.into_iter().zip(image_formats) {
             if result.format == format_atom {
                 let bytes = result.bytes;

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

@@ -1,4 +1,4 @@
-use anyhow::Result;
+use anyhow::Context as _;
 use uuid::Uuid;
 use x11rb::{connection::Connection as _, xcb_ffi::XCBConnection};
 
@@ -17,12 +17,11 @@ impl X11Display {
         scale_factor: f32,
         x_screen_index: usize,
     ) -> anyhow::Result<Self> {
-        let Some(screen) = xcb.setup().roots.get(x_screen_index) else {
-            return Err(anyhow::anyhow!(
-                "No screen found with index {}",
-                x_screen_index
-            ));
-        };
+        let screen = xcb
+            .setup()
+            .roots
+            .get(x_screen_index)
+            .with_context(|| format!("No screen found with index {x_screen_index}"))?;
         Ok(Self {
             x_screen_index,
             bounds: Bounds {
@@ -42,7 +41,7 @@ impl PlatformDisplay for X11Display {
         DisplayId(self.x_screen_index as u32)
     }
 
-    fn uuid(&self) -> Result<Uuid> {
+    fn uuid(&self) -> anyhow::Result<Uuid> {
         Ok(self.uuid)
     }
 

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

@@ -1,12 +1,13 @@
 use anyhow::{Context as _, anyhow};
+use x11rb::connection::RequestConnection;
 
 use crate::platform::blade::{BladeContext, BladeRenderer, BladeSurfaceConfig};
 use crate::{
     AnyWindowHandle, Bounds, Decorations, DevicePixels, ForegroundExecutor, GpuSpecs, Modifiers,
     Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow,
-    Point, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Scene, Size, Tiling,
-    WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind,
-    WindowParams, X11ClientStatePtr, px, size,
+    Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Scene, Size,
+    Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea,
+    WindowDecorations, WindowKind, WindowParams, X11ClientStatePtr, px, size,
 };
 
 use blade_graphics as gpu;
@@ -20,7 +21,7 @@ use x11rb::{
     protocol::{
         sync,
         xinput::{self, ConnectionExt as _},
-        xproto::{self, ClientMessageEvent, ConnectionExt, EventMask, TranslateCoordinatesReply},
+        xproto::{self, ClientMessageEvent, ConnectionExt, TranslateCoordinatesReply},
     },
     wrapper::ConnectionExt as _,
     xcb_ffi::XCBConnection,
@@ -32,6 +33,7 @@ use std::{
 };
 
 use super::{X11Display, XINPUT_ALL_DEVICE_GROUPS, XINPUT_ALL_DEVICES};
+
 x11rb::atom_manager! {
     pub XcbAtoms: AtomsCookie {
         XA_ATOM,
@@ -286,7 +288,7 @@ pub(crate) struct X11WindowStatePtr {
 }
 
 impl rwh::HasWindowHandle for RawWindow {
-    fn window_handle(&self) -> Result<rwh::WindowHandle, rwh::HandleError> {
+    fn window_handle(&self) -> Result<rwh::WindowHandle<'_>, rwh::HandleError> {
         let Some(non_zero) = NonZeroU32::new(self.window_id) else {
             log::error!("RawWindow.window_id zero when getting window handle.");
             return Err(rwh::HandleError::Unavailable);
@@ -297,7 +299,7 @@ impl rwh::HasWindowHandle for RawWindow {
     }
 }
 impl rwh::HasDisplayHandle for RawWindow {
-    fn display_handle(&self) -> Result<rwh::DisplayHandle, rwh::HandleError> {
+    fn display_handle(&self) -> Result<rwh::DisplayHandle<'_>, rwh::HandleError> {
         let Some(non_zero) = NonNull::new(self.connection) else {
             log::error!("Null RawWindow.connection when getting display handle.");
             return Err(rwh::HandleError::Unavailable);
@@ -308,53 +310,74 @@ impl rwh::HasDisplayHandle for RawWindow {
 }
 
 impl rwh::HasWindowHandle for X11Window {
-    fn window_handle(&self) -> Result<rwh::WindowHandle, rwh::HandleError> {
+    fn window_handle(&self) -> Result<rwh::WindowHandle<'_>, rwh::HandleError> {
         unimplemented!()
     }
 }
 impl rwh::HasDisplayHandle for X11Window {
-    fn display_handle(&self) -> Result<rwh::DisplayHandle, rwh::HandleError> {
+    fn display_handle(&self) -> Result<rwh::DisplayHandle<'_>, rwh::HandleError> {
         unimplemented!()
     }
 }
 
-fn check_reply<C, F>(
+pub(crate) fn xcb_flush(xcb: &XCBConnection) {
+    xcb.flush()
+        .map_err(handle_connection_error)
+        .context("X11 flush failed")
+        .log_err();
+}
+
+pub(crate) fn check_reply<E, F, C>(
     failure_context: F,
-    result: Result<VoidCookie<'_, Rc<XCBConnection>>, ConnectionError>,
+    result: Result<VoidCookie<'_, C>, ConnectionError>,
 ) -> anyhow::Result<()>
 where
-    C: Display + Send + Sync + 'static,
-    F: FnOnce() -> C,
+    E: Display + Send + Sync + 'static,
+    F: FnOnce() -> E,
+    C: RequestConnection,
 {
     result
-        .map_err(|connection_error| anyhow!(connection_error))
-        .and_then(|response| {
-            response
-                .check()
-                .map_err(|error_response| anyhow!(error_response))
-        })
+        .map_err(handle_connection_error)
+        .and_then(|response| response.check().map_err(|reply_error| anyhow!(reply_error)))
         .with_context(failure_context)
 }
 
-fn get_reply<C, F, O>(
+pub(crate) fn get_reply<E, F, C, O>(
     failure_context: F,
-    result: Result<Cookie<'_, Rc<XCBConnection>, O>, ConnectionError>,
+    result: Result<Cookie<'_, C, O>, ConnectionError>,
 ) -> anyhow::Result<O>
 where
-    C: Display + Send + Sync + 'static,
-    F: FnOnce() -> C,
+    E: Display + Send + Sync + 'static,
+    F: FnOnce() -> E,
+    C: RequestConnection,
     O: x11rb::x11_utils::TryParse,
 {
     result
-        .map_err(|connection_error| anyhow!(connection_error))
-        .and_then(|response| {
-            response
-                .reply()
-                .map_err(|error_response| anyhow!(error_response))
-        })
+        .map_err(handle_connection_error)
+        .and_then(|response| response.reply().map_err(|reply_error| anyhow!(reply_error)))
         .with_context(failure_context)
 }
 
+/// Convert X11 connection errors to `anyhow::Error` and panic for unrecoverable errors.
+pub(crate) fn handle_connection_error(err: ConnectionError) -> anyhow::Error {
+    match err {
+        ConnectionError::UnknownError => anyhow!("X11 connection: Unknown error"),
+        ConnectionError::UnsupportedExtension => anyhow!("X11 connection: Unsupported extension"),
+        ConnectionError::MaximumRequestLengthExceeded => {
+            anyhow!("X11 connection: Maximum request length exceeded")
+        }
+        ConnectionError::FdPassingFailed => {
+            panic!("X11 connection: File descriptor passing failed")
+        }
+        ConnectionError::ParseError(parse_error) => {
+            anyhow!(parse_error).context("Parse error in X11 response")
+        }
+        ConnectionError::InsufficientMemory => panic!("X11 connection: Insufficient memory"),
+        ConnectionError::IoError(err) => anyhow!(err).context("X11 connection: IOError"),
+        _ => anyhow!(err),
+    }
+}
+
 impl X11WindowState {
     pub fn new(
         handle: AnyWindowHandle,
@@ -407,7 +430,8 @@ impl X11WindowState {
                     | xproto::EventMask::FOCUS_CHANGE
                     | xproto::EventMask::KEY_PRESS
                     | xproto::EventMask::KEY_RELEASE
-                    | EventMask::PROPERTY_CHANGE,
+                    | xproto::EventMask::PROPERTY_CHANGE
+                    | xproto::EventMask::VISIBILITY_CHANGE,
             );
 
         let mut bounds = params.bounds.to_device_pixels(scale_factor);
@@ -580,7 +604,7 @@ impl X11WindowState {
                 ),
             )?;
 
-            xcb.flush().with_context(|| "X11 Flush failed.")?;
+            xcb_flush(&xcb);
 
             let renderer = {
                 let raw_window = RawWindow {
@@ -640,8 +664,7 @@ impl X11WindowState {
                 || "X11 DestroyWindow failed while cleaning it up after setup failure.",
                 xcb.destroy_window(x_window),
             )?;
-            xcb.flush()
-                .with_context(|| "X11 Flush failed while cleaning it up after setup failure.")?;
+            xcb_flush(&xcb);
         }
 
         setup_result
@@ -656,28 +679,6 @@ impl X11WindowState {
     }
 }
 
-/// A handle to an X11 window which destroys it on Drop.
-pub struct X11WindowHandle {
-    id: xproto::Window,
-    xcb: Rc<XCBConnection>,
-}
-
-impl Drop for X11WindowHandle {
-    fn drop(&mut self) {
-        maybe!({
-            check_reply(
-                || "X11 DestroyWindow failed while dropping X11WindowHandle.",
-                self.xcb.destroy_window(self.id),
-            )?;
-            self.xcb
-                .flush()
-                .with_context(|| "X11 Flush failed while dropping X11WindowHandle.")?;
-            anyhow::Ok(())
-        })
-        .log_err();
-    }
-}
-
 pub(crate) struct X11Window(pub X11WindowStatePtr);
 
 impl Drop for X11Window {
@@ -690,10 +691,7 @@ impl Drop for X11Window {
                 || "X11 DestroyWindow failure.",
                 self.0.xcb.destroy_window(self.0.x_window),
             )?;
-            self.0
-                .xcb
-                .flush()
-                .with_context(|| "X11 Flush failed after calling DestroyWindow.")?;
+            xcb_flush(&self.0.xcb);
 
             anyhow::Ok(())
         })
@@ -785,10 +783,12 @@ impl X11Window {
             self.0.xcb.send_event(
                 false,
                 state.x_root_window,
-                EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY,
+                xproto::EventMask::SUBSTRUCTURE_REDIRECT | xproto::EventMask::SUBSTRUCTURE_NOTIFY,
                 message,
             ),
-        )
+        )?;
+        xcb_flush(&self.0.xcb);
+        Ok(())
     }
 
     fn get_root_position(
@@ -811,7 +811,7 @@ impl X11Window {
         let state = self.0.state.borrow();
 
         check_reply(
-            || "X11 UngrabPointer before move/resize of window ailed.",
+            || "X11 UngrabPointer before move/resize of window failed.",
             self.0.xcb.ungrab_pointer(x11rb::CURRENT_TIME),
         )?;
 
@@ -836,16 +836,13 @@ impl X11Window {
             self.0.xcb.send_event(
                 false,
                 state.x_root_window,
-                EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY,
+                xproto::EventMask::SUBSTRUCTURE_REDIRECT | xproto::EventMask::SUBSTRUCTURE_NOTIFY,
                 message,
             ),
         )?;
 
-        self.flush()
-    }
-
-    fn flush(&self) -> anyhow::Result<()> {
-        self.0.xcb.flush().with_context(|| "X11 Flush failed.")
+        xcb_flush(&self.0.xcb);
+        Ok(())
     }
 }
 
@@ -888,9 +885,13 @@ impl X11WindowStatePtr {
         )?;
 
         if reply.value_len != 0 {
-            let atom = u32::from_ne_bytes(reply.value[0..4].try_into().unwrap());
-            let edge_constraints = EdgeConstraints::from_atom(atom);
-            state.edge_constraints.replace(edge_constraints);
+            if let Ok(bytes) = reply.value[0..4].try_into() {
+                let atom = u32::from_ne_bytes(bytes);
+                let edge_constraints = EdgeConstraints::from_atom(atom);
+                state.edge_constraints.replace(edge_constraints);
+            } else {
+                log::error!("Failed to parse GTK_EDGE_CONSTRAINTS");
+            }
         }
 
         Ok(())
@@ -921,7 +922,7 @@ impl X11WindowStatePtr {
         state.fullscreen = false;
         state.maximized_vertical = false;
         state.maximized_horizontal = false;
-        state.hidden = true;
+        state.hidden = false;
 
         for atom in atoms {
             if atom == state.atoms._NET_WM_STATE_FOCUSED {
@@ -961,14 +962,17 @@ impl X11WindowStatePtr {
             }
         }
         if let PlatformInput::KeyDown(event) = input {
-            let mut state = self.state.borrow_mut();
-            if let Some(mut input_handler) = state.input_handler.take() {
-                if let Some(key_char) = &event.keystroke.key_char {
-                    drop(state);
-                    input_handler.replace_text_in_range(None, key_char);
-                    state = self.state.borrow_mut();
+            // only allow shift modifier when inserting text
+            if event.keystroke.modifiers.is_subset_of(&Modifiers::shift()) {
+                let mut state = self.state.borrow_mut();
+                if let Some(mut input_handler) = state.input_handler.take() {
+                    if let Some(key_char) = &event.keystroke.key_char {
+                        drop(state);
+                        input_handler.replace_text_in_range(None, key_char);
+                        state = self.state.borrow_mut();
+                    }
+                    state.input_handler = Some(input_handler);
                 }
-                state.input_handler = Some(input_handler);
             }
         }
     }
@@ -1029,7 +1033,7 @@ impl X11WindowStatePtr {
         bounds
     }
 
-    pub fn configure(&self, bounds: Bounds<i32>) -> anyhow::Result<()> {
+    pub fn set_bounds(&self, bounds: Bounds<i32>) -> anyhow::Result<()> {
         let mut resize_args = None;
         let is_resize;
         {
@@ -1179,7 +1183,7 @@ impl PlatformWindow for X11Window {
             ),
         )
         .log_err();
-        self.flush().log_err();
+        xcb_flush(&self.0.xcb);
     }
 
     fn scale_factor(&self) -> f32 {
@@ -1195,12 +1199,14 @@ impl PlatformWindow for X11Window {
     }
 
     fn mouse_position(&self) -> Point<Pixels> {
-        let reply = get_reply(
+        get_reply(
             || "X11 QueryPointer failed.",
             self.0.xcb.query_pointer(self.0.x_window),
         )
-        .unwrap();
-        Point::new((reply.root_x as u32).into(), (reply.root_y as u32).into())
+        .log_err()
+        .map_or(Point::new(Pixels(0.0), Pixels(0.0)), |reply| {
+            Point::new((reply.root_x as u32).into(), (reply.root_y as u32).into())
+        })
     }
 
     fn modifiers(&self) -> Modifiers {
@@ -1214,6 +1220,17 @@ impl PlatformWindow for X11Window {
             .unwrap_or_default()
     }
 
+    fn capslock(&self) -> crate::Capslock {
+        self.0
+            .state
+            .borrow()
+            .client
+            .0
+            .upgrade()
+            .map(|ref_cell| ref_cell.borrow().capslock)
+            .unwrap_or_default()
+    }
+
     fn set_input_handler(&mut self, input_handler: PlatformInputHandler) {
         self.0.state.borrow_mut().input_handler = Some(input_handler);
     }
@@ -1227,7 +1244,7 @@ impl PlatformWindow for X11Window {
         _level: PromptLevel,
         _msg: &str,
         _detail: Option<&str>,
-        _answers: &[&str],
+        _answers: &[PromptButton],
     ) -> Option<futures::channel::oneshot::Receiver<usize>> {
         None
     }
@@ -1257,7 +1274,7 @@ impl PlatformWindow for X11Window {
                 xproto::Time::CURRENT_TIME,
             )
             .log_err();
-        self.flush().unwrap();
+        xcb_flush(&self.0.xcb);
     }
 
     fn is_active(&self) -> bool {
@@ -1292,7 +1309,7 @@ impl PlatformWindow for X11Window {
             ),
         )
         .log_err();
-        self.flush().log_err();
+        xcb_flush(&self.0.xcb);
     }
 
     fn set_app_id(&mut self, app_id: &str) {
@@ -1311,7 +1328,7 @@ impl PlatformWindow for X11Window {
                 &data,
             ),
         )
-        .unwrap();
+        .log_err();
     }
 
     fn map_window(&mut self) -> anyhow::Result<()> {
@@ -1322,10 +1339,6 @@ impl PlatformWindow for X11Window {
         Ok(())
     }
 
-    fn set_edited(&mut self, _edited: bool) {
-        log::info!("ignoring macOS specific set_edited");
-    }
-
     fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance) {
         let mut state = self.0.state.borrow_mut();
         state.background_appearance = background_appearance;
@@ -1333,10 +1346,6 @@ impl PlatformWindow for X11Window {
         state.renderer.update_transparency(transparent);
     }
 
-    fn show_character_palette(&self) {
-        log::info!("ignoring macOS specific show_character_palette");
-    }
-
     fn minimize(&self) {
         let state = self.0.state.borrow();
         const WINDOW_ICONIC_STATE: u32 = 3;
@@ -1351,11 +1360,11 @@ impl PlatformWindow for X11Window {
             self.0.xcb.send_event(
                 false,
                 state.x_root_window,
-                EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY,
+                xproto::EventMask::SUBSTRUCTURE_REDIRECT | xproto::EventMask::SUBSTRUCTURE_NOTIFY,
                 message,
             ),
         )
-        .unwrap();
+        .log_err();
     }
 
     fn zoom(&self) {
@@ -1366,7 +1375,7 @@ impl PlatformWindow for X11Window {
             state.atoms._NET_WM_STATE_MAXIMIZED_VERT,
             state.atoms._NET_WM_STATE_MAXIMIZED_HORZ,
         )
-        .unwrap();
+        .log_err();
     }
 
     fn toggle_fullscreen(&self) {
@@ -1377,7 +1386,7 @@ impl PlatformWindow for X11Window {
             state.atoms._NET_WM_STATE_FULLSCREEN,
             xproto::AtomEnum::NONE.into(),
         )
-        .unwrap();
+        .log_err();
     }
 
     fn is_fullscreen(&self) -> bool {
@@ -1416,6 +1425,9 @@ impl PlatformWindow for X11Window {
         self.0.callbacks.borrow_mut().close = Some(callback);
     }
 
+    fn on_hit_test_window_control(&self, _callback: Box<dyn FnMut() -> Option<WindowControlArea>>) {
+    }
+
     fn on_appearance_changed(&self, callback: Box<dyn FnMut()>) {
         self.0.callbacks.borrow_mut().appearance_changed = Some(callback);
     }
@@ -1437,9 +1449,11 @@ impl PlatformWindow for X11Window {
             || "X11 UngrabPointer failed.",
             self.0.xcb.ungrab_pointer(x11rb::CURRENT_TIME),
         )
-        .unwrap();
+        .log_err();
 
-        let coords = self.get_root_position(position).unwrap();
+        let Some(coords) = self.get_root_position(position).log_err() else {
+            return;
+        };
         let message = ClientMessageEvent::new(
             32,
             self.0.x_window,
@@ -1457,20 +1471,20 @@ impl PlatformWindow for X11Window {
             self.0.xcb.send_event(
                 false,
                 state.x_root_window,
-                EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY,
+                xproto::EventMask::SUBSTRUCTURE_REDIRECT | xproto::EventMask::SUBSTRUCTURE_NOTIFY,
                 message,
             ),
         )
-        .unwrap();
+        .log_err();
     }
 
     fn start_window_move(&self) {
         const MOVERESIZE_MOVE: u32 = 8;
-        self.send_moveresize(MOVERESIZE_MOVE).unwrap();
+        self.send_moveresize(MOVERESIZE_MOVE).log_err();
     }
 
     fn start_window_resize(&self, edge: ResizeEdge) {
-        self.send_moveresize(edge.to_moveresize()).unwrap();
+        self.send_moveresize(edge.to_moveresize()).log_err();
     }
 
     fn window_decorations(&self) -> crate::Decorations {
@@ -1545,7 +1559,7 @@ impl PlatformWindow for X11Window {
                     bytemuck::cast_slice::<u32, u8>(&insets),
                 ),
             )
-            .unwrap();
+            .log_err();
         }
     }
 
@@ -1567,7 +1581,7 @@ impl PlatformWindow for X11Window {
             WindowDecorations::Client => [1 << 1, 0, 0, 0, 0],
         };
 
-        check_reply(
+        let success = check_reply(
             || "X11 ChangeProperty for _MOTIF_WM_HINTS failed.",
             self.0.xcb.change_property(
                 xproto::PropMode::REPLACE,
@@ -1579,7 +1593,11 @@ impl PlatformWindow for X11Window {
                 bytemuck::cast_slice::<u32, u8>(&hints_data),
             ),
         )
-        .unwrap();
+        .log_err();
+
+        let Some(()) = success else {
+            return;
+        };
 
         match decorations {
             WindowDecorations::Server => {

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

@@ -12,7 +12,7 @@ use std::ffi::c_void;
 use util::ResultExt;
 
 pub struct DisplayLink {
-    display_link: sys::DisplayLink,
+    display_link: Option<sys::DisplayLink>,
     frame_requests: dispatch_source_t,
 }
 
@@ -59,7 +59,7 @@ impl DisplayLink {
             )?;
 
             Ok(Self {
-                display_link,
+                display_link: Some(display_link),
                 frame_requests,
             })
         }
@@ -70,7 +70,7 @@ impl DisplayLink {
             dispatch_resume(crate::dispatch_sys::dispatch_object_t {
                 _ds: self.frame_requests,
             });
-            self.display_link.start()?;
+            self.display_link.as_mut().unwrap().start()?;
         }
         Ok(())
     }
@@ -80,7 +80,7 @@ impl DisplayLink {
             dispatch_suspend(crate::dispatch_sys::dispatch_object_t {
                 _ds: self.frame_requests,
             });
-            self.display_link.stop()?;
+            self.display_link.as_mut().unwrap().stop()?;
         }
         Ok(())
     }
@@ -89,6 +89,14 @@ impl DisplayLink {
 impl Drop for DisplayLink {
     fn drop(&mut self) {
         self.stop().log_err();
+        // We see occasional segfaults on the CVDisplayLink thread.
+        //
+        // It seems possible that this happens because CVDisplayLinkRelease releases the CVDisplayLink
+        // on the main thread immediately, but the background thread that CVDisplayLink uses for timers
+        // is still accessing it.
+        //
+        // We might also want to upgrade to CADisplayLink, but that requires dropping old macOS support.
+        std::mem::forget(self.display_link.take());
         unsafe {
             dispatch_source_cancel(self.frame_requests);
         }

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

@@ -1,5 +1,5 @@
 use crate::{
-    KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton,
+    Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton,
     MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels,
     PlatformInput, ScrollDelta, ScrollWheelEvent, TouchPhase,
     platform::mac::{
@@ -21,15 +21,16 @@ const BACKSPACE_KEY: u16 = 0x7f;
 const SPACE_KEY: u16 = b' ' as u16;
 const ENTER_KEY: u16 = 0x0d;
 const NUMPAD_ENTER_KEY: u16 = 0x03;
-const ESCAPE_KEY: u16 = 0x1b;
+pub(crate) const ESCAPE_KEY: u16 = 0x1b;
 const TAB_KEY: u16 = 0x09;
 const SHIFT_TAB_KEY: u16 = 0x19;
 
-pub fn key_to_native(key: &str) -> Cow<str> {
+pub fn key_to_native(key: &str) -> Cow<'_, str> {
     use cocoa::appkit::*;
     let code = match key {
         "space" => SPACE_KEY,
         "backspace" => BACKSPACE_KEY,
+        "escape" => ESCAPE_KEY,
         "up" => NSUpArrowFunctionKey,
         "down" => NSDownArrowFunctionKey,
         "left" => NSLeftArrowFunctionKey,
@@ -120,6 +121,11 @@ impl PlatformInput {
                 NSEventType::NSFlagsChanged => {
                     Some(Self::ModifiersChanged(ModifiersChangedEvent {
                         modifiers: read_modifiers(native_event),
+                        capslock: Capslock {
+                            on: native_event
+                                .modifierFlags()
+                                .contains(NSEventModifierFlags::NSAlphaShiftKeyMask),
+                        },
                     }))
                 }
                 NSEventType::NSKeyDown => Some(Self::KeyDown(KeyDownEvent {

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

@@ -2,7 +2,7 @@ use crate::{
     AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTile, Bounds, DevicePixels, PlatformAtlas,
     Point, Size, platform::AtlasTextureList,
 };
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use collections::FxHashMap;
 use derive_more::{Deref, DerefMut};
 use etagere::BucketedAtlasAllocator;
@@ -77,7 +77,7 @@ impl PlatformAtlas for MetalAtlas {
             };
             let tile = lock
                 .allocate(size, key.texture_kind())
-                .ok_or_else(|| anyhow!("failed to allocate"))?;
+                .context("failed to allocate")?;
             let texture = lock.texture(tile.texture_id);
             texture.upload(tile.bounds, &bytes);
             lock.tiles_by_key.insert(key.clone(), tile.clone());

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

@@ -4,7 +4,7 @@ use crate::{
     MonochromeSprite, PaintSurface, Path, PathId, PathVertex, PolychromeSprite, PrimitiveBatch,
     Quad, ScaledPixels, Scene, Shadow, Size, Surface, Underline, point, size,
 };
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use block::ConcreteBlock;
 use cocoa::{
     base::{NO, YES},
@@ -376,14 +376,14 @@ impl MetalRenderer {
         let command_buffer = command_queue.new_command_buffer();
         let mut instance_offset = 0;
 
-        let Some(path_tiles) = self.rasterize_paths(
-            scene.paths(),
-            instance_buffer,
-            &mut instance_offset,
-            command_buffer,
-        ) else {
-            return Err(anyhow!("failed to rasterize {} paths", scene.paths().len()));
-        };
+        let path_tiles = self
+            .rasterize_paths(
+                scene.paths(),
+                instance_buffer,
+                &mut instance_offset,
+                command_buffer,
+            )
+            .with_context(|| format!("rasterizing {} paths", scene.paths().len()))?;
 
         let render_pass_descriptor = metal::RenderPassDescriptor::new();
         let color_attachment = render_pass_descriptor
@@ -471,7 +471,7 @@ impl MetalRenderer {
 
             if !ok {
                 command_encoder.end_encoding();
-                return Err(anyhow!(
+                anyhow::bail!(
                     "scene too large: {} paths, {} shadows, {} quads, {} underlines, {} mono, {} poly, {} surfaces",
                     scene.paths.len(),
                     scene.shadows.len(),
@@ -480,7 +480,7 @@ impl MetalRenderer {
                     scene.monochrome_sprites.len(),
                     scene.polychrome_sprites.len(),
                     scene.surfaces.len(),
-                ));
+                );
             }
         }
 

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

@@ -6,8 +6,8 @@ use super::{
 };
 use crate::{
     Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString,
-    CursorStyle, ForegroundExecutor, Image, ImageFormat, Keymap, MacDispatcher, MacDisplay,
-    MacWindow, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay,
+    CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher,
+    MacDisplay, MacWindow, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay,
     PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, Result, ScreenCaptureSource,
     SemanticVersion, Task, WindowAppearance, WindowParams, hash,
 };
@@ -36,6 +36,7 @@ use core_foundation::{
 };
 use ctor::ctor;
 use futures::channel::oneshot;
+use itertools::Itertools;
 use objc::{
     class,
     declare::ClassDecl,
@@ -46,7 +47,7 @@ use objc::{
 use parking_lot::Mutex;
 use ptr::null_mut;
 use std::{
-    cell::Cell,
+    cell::{Cell, LazyCell},
     convert::TryInto,
     ffi::{CStr, OsStr, c_void},
     os::{raw::c_char, unix::ffi::OsStrExt},
@@ -293,6 +294,19 @@ impl MacPlatform {
         actions: &mut Vec<Box<dyn Action>>,
         keymap: &Keymap,
     ) -> id {
+        const DEFAULT_CONTEXT: LazyCell<Vec<KeyContext>> = LazyCell::new(|| {
+            let mut workspace_context = KeyContext::new_with_defaults();
+            workspace_context.add("Workspace");
+            let mut pane_context = KeyContext::new_with_defaults();
+            pane_context.add("Pane");
+            let mut editor_context = KeyContext::new_with_defaults();
+            editor_context.add("Editor");
+
+            pane_context.extend(&editor_context);
+            workspace_context.extend(&pane_context);
+            vec![workspace_context]
+        });
+
         unsafe {
             match item {
                 MenuItem::Separator => NSMenuItem::separatorItem(nil),
@@ -301,10 +315,17 @@ impl MacPlatform {
                     action,
                     os_action,
                 } => {
-                    let keystrokes = crate::Keymap::binding_to_display_from_bindings_iterator(
-                        keymap.bindings_for_action(action.as_ref()),
-                    )
-                    .map(|binding| binding.keystrokes());
+                    // Note that this is intentionally using earlier bindings, whereas typically
+                    // later ones take display precedence. See the discussion on
+                    // https://github.com/zed-industries/zed/issues/23621
+                    let keystrokes = keymap
+                        .bindings_for_action(action.as_ref())
+                        .find_or_first(|binding| {
+                            binding
+                                .predicate()
+                                .is_none_or(|predicate| predicate.eval(&DEFAULT_CONTEXT))
+                        })
+                        .map(|binding| binding.keystrokes());
 
                     let selector = match os_action {
                         Some(crate::OsAction::Cut) => selector("cut:"),
@@ -638,7 +659,7 @@ impl Platform for MacPlatform {
                     Ok(())
                 } else {
                     let msg: id = msg_send![error, localizedDescription];
-                    Err(anyhow!("Failed to register: {:?}", msg))
+                    Err(anyhow!("Failed to register: {msg:?}"))
                 };
 
                 if let Some(done_tx) = done_tx.take() {
@@ -832,11 +853,8 @@ impl Platform for MacPlatform {
     fn app_path(&self) -> Result<PathBuf> {
         unsafe {
             let bundle: id = NSBundle::mainBundle();
-            if bundle.is_null() {
-                Err(anyhow!("app is not running inside a bundle"))
-            } else {
-                Ok(path_from_objc(msg_send![bundle, bundlePath]))
-            }
+            anyhow::ensure!(!bundle.is_null(), "app is not running inside a bundle");
+            Ok(path_from_objc(msg_send![bundle, bundlePath]))
         }
     }
 
@@ -877,17 +895,11 @@ impl Platform for MacPlatform {
     fn path_for_auxiliary_executable(&self, name: &str) -> Result<PathBuf> {
         unsafe {
             let bundle: id = NSBundle::mainBundle();
-            if bundle.is_null() {
-                Err(anyhow!("app is not running inside a bundle"))
-            } else {
-                let name = ns_string(name);
-                let url: id = msg_send![bundle, URLForAuxiliaryExecutable: name];
-                if url.is_null() {
-                    Err(anyhow!("resource not found"))
-                } else {
-                    ns_url_to_path(url)
-                }
-            }
+            anyhow::ensure!(!bundle.is_null(), "app is not running inside a bundle");
+            let name = ns_string(name);
+            let url: id = msg_send![bundle, URLForAuxiliaryExecutable: name];
+            anyhow::ensure!(!url.is_null(), "resource not found");
+            ns_url_to_path(url)
         }
     }
 
@@ -1101,10 +1113,7 @@ impl Platform for MacPlatform {
                     verb = "creating";
                     status = SecItemAdd(attrs.as_concrete_TypeRef(), ptr::null_mut());
                 }
-
-                if status != errSecSuccess {
-                    return Err(anyhow!("{} password failed: {}", verb, status));
-                }
+                anyhow::ensure!(status == errSecSuccess, "{verb} password failed: {status}");
             }
             Ok(())
         })
@@ -1131,24 +1140,24 @@ impl Platform for MacPlatform {
                 match status {
                     security::errSecSuccess => {}
                     security::errSecItemNotFound | security::errSecUserCanceled => return Ok(None),
-                    _ => return Err(anyhow!("reading password failed: {}", status)),
+                    _ => anyhow::bail!("reading password failed: {status}"),
                 }
 
                 let result = CFType::wrap_under_create_rule(result)
                     .downcast::<CFDictionary>()
-                    .ok_or_else(|| anyhow!("keychain item was not a dictionary"))?;
+                    .context("keychain item was not a dictionary")?;
                 let username = result
                     .find(kSecAttrAccount as *const _)
-                    .ok_or_else(|| anyhow!("account was missing from keychain item"))?;
+                    .context("account was missing from keychain item")?;
                 let username = CFType::wrap_under_get_rule(*username)
                     .downcast::<CFString>()
-                    .ok_or_else(|| anyhow!("account was not a string"))?;
+                    .context("account was not a string")?;
                 let password = result
                     .find(kSecValueData as *const _)
-                    .ok_or_else(|| anyhow!("password was missing from keychain item"))?;
+                    .context("password was missing from keychain item")?;
                 let password = CFType::wrap_under_get_rule(*password)
                     .downcast::<CFData>()
-                    .ok_or_else(|| anyhow!("password was not a string"))?;
+                    .context("password was not a string")?;
 
                 Ok(Some((username.to_string(), password.bytes().to_vec())))
             }
@@ -1168,10 +1177,7 @@ impl Platform for MacPlatform {
                 query_attrs.set(kSecAttrServer as *const _, url.as_CFTypeRef());
 
                 let status = SecItemDelete(query_attrs.as_concrete_TypeRef());
-
-                if status != errSecSuccess {
-                    return Err(anyhow!("delete password failed: {}", status));
-                }
+                anyhow::ensure!(status == errSecSuccess, "delete password failed: {status}");
             }
             Ok(())
         })
@@ -1455,15 +1461,12 @@ unsafe fn ns_string(string: &str) -> id {
 
 unsafe fn ns_url_to_path(url: id) -> Result<PathBuf> {
     let path: *mut c_char = msg_send![url, fileSystemRepresentation];
-    if path.is_null() {
-        Err(anyhow!("url is not a file path: {}", unsafe {
-            CStr::from_ptr(url.absoluteString().UTF8String()).to_string_lossy()
-        }))
-    } else {
-        Ok(PathBuf::from(OsStr::from_bytes(unsafe {
-            CStr::from_ptr(path).to_bytes()
-        })))
-    }
+    anyhow::ensure!(!path.is_null(), "url is not a file path: {}", unsafe {
+        CStr::from_ptr(url.absoluteString().UTF8String()).to_string_lossy()
+    });
+    Ok(PathBuf::from(OsStr::from_bytes(unsafe {
+        CStr::from_ptr(path).to_bytes()
+    })))
 }
 
 #[link(name = "Carbon", kind = "framework")]

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

@@ -194,7 +194,7 @@ impl MacTextSystemState {
                         core_graphics::data_provider::CGDataProvider::from_slice(embedded_font)
                     };
                     let font = core_graphics::font::CGFont::from_data_provider(data_provider)
-                        .map_err(|_| anyhow!("Could not load an embedded font."))?;
+                        .map_err(|()| anyhow!("Could not load an embedded font."))?;
                     let font = font_kit::loaders::core_text::Font::from_core_graphics_font(font);
                     Ok(Handle::from_native(&font))
                 }
@@ -348,7 +348,7 @@ impl MacTextSystemState {
         glyph_bounds: Bounds<DevicePixels>,
     ) -> Result<(Size<DevicePixels>, Vec<u8>)> {
         if glyph_bounds.size.width.0 == 0 || glyph_bounds.size.height.0 == 0 {
-            Err(anyhow!("glyph bounds are empty"))
+            anyhow::bail!("glyph bounds are empty");
         } else {
             // Add an extra pixel when the subpixel variant isn't zero to make room for anti-aliasing.
             let mut bitmap_size = glyph_bounds.size;
@@ -480,7 +480,7 @@ impl MacTextSystemState {
             };
             let font_id = self.id_for_native_font(font);
 
-            let mut glyphs = SmallVec::new();
+            let mut glyphs = Vec::with_capacity(run.glyph_count().try_into().unwrap_or(0));
             for ((glyph_id, position), glyph_utf16_ix) in run
                 .glyphs()
                 .iter()

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

@@ -1,24 +1,27 @@
 use super::{BoolExt, MacDisplay, NSRange, NSStringExt, ns_string, renderer};
 use crate::{
-    AnyWindowHandle, Bounds, DisplayLink, ExternalPaths, FileDropEvent, ForegroundExecutor,
-    KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent,
-    MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput,
-    PlatformWindow, Point, PromptLevel, RequestFrameOptions, ScaledPixels, Size, Timer,
-    WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowKind, WindowParams,
-    platform::PlatformInputHandler, point, px, size,
+    AnyWindowHandle, Bounds, Capslock, DisplayLink, ExternalPaths, FileDropEvent,
+    ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton,
+    MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay,
+    PlatformInput, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions,
+    ScaledPixels, Size, Timer, WindowAppearance, WindowBackgroundAppearance, WindowBounds,
+    WindowControlArea, WindowKind, WindowParams, platform::PlatformInputHandler, point, px, size,
 };
 use block::ConcreteBlock;
 use cocoa::{
     appkit::{
-        NSApplication, NSBackingStoreBuffered, NSColor, NSEvent, NSEventModifierFlags,
-        NSFilenamesPboardType, NSPasteboard, NSScreen, NSView, NSViewHeightSizable,
-        NSViewWidthSizable, NSWindow, NSWindowButton, NSWindowCollectionBehavior,
-        NSWindowOcclusionState, NSWindowStyleMask, NSWindowTitleVisibility,
+        NSAppKitVersionNumber, NSAppKitVersionNumber12_0, NSApplication, NSBackingStoreBuffered,
+        NSColor, NSEvent, NSEventModifierFlags, NSFilenamesPboardType, NSPasteboard, NSScreen,
+        NSView, NSViewHeightSizable, NSViewWidthSizable, NSVisualEffectMaterial,
+        NSVisualEffectState, NSVisualEffectView, NSWindow, NSWindowButton,
+        NSWindowCollectionBehavior, NSWindowOcclusionState, NSWindowOrderingMode,
+        NSWindowStyleMask, NSWindowTitleVisibility,
     },
     base::{id, nil},
     foundation::{
         NSArray, NSAutoreleasePool, NSDictionary, NSFastEnumeration, NSInteger, NSNotFound,
         NSOperatingSystemVersion, NSPoint, NSProcessInfo, NSRect, NSSize, NSString, NSUInteger,
+        NSUserDefaults,
     },
 };
 use core_graphics::display::{CGDirectDisplayID, CGPoint, CGRect};
@@ -52,6 +55,7 @@ const WINDOW_STATE_IVAR: &str = "windowState";
 static mut WINDOW_CLASS: *const Class = ptr::null();
 static mut PANEL_CLASS: *const Class = ptr::null();
 static mut VIEW_CLASS: *const Class = ptr::null();
+static mut BLURRED_VIEW_CLASS: *const Class = ptr::null();
 
 #[allow(non_upper_case_globals)]
 const NSWindowStyleMaskNonactivatingPanel: NSWindowStyleMask =
@@ -240,6 +244,20 @@ unsafe fn build_classes() {
             }
             decl.register()
         };
+        BLURRED_VIEW_CLASS = {
+            let mut decl = ClassDecl::new("BlurredView", class!(NSVisualEffectView)).unwrap();
+            unsafe {
+                decl.add_method(
+                    sel!(initWithFrame:),
+                    blurred_view_init_with_frame as extern "C" fn(&Object, Sel, NSRect) -> id,
+                );
+                decl.add_method(
+                    sel!(updateLayer),
+                    blurred_view_update_layer as extern "C" fn(&Object, Sel),
+                );
+                decl.register()
+            }
+        };
     }
 }
 
@@ -334,6 +352,7 @@ struct MacWindowState {
     executor: ForegroundExecutor,
     native_window: id,
     native_view: NonNull<Object>,
+    blurred_view: Option<id>,
     display_link: Option<DisplayLink>,
     renderer: renderer::Renderer,
     request_frame_callback: Option<Box<dyn FnMut(RequestFrameOptions)>>,
@@ -599,8 +618,9 @@ impl MacWindow {
                 setReleasedWhenClosed: NO
             ];
 
+            let content_view = native_window.contentView();
             let native_view: id = msg_send![VIEW_CLASS, alloc];
-            let native_view = NSView::init(native_view);
+            let native_view = NSView::initWithFrame_(native_view, NSView::bounds(content_view));
             assert!(!native_view.is_null());
 
             let mut window = Self(Arc::new(Mutex::new(MacWindowState {
@@ -608,6 +628,7 @@ impl MacWindow {
                 executor,
                 native_window,
                 native_view: NonNull::new_unchecked(native_view),
+                blurred_view: None,
                 display_link: None,
                 renderer: renderer::new_renderer(
                     renderer_context,
@@ -682,11 +703,11 @@ impl MacWindow {
             // itself and break the association with its context.
             native_view.setWantsLayer(YES);
             let _: () = msg_send![
-                native_view,
-                setLayerContentsRedrawPolicy: NSViewLayerContentsRedrawDuringViewResize
+            native_view,
+            setLayerContentsRedrawPolicy: NSViewLayerContentsRedrawDuringViewResize
             ];
 
-            native_window.setContentView_(native_view.autorelease());
+            content_view.addSubview_(native_view.autorelease());
             native_window.makeFirstResponder_(native_view);
 
             match kind {
@@ -844,6 +865,9 @@ impl PlatformWindow for MacWindow {
     fn display(&self) -> Option<Rc<dyn PlatformDisplay>> {
         unsafe {
             let screen = self.0.lock().native_window.screen();
+            if screen.is_null() {
+                return None;
+            }
             let device_description: id = msg_send![screen, deviceDescription];
             let screen_number: id = NSDictionary::valueForKey_(
                 device_description,
@@ -886,6 +910,16 @@ impl PlatformWindow for MacWindow {
         }
     }
 
+    fn capslock(&self) -> Capslock {
+        unsafe {
+            let modifiers: NSEventModifierFlags = msg_send![class!(NSEvent), modifierFlags];
+
+            Capslock {
+                on: modifiers.contains(NSEventModifierFlags::NSAlphaShiftKeyMask),
+            }
+        }
+    }
+
     fn set_input_handler(&mut self, input_handler: PlatformInputHandler) {
         self.0.as_ref().lock().input_handler = Some(input_handler);
     }
@@ -899,7 +933,7 @@ impl PlatformWindow for MacWindow {
         level: PromptLevel,
         msg: &str,
         detail: Option<&str>,
-        answers: &[&str],
+        answers: &[PromptButton],
     ) -> Option<oneshot::Receiver<usize>> {
         // macOs applies overrides to modal window buttons after they are added.
         // Two most important for this logic are:
@@ -923,7 +957,7 @@ impl PlatformWindow for MacWindow {
             .iter()
             .enumerate()
             .rev()
-            .find(|(_, label)| **label != "Cancel")
+            .find(|(_, label)| !label.is_cancel())
             .filter(|&(label_index, _)| label_index > 0);
 
         unsafe {
@@ -945,11 +979,19 @@ impl PlatformWindow for MacWindow {
                 .enumerate()
                 .filter(|&(ix, _)| Some(ix) != latest_non_cancel_label.map(|(ix, _)| ix))
             {
-                let button: id = msg_send![alert, addButtonWithTitle: ns_string(answer)];
+                let button: id = msg_send![alert, addButtonWithTitle: ns_string(answer.label())];
                 let _: () = msg_send![button, setTag: ix as NSInteger];
+
+                if answer.is_cancel() {
+                    // Bind Escape Key to Cancel Button
+                    if let Some(key) = std::char::from_u32(super::events::ESCAPE_KEY as u32) {
+                        let _: () =
+                            msg_send![button, setKeyEquivalent: ns_string(&key.to_string())];
+                    }
+                }
             }
             if let Some((ix, answer)) = latest_non_cancel_label {
-                let button: id = msg_send![alert, addButtonWithTitle: ns_string(answer)];
+                let button: id = msg_send![alert, addButtonWithTitle: ns_string(answer.label())];
                 let _: () = msg_send![button, setTag: ix as NSInteger];
             }
 
@@ -1013,28 +1055,57 @@ impl PlatformWindow for MacWindow {
 
     fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance) {
         let mut this = self.0.as_ref().lock();
-        this.renderer
-            .update_transparency(background_appearance != WindowBackgroundAppearance::Opaque);
 
-        let blur_radius = if background_appearance == WindowBackgroundAppearance::Blurred {
-            80
-        } else {
-            0
-        };
-        let opaque = (background_appearance == WindowBackgroundAppearance::Opaque).to_objc();
+        let opaque = background_appearance == WindowBackgroundAppearance::Opaque;
+        this.renderer.update_transparency(!opaque);
 
         unsafe {
-            this.native_window.setOpaque_(opaque);
-            // Shadows for transparent windows cause artifacts and performance issues
-            this.native_window.setHasShadow_(opaque);
-            let clear_color = if opaque == YES {
+            this.native_window.setOpaque_(opaque as BOOL);
+            let background_color = if opaque {
                 NSColor::colorWithSRGBRed_green_blue_alpha_(nil, 0f64, 0f64, 0f64, 1f64)
             } else {
-                NSColor::clearColor(nil)
+                // Not using `+[NSColor clearColor]` to avoid broken shadow.
+                NSColor::colorWithSRGBRed_green_blue_alpha_(nil, 0f64, 0f64, 0f64, 0.0001)
             };
-            this.native_window.setBackgroundColor_(clear_color);
-            let window_number = this.native_window.windowNumber();
-            CGSSetWindowBackgroundBlurRadius(CGSMainConnectionID(), window_number, blur_radius);
+            this.native_window.setBackgroundColor_(background_color);
+
+            if NSAppKitVersionNumber < NSAppKitVersionNumber12_0 {
+                // Whether `-[NSVisualEffectView respondsToSelector:@selector(_updateProxyLayer)]`.
+                // On macOS Catalina/Big Sur `NSVisualEffectView` doesn’t own concrete sublayers
+                // but uses a `CAProxyLayer`. Use the legacy WindowServer API.
+                let blur_radius = if background_appearance == WindowBackgroundAppearance::Blurred {
+                    80
+                } else {
+                    0
+                };
+
+                let window_number = this.native_window.windowNumber();
+                CGSSetWindowBackgroundBlurRadius(CGSMainConnectionID(), window_number, blur_radius);
+            } else {
+                // On newer macOS `NSVisualEffectView` manages the effect layer directly. Using it
+                // could have a better performance (it downsamples the backdrop) and more control
+                // over the effect layer.
+                if background_appearance != WindowBackgroundAppearance::Blurred {
+                    if let Some(blur_view) = this.blurred_view {
+                        NSView::removeFromSuperview(blur_view);
+                        this.blurred_view = None;
+                    }
+                } else if this.blurred_view == None {
+                    let content_view = this.native_window.contentView();
+                    let frame = NSView::bounds(content_view);
+                    let mut blur_view: id = msg_send![BLURRED_VIEW_CLASS, alloc];
+                    blur_view = NSView::initWithFrame_(blur_view, frame);
+                    blur_view.setAutoresizingMask_(NSViewWidthSizable | NSViewHeightSizable);
+
+                    let _: () = msg_send![
+                        content_view,
+                        addSubview: blur_view
+                        positioned: NSWindowOrderingMode::NSWindowBelow
+                        relativeTo: nil
+                    ];
+                    this.blurred_view = Some(blur_view.autorelease());
+                }
+            }
         }
     }
 
@@ -1134,6 +1205,9 @@ impl PlatformWindow for MacWindow {
         self.0.as_ref().lock().close_callback = Some(callback);
     }
 
+    fn on_hit_test_window_control(&self, _callback: Box<dyn FnMut() -> Option<WindowControlArea>>) {
+    }
+
     fn on_appearance_changed(&self, callback: Box<dyn FnMut()>) {
         self.0.lock().appearance_changed_callback = Some(callback);
     }
@@ -1166,6 +1240,49 @@ impl PlatformWindow for MacWindow {
             })
             .detach()
     }
+
+    fn titlebar_double_click(&self) {
+        let this = self.0.lock();
+        let window = this.native_window;
+        this.executor
+            .spawn(async move {
+                unsafe {
+                    let defaults: id = NSUserDefaults::standardUserDefaults();
+                    let domain = NSString::alloc(nil).init_str("NSGlobalDomain");
+                    let key = NSString::alloc(nil).init_str("AppleActionOnDoubleClick");
+
+                    let dict: id = msg_send![defaults, persistentDomainForName: domain];
+                    let action: id = if !dict.is_null() {
+                        msg_send![dict, objectForKey: key]
+                    } else {
+                        nil
+                    };
+
+                    let action_str = if !action.is_null() {
+                        CStr::from_ptr(NSString::UTF8String(action)).to_string_lossy()
+                    } else {
+                        "".into()
+                    };
+
+                    match action_str.as_ref() {
+                        "Minimize" => {
+                            window.miniaturize_(nil);
+                        }
+                        "Maximize" => {
+                            window.zoom_(nil);
+                        }
+                        "Fill" => {
+                            // There is no documented API for "Fill" action, so we'll just zoom the window
+                            window.zoom_(nil);
+                        }
+                        _ => {
+                            window.zoom_(nil);
+                        }
+                    }
+                }
+            })
+            .detach();
+    }
 }
 
 impl rwh::HasWindowHandle for MacWindow {
@@ -1193,6 +1310,9 @@ impl rwh::HasDisplayHandle for MacWindow {
 fn get_scale_factor(native_window: id) -> f32 {
     let factor = unsafe {
         let screen: id = msg_send![native_window, screen];
+        if screen.is_null() {
+            return 1.0;
+        }
         NSScreen::backingScaleFactor(screen) as f32
     };
 
@@ -1495,13 +1615,17 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) {
                 lock.synthetic_drag_counter += 1;
             }
 
-            PlatformInput::ModifiersChanged(ModifiersChangedEvent { modifiers }) => {
+            PlatformInput::ModifiersChanged(ModifiersChangedEvent {
+                modifiers,
+                capslock,
+            }) => {
                 // Only raise modifiers changed event when they have actually changed
                 if let Some(PlatformInput::ModifiersChanged(ModifiersChangedEvent {
                     modifiers: prev_modifiers,
+                    capslock: prev_capslock,
                 })) = &lock.previous_modifiers_changed_event
                 {
-                    if prev_modifiers == modifiers {
+                    if prev_modifiers == modifiers && prev_capslock == capslock {
                         return;
                     }
                 }
@@ -1688,7 +1812,12 @@ extern "C" fn set_frame_size(this: &Object, _: Sel, size: NSSize) {
     let mut lock = window_state.as_ref().lock();
 
     let new_size = Size::<Pixels>::from(size);
-    if lock.content_size() == new_size {
+    let old_size = unsafe {
+        let old_frame: NSRect = msg_send![this, frame];
+        Size::<Pixels>::from(old_frame.size)
+    };
+
+    if old_size == new_size {
         return;
     }
 
@@ -2073,3 +2202,75 @@ unsafe fn display_id_for_screen(screen: id) -> CGDirectDisplayID {
         screen_number as CGDirectDisplayID
     }
 }
+
+extern "C" fn blurred_view_init_with_frame(this: &Object, _: Sel, frame: NSRect) -> id {
+    unsafe {
+        let view = msg_send![super(this, class!(NSVisualEffectView)), initWithFrame: frame];
+        // Use a colorless semantic material. The default value `AppearanceBased`, though not
+        // manually set, is deprecated.
+        NSVisualEffectView::setMaterial_(view, NSVisualEffectMaterial::Selection);
+        NSVisualEffectView::setState_(view, NSVisualEffectState::Active);
+        view
+    }
+}
+
+extern "C" fn blurred_view_update_layer(this: &Object, _: Sel) {
+    unsafe {
+        let _: () = msg_send![super(this, class!(NSVisualEffectView)), updateLayer];
+        let layer: id = msg_send![this, layer];
+        if !layer.is_null() {
+            remove_layer_background(layer);
+        }
+    }
+}
+
+unsafe fn remove_layer_background(layer: id) {
+    unsafe {
+        let _: () = msg_send![layer, setBackgroundColor:nil];
+
+        let class_name: id = msg_send![layer, className];
+        if class_name.isEqualToString("CAChameleonLayer") {
+            // Remove the desktop tinting effect.
+            let _: () = msg_send![layer, setHidden: YES];
+            return;
+        }
+
+        let filters: id = msg_send![layer, filters];
+        if !filters.is_null() {
+            // Remove the increased saturation.
+            // The effect of a `CAFilter` or `CIFilter` is determined by its name, and the
+            // `description` reflects its name and some parameters. Currently `NSVisualEffectView`
+            // uses a `CAFilter` named "colorSaturate". If one day they switch to `CIFilter`, the
+            // `description` will still contain "Saturat" ("... inputSaturation = ...").
+            let test_string: id = NSString::alloc(nil).init_str("Saturat").autorelease();
+            let count = NSArray::count(filters);
+            for i in 0..count {
+                let description: id = msg_send![filters.objectAtIndex(i), description];
+                let hit: BOOL = msg_send![description, containsString: test_string];
+                if hit == NO {
+                    continue;
+                }
+
+                let all_indices = NSRange {
+                    location: 0,
+                    length: count,
+                };
+                let indices: id = msg_send![class!(NSMutableIndexSet), indexSet];
+                let _: () = msg_send![indices, addIndexesInRange: all_indices];
+                let _: () = msg_send![indices, removeIndex:i];
+                let filtered: id = msg_send![filters, objectsAtIndexes: indices];
+                let _: () = msg_send![layer, setFilters: filtered];
+                break;
+            }
+        }
+
+        let sublayers: id = msg_send![layer, sublayers];
+        if !sublayers.is_null() {
+            let count = NSArray::count(sublayers);
+            for i in 0..count {
+                let sublayer = sublayers.objectAtIndex(i);
+                remove_layer_background(sublayer);
+            }
+        }
+    }
+}

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

@@ -1,8 +1,8 @@
 use crate::{
     AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels,
     ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, PlatformKeyboardLayout,
-    PlatformTextSystem, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, Size, Task,
-    TestDisplay, TestWindow, WindowAppearance, WindowParams, size,
+    PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream,
+    Size, Task, TestDisplay, TestWindow, WindowAppearance, WindowParams, size,
 };
 use anyhow::Result;
 use collections::VecDeque;
@@ -165,10 +165,10 @@ impl TestPlatform {
         &self,
         msg: &str,
         detail: Option<&str>,
-        answers: &[&str],
+        answers: &[PromptButton],
     ) -> oneshot::Receiver<usize> {
         let (tx, rx) = oneshot::channel();
-        let answers: Vec<String> = answers.iter().map(|&s| s.to_string()).collect();
+        let answers: Vec<String> = answers.iter().map(|s| s.label().to_string()).collect();
         self.background_executor()
             .set_waiting_hint(Some(format!("PROMPT: {:?} {:?}", msg, detail)));
         self.prompts
@@ -236,7 +236,7 @@ impl Platform for TestPlatform {
     fn quit(&self) {}
 
     fn restart(&self, _: Option<PathBuf>) {
-        unimplemented!()
+        //
     }
 
     fn activate(&self, _ignoring_other_apps: bool) {

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

@@ -1,8 +1,8 @@
 use crate::{
     AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DispatchEventResult, GpuSpecs,
     Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow,
-    Point, RequestFrameOptions, ScaledPixels, Size, TestPlatform, TileId, WindowAppearance,
-    WindowBackgroundAppearance, WindowBounds, WindowParams,
+    Point, PromptButton, RequestFrameOptions, ScaledPixels, Size, TestPlatform, TileId,
+    WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams,
 };
 use collections::HashMap;
 use parking_lot::Mutex;
@@ -21,6 +21,7 @@ pub(crate) struct TestWindowState {
     platform: Weak<TestPlatform>,
     sprite_atlas: Arc<dyn PlatformAtlas>,
     pub(crate) should_close_handler: Option<Box<dyn FnMut() -> bool>>,
+    hit_test_window_control_callback: Option<Box<dyn FnMut() -> Option<WindowControlArea>>>,
     input_callback: Option<Box<dyn FnMut(PlatformInput) -> DispatchEventResult>>,
     active_status_change_callback: Option<Box<dyn FnMut(bool)>>,
     hover_status_change_callback: Option<Box<dyn FnMut(bool)>>,
@@ -65,6 +66,7 @@ impl TestWindow {
             title: Default::default(),
             edited: false,
             should_close_handler: None,
+            hit_test_window_control_callback: None,
             input_callback: None,
             active_status_change_callback: None,
             hover_status_change_callback: None,
@@ -151,6 +153,10 @@ impl PlatformWindow for TestWindow {
         crate::Modifiers::default()
     }
 
+    fn capslock(&self) -> crate::Capslock {
+        crate::Capslock::default()
+    }
+
     fn set_input_handler(&mut self, input_handler: PlatformInputHandler) {
         self.0.lock().input_handler = Some(input_handler);
     }
@@ -164,7 +170,7 @@ impl PlatformWindow for TestWindow {
         _level: crate::PromptLevel,
         msg: &str,
         detail: Option<&str>,
-        answers: &[&str],
+        answers: &[PromptButton],
     ) -> Option<futures::channel::oneshot::Receiver<usize>> {
         Some(
             self.0
@@ -254,6 +260,10 @@ impl PlatformWindow for TestWindow {
 
     fn on_close(&self, _callback: Box<dyn FnOnce()>) {}
 
+    fn on_hit_test_window_control(&self, callback: Box<dyn FnMut() -> Option<WindowControlArea>>) {
+        self.0.lock().hit_test_window_control_callback = Some(callback);
+    }
+
     fn on_appearance_changed(&self, _callback: Box<dyn FnMut()>) {}
 
     fn draw(&self, _scene: &crate::Scene) {}

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

@@ -54,9 +54,7 @@ impl DockMenuItem {
                 },
                 action,
             }),
-            _ => Err(anyhow::anyhow!(
-                "Only `MenuItem::Action` is supported for dock menu on Windows."
-            )),
+            _ => anyhow::bail!("Only `MenuItem::Action` is supported for dock menu on Windows."),
         }
     }
 }
@@ -137,10 +135,7 @@ fn add_recent_folders(
         let tasks: IObjectCollection =
             CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?;
 
-        for folder_path in entries
-            .iter()
-            .filter(|path| !is_item_in_array(path, removed))
-        {
+        for folder_path in entries.iter().filter(|path| !removed.contains(path)) {
             let argument = HSTRING::from(
                 folder_path
                     .iter()
@@ -163,8 +158,8 @@ fn add_recent_folders(
                 .iter()
                 .map(|p| {
                     p.file_name()
-                        .map(|name| name.to_string_lossy().to_string())
-                        .unwrap_or_else(|| p.to_string_lossy().to_string())
+                        .map(|name| name.to_string_lossy())
+                        .unwrap_or_else(|| p.to_string_lossy())
                 })
                 .join(", ");
 
@@ -181,11 +176,6 @@ fn add_recent_folders(
     }
 }
 
-#[inline]
-fn is_item_in_array(item: &SmallVec<[PathBuf; 2]>, removed: &Vec<SmallVec<[PathBuf; 2]>>) -> bool {
-    removed.iter().any(|removed_item| removed_item == item)
-}
-
 fn create_shell_link(
     argument: HSTRING,
     description: HSTRING,

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

@@ -1,11 +1,10 @@
 use std::{borrow::Cow, sync::Arc};
 
 use ::util::ResultExt;
-use anyhow::{Result, anyhow};
+use anyhow::Result;
 use collections::HashMap;
 use itertools::Itertools;
 use parking_lot::{RwLock, RwLockUpgradableReadGuard};
-use smallvec::SmallVec;
 use windows::{
     Win32::{
         Foundation::*,
@@ -599,7 +598,6 @@ impl DirectWriteState {
                 text_system: self,
                 index_converter: StringIndexConverter::new(text),
                 runs: &mut runs,
-                utf16_index: 0,
                 width: 0.0,
             };
             text_layout.Draw(
@@ -730,7 +728,7 @@ impl DirectWriteState {
         glyph_bounds: Bounds<DevicePixels>,
     ) -> Result<(Size<DevicePixels>, Vec<u8>)> {
         if glyph_bounds.size.width.0 == 0 || glyph_bounds.size.height.0 == 0 {
-            return Err(anyhow!("glyph bounds are empty"));
+            anyhow::bail!("glyph bounds are empty");
         }
 
         let font_info = &self.fonts[params.font_id.0];
@@ -1004,10 +1002,65 @@ struct RendererContext<'t, 'a, 'b> {
     text_system: &'t mut DirectWriteState,
     index_converter: StringIndexConverter<'a>,
     runs: &'b mut Vec<ShapedRun>,
-    utf16_index: usize,
     width: f32,
 }
 
+#[derive(Debug)]
+struct ClusterAnalyzer<'t> {
+    utf16_idx: usize,
+    glyph_idx: usize,
+    glyph_count: usize,
+    cluster_map: &'t [u16],
+}
+
+impl<'t> ClusterAnalyzer<'t> {
+    pub fn new(cluster_map: &'t [u16], glyph_count: usize) -> Self {
+        ClusterAnalyzer {
+            utf16_idx: 0,
+            glyph_idx: 0,
+            glyph_count,
+            cluster_map,
+        }
+    }
+}
+
+impl Iterator for ClusterAnalyzer<'_> {
+    type Item = (usize, usize);
+
+    fn next(&mut self) -> Option<(usize, usize)> {
+        if self.utf16_idx >= self.cluster_map.len() {
+            return None; // No more clusters
+        }
+        let start_utf16_idx = self.utf16_idx;
+        let current_glyph = self.cluster_map[start_utf16_idx] as usize;
+
+        // Find the end of current cluster (where glyph index changes)
+        let mut end_utf16_idx = start_utf16_idx + 1;
+        while end_utf16_idx < self.cluster_map.len()
+            && self.cluster_map[end_utf16_idx] as usize == current_glyph
+        {
+            end_utf16_idx += 1;
+        }
+
+        let utf16_len = end_utf16_idx - start_utf16_idx;
+
+        // Calculate glyph count for this cluster
+        let next_glyph = if end_utf16_idx < self.cluster_map.len() {
+            self.cluster_map[end_utf16_idx] as usize
+        } else {
+            self.glyph_count
+        };
+
+        let glyph_count = next_glyph - current_glyph;
+
+        // Update state for next call
+        self.utf16_idx = end_utf16_idx;
+        self.glyph_idx = next_glyph;
+
+        Some((utf16_len, glyph_count))
+    }
+}
+
 #[allow(non_snake_case)]
 impl IDWritePixelSnapping_Impl for TextRenderer_Impl {
     fn IsPixelSnappingDisabled(
@@ -1055,59 +1108,73 @@ impl IDWriteTextRenderer_Impl for TextRenderer_Impl {
         glyphrundescription: *const DWRITE_GLYPH_RUN_DESCRIPTION,
         _clientdrawingeffect: windows::core::Ref<windows::core::IUnknown>,
     ) -> windows::core::Result<()> {
-        unsafe {
-            let glyphrun = &*glyphrun;
-            let glyph_count = glyphrun.glyphCount as usize;
-            if glyph_count == 0 {
-                return Ok(());
-            }
-            let desc = &*glyphrundescription;
-            let utf16_length_per_glyph = desc.stringLength as usize / glyph_count;
-            let context =
-                &mut *(clientdrawingcontext as *const RendererContext as *mut RendererContext);
-
-            if glyphrun.fontFace.is_none() {
-                return Ok(());
-            }
+        let glyphrun = unsafe { &*glyphrun };
+        let glyph_count = glyphrun.glyphCount as usize;
+        if glyph_count == 0 || glyphrun.fontFace.is_none() {
+            return Ok(());
+        }
+        let desc = unsafe { &*glyphrundescription };
+        let context = unsafe {
+            &mut *(clientdrawingcontext as *const RendererContext as *mut RendererContext)
+        };
+        let font_face = glyphrun.fontFace.as_ref().unwrap();
+        // This `cast()` action here should never fail since we are running on Win10+, and
+        // `IDWriteFontFace3` requires Win10
+        let font_face = &font_face.cast::<IDWriteFontFace3>().unwrap();
+        let Some((font_identifier, font_struct, color_font)) =
+            get_font_identifier_and_font_struct(font_face, &self.locale)
+        else {
+            return Ok(());
+        };
 
-            let font_face = glyphrun.fontFace.as_ref().unwrap();
-            // This `cast()` action here should never fail since we are running on Win10+, and
-            // `IDWriteFontFace3` requires Win10
-            let font_face = &font_face.cast::<IDWriteFontFace3>().unwrap();
-            let Some((font_identifier, font_struct, color_font)) =
-                get_font_identifier_and_font_struct(font_face, &self.locale)
-            else {
-                return Ok(());
-            };
+        let font_id = if let Some(id) = context
+            .text_system
+            .font_id_by_identifier
+            .get(&font_identifier)
+        {
+            *id
+        } else {
+            context.text_system.select_font(&font_struct)
+        };
 
-            let font_id = if let Some(id) = context
-                .text_system
-                .font_id_by_identifier
-                .get(&font_identifier)
+        let glyph_ids = unsafe { std::slice::from_raw_parts(glyphrun.glyphIndices, glyph_count) };
+        let glyph_advances =
+            unsafe { std::slice::from_raw_parts(glyphrun.glyphAdvances, glyph_count) };
+        let glyph_offsets =
+            unsafe { std::slice::from_raw_parts(glyphrun.glyphOffsets, glyph_count) };
+        let cluster_map =
+            unsafe { std::slice::from_raw_parts(desc.clusterMap, desc.stringLength as usize) };
+
+        let mut cluster_analyzer = ClusterAnalyzer::new(cluster_map, glyph_count);
+        let mut utf16_idx = desc.textPosition as usize;
+        let mut glyph_idx = 0;
+        let mut glyphs = Vec::with_capacity(glyph_count);
+        for (cluster_utf16_len, cluster_glyph_count) in cluster_analyzer {
+            context.index_converter.advance_to_utf16_ix(utf16_idx);
+            utf16_idx += cluster_utf16_len;
+            for (cluster_glyph_idx, glyph_id) in glyph_ids
+                [glyph_idx..(glyph_idx + cluster_glyph_count)]
+                .iter()
+                .enumerate()
             {
-                *id
-            } else {
-                context.text_system.select_font(&font_struct)
-            };
-            let mut glyphs = SmallVec::new();
-            for index in 0..glyph_count {
-                let id = GlyphId(*glyphrun.glyphIndices.add(index) as u32);
-                context
-                    .index_converter
-                    .advance_to_utf16_ix(context.utf16_index);
+                let id = GlyphId(*glyph_id as u32);
                 let is_emoji = color_font
                     && is_color_glyph(font_face, id, &context.text_system.components.factory);
+                let this_glyph_idx = glyph_idx + cluster_glyph_idx;
                 glyphs.push(ShapedGlyph {
                     id,
-                    position: point(px(context.width), px(0.0)),
+                    position: point(
+                        px(context.width + glyph_offsets[this_glyph_idx].advanceOffset),
+                        px(0.0),
+                    ),
                     index: context.index_converter.utf8_ix,
                     is_emoji,
                 });
-                context.utf16_index += utf16_length_per_glyph;
-                context.width += *glyphrun.glyphAdvances.add(index);
+                context.width += glyph_advances[this_glyph_idx];
             }
-            context.runs.push(ShapedRun { font_id, glyphs });
+            glyph_idx += cluster_glyph_count;
         }
+        context.runs.push(ShapedRun { font_id, glyphs });
         Ok(())
     }
 
@@ -1302,7 +1369,7 @@ fn get_postscript_name(font_face: &IDWriteFontFace3, locale: &str) -> Result<Str
         )?
     };
     if !exists.as_bool() || info.is_none() {
-        return Err(anyhow!("No postscript name found for font face"));
+        anyhow::bail!("No postscript name found for font face");
     }
 
     get_name(info.unwrap(), locale)
@@ -1352,7 +1419,7 @@ fn apply_font_features(
 }
 
 #[inline]
-fn make_direct_write_feature(feature_name: &str, parameter: u32) -> DWRITE_FONT_FEATURE {
+const fn make_direct_write_feature(feature_name: &str, parameter: u32) -> DWRITE_FONT_FEATURE {
     let tag = make_direct_write_tag(feature_name);
     DWRITE_FONT_FEATURE {
         nameTag: tag,
@@ -1361,17 +1428,14 @@ fn make_direct_write_feature(feature_name: &str, parameter: u32) -> DWRITE_FONT_
 }
 
 #[inline]
-fn make_open_type_tag(tag_name: &str) -> u32 {
-    let bytes = tag_name.bytes().collect_vec();
-    assert_eq!(bytes.len(), 4);
-    ((bytes[3] as u32) << 24)
-        | ((bytes[2] as u32) << 16)
-        | ((bytes[1] as u32) << 8)
-        | (bytes[0] as u32)
+const fn make_open_type_tag(tag_name: &str) -> u32 {
+    let bytes = tag_name.as_bytes();
+    debug_assert!(bytes.len() == 4);
+    u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
 }
 
 #[inline]
-fn make_direct_write_tag(tag_name: &str) -> DWRITE_FONT_FEATURE_TAG {
+const fn make_direct_write_tag(tag_name: &str) -> DWRITE_FONT_FEATURE_TAG {
     DWRITE_FONT_FEATURE_TAG(make_open_type_tag(tag_name))
 }
 
@@ -1394,9 +1458,7 @@ fn get_name(string: IDWriteLocalizedStrings, locale: &str) -> Result<String> {
                 &mut exists as _,
             )?
         };
-        if !exists.as_bool() {
-            return Err(anyhow!("No localised string for {}", locale));
-        }
+        anyhow::ensure!(exists.as_bool(), "No localised string for {locale}");
     }
 
     let name_length = unsafe { string.GetStringLength(locale_name_index) }? as usize;
@@ -1505,3 +1567,45 @@ const BRUSH_COLOR: D2D1_COLOR_F = D2D1_COLOR_F {
     b: 1.0,
     a: 1.0,
 };
+
+#[cfg(test)]
+mod tests {
+    use crate::platform::windows::direct_write::ClusterAnalyzer;
+
+    #[test]
+    fn test_cluster_map() {
+        let cluster_map = [0];
+        let mut analyzer = ClusterAnalyzer::new(&cluster_map, 1);
+        let next = analyzer.next();
+        assert_eq!(next, Some((1, 1)));
+        let next = analyzer.next();
+        assert_eq!(next, None);
+
+        let cluster_map = [0, 1, 2];
+        let mut analyzer = ClusterAnalyzer::new(&cluster_map, 3);
+        let next = analyzer.next();
+        assert_eq!(next, Some((1, 1)));
+        let next = analyzer.next();
+        assert_eq!(next, Some((1, 1)));
+        let next = analyzer.next();
+        assert_eq!(next, Some((1, 1)));
+        let next = analyzer.next();
+        assert_eq!(next, None);
+        // 👨‍👩‍👧‍👦👩‍💻
+        let cluster_map = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 4, 4, 4, 4];
+        let mut analyzer = ClusterAnalyzer::new(&cluster_map, 5);
+        let next = analyzer.next();
+        assert_eq!(next, Some((11, 4)));
+        let next = analyzer.next();
+        assert_eq!(next, Some((5, 1)));
+        let next = analyzer.next();
+        assert_eq!(next, None);
+        // 👩‍💻
+        let cluster_map = [0, 0, 0, 0, 0];
+        let mut analyzer = ClusterAnalyzer::new(&cluster_map, 1);
+        let next = analyzer.next();
+        assert_eq!(next, Some((5, 1)));
+        let next = analyzer.next();
+        assert_eq!(next, None);
+    }
+}

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

@@ -241,7 +241,7 @@ fn get_monitor_info(hmonitor: HMONITOR) -> anyhow::Result<MONITORINFOEXW> {
 fn generate_uuid(device_name: &[u16]) -> Uuid {
     let name = device_name
         .iter()
-        .flat_map(|&a| a.to_be_bytes().to_vec())
+        .flat_map(|&a| a.to_be_bytes())
         .collect_vec();
     Uuid::new_v5(&Uuid::NAMESPACE_DNS, &name)
 }

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

@@ -35,7 +35,7 @@ pub(crate) fn handle_msg(
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> LRESULT {
     let handled = match msg {
-        WM_ACTIVATE => handle_activate_msg(handle, wparam, state_ptr),
+        WM_ACTIVATE => handle_activate_msg(wparam, state_ptr),
         WM_CREATE => handle_create_msg(handle, state_ptr),
         WM_MOVE => handle_move_msg(handle, lparam, state_ptr),
         WM_SIZE => handle_size_msg(wparam, lparam, state_ptr),
@@ -48,7 +48,7 @@ pub(crate) fn handle_msg(
         WM_DISPLAYCHANGE => handle_display_change_msg(handle, state_ptr),
         WM_NCHITTEST => handle_hit_test_msg(handle, msg, wparam, lparam, state_ptr),
         WM_PAINT => handle_paint_msg(handle, state_ptr),
-        WM_CLOSE => handle_close_msg(state_ptr),
+        WM_CLOSE => handle_close_msg(handle, state_ptr),
         WM_DESTROY => handle_destroy_msg(handle, state_ptr),
         WM_MOUSEMOVE => handle_mouse_move_msg(handle, lparam, wparam, state_ptr),
         WM_MOUSELEAVE | WM_NCMOUSELEAVE => handle_mouse_leave_msg(state_ptr),
@@ -83,16 +83,18 @@ pub(crate) fn handle_msg(
         WM_XBUTTONUP => handle_xbutton_msg(handle, wparam, lparam, handle_mouse_up_msg, state_ptr),
         WM_MOUSEWHEEL => handle_mouse_wheel_msg(handle, wparam, lparam, state_ptr),
         WM_MOUSEHWHEEL => handle_mouse_horizontal_wheel_msg(handle, wparam, lparam, state_ptr),
-        WM_SYSKEYDOWN => handle_syskeydown_msg(wparam, lparam, state_ptr),
-        WM_SYSKEYUP => handle_syskeyup_msg(wparam, state_ptr),
+        WM_SYSKEYDOWN => handle_syskeydown_msg(handle, wparam, lparam, state_ptr),
+        WM_SYSKEYUP => handle_syskeyup_msg(handle, wparam, lparam, state_ptr),
         WM_SYSCOMMAND => handle_system_command(wparam, state_ptr),
-        WM_KEYDOWN => handle_keydown_msg(wparam, lparam, state_ptr),
-        WM_KEYUP => handle_keyup_msg(wparam, state_ptr),
-        WM_CHAR => handle_char_msg(wparam, lparam, state_ptr),
+        WM_KEYDOWN => handle_keydown_msg(handle, wparam, lparam, state_ptr),
+        WM_KEYUP => handle_keyup_msg(handle, wparam, lparam, state_ptr),
+        WM_CHAR => handle_char_msg(wparam, state_ptr),
+        WM_DEADCHAR => handle_dead_char_msg(wparam, state_ptr),
         WM_IME_STARTCOMPOSITION => handle_ime_position(handle, state_ptr),
         WM_IME_COMPOSITION => handle_ime_composition(handle, lparam, state_ptr),
-        WM_SETCURSOR => handle_set_cursor(lparam, state_ptr),
+        WM_SETCURSOR => handle_set_cursor(handle, lparam, state_ptr),
         WM_SETTINGCHANGE => handle_system_settings_changed(handle, lparam, state_ptr),
+        WM_INPUTLANGCHANGE => handle_input_language_changed(lparam, state_ptr),
         WM_GPUI_CURSOR_STYLE_CHANGED => handle_cursor_changed(lparam, state_ptr),
         _ => None,
     };
@@ -146,22 +148,18 @@ fn handle_get_min_max_info_msg(
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> Option<isize> {
     let lock = state_ptr.state.borrow();
-    if let Some(min_size) = lock.min_size {
-        let scale_factor = lock.scale_factor;
-        let boarder_offset = lock.border_offset;
-        drop(lock);
-
-        unsafe {
-            let minmax_info = &mut *(lparam.0 as *mut MINMAXINFO);
-            minmax_info.ptMinTrackSize.x =
-                min_size.width.scale(scale_factor).0 as i32 + boarder_offset.width_offset;
-            minmax_info.ptMinTrackSize.y =
-                min_size.height.scale(scale_factor).0 as i32 + boarder_offset.height_offset;
-        }
-        Some(0)
-    } else {
-        None
+    let min_size = lock.min_size?;
+    let scale_factor = lock.scale_factor;
+    let boarder_offset = lock.border_offset;
+    drop(lock);
+    unsafe {
+        let minmax_info = &mut *(lparam.0 as *mut MINMAXINFO);
+        minmax_info.ptMinTrackSize.x =
+            min_size.width.scale(scale_factor).0 as i32 + boarder_offset.width_offset;
+        minmax_info.ptMinTrackSize.y =
+            min_size.height.scale(scale_factor).0 as i32 + boarder_offset.height_offset;
     }
+    Some(0)
 }
 
 fn handle_size_msg(
@@ -250,16 +248,30 @@ fn handle_paint_msg(handle: HWND, state_ptr: Rc<WindowsWindowStatePtr>) -> Optio
     Some(0)
 }
 
-fn handle_close_msg(state_ptr: Rc<WindowsWindowStatePtr>) -> Option<isize> {
+fn handle_close_msg(handle: HWND, state_ptr: Rc<WindowsWindowStatePtr>) -> Option<isize> {
     let mut lock = state_ptr.state.borrow_mut();
-    if let Some(mut callback) = lock.callbacks.should_close.take() {
+    let output = if let Some(mut callback) = lock.callbacks.should_close.take() {
         drop(lock);
         let should_close = callback();
         state_ptr.state.borrow_mut().callbacks.should_close = Some(callback);
         if should_close { None } else { Some(0) }
     } else {
         None
+    };
+
+    // Workaround as window close animation is not played with `WS_EX_LAYERED` enabled.
+    if output.is_none() {
+        unsafe {
+            let current_style = get_window_long(handle, GWL_EXSTYLE);
+            set_window_long(
+                handle,
+                GWL_EXSTYLE,
+                current_style & !WS_EX_LAYERED.0 as isize,
+            );
+        }
     }
+
+    output
 }
 
 fn handle_destroy_msg(handle: HWND, state_ptr: Rc<WindowsWindowStatePtr>) -> Option<isize> {
@@ -291,37 +303,35 @@ fn handle_mouse_move_msg(
     start_tracking_mouse(handle, &state_ptr, TME_LEAVE);
 
     let mut lock = state_ptr.state.borrow_mut();
-    if let Some(mut callback) = lock.callbacks.input.take() {
-        let scale_factor = lock.scale_factor;
-        drop(lock);
-        let pressed_button = match MODIFIERKEYS_FLAGS(wparam.loword() as u32) {
-            flags if flags.contains(MK_LBUTTON) => Some(MouseButton::Left),
-            flags if flags.contains(MK_RBUTTON) => Some(MouseButton::Right),
-            flags if flags.contains(MK_MBUTTON) => Some(MouseButton::Middle),
-            flags if flags.contains(MK_XBUTTON1) => {
-                Some(MouseButton::Navigate(NavigationDirection::Back))
-            }
-            flags if flags.contains(MK_XBUTTON2) => {
-                Some(MouseButton::Navigate(NavigationDirection::Forward))
-            }
-            _ => None,
-        };
-        let x = lparam.signed_loword() as f32;
-        let y = lparam.signed_hiword() as f32;
-        let event = MouseMoveEvent {
-            position: logical_point(x, y, scale_factor),
-            pressed_button,
-            modifiers: current_modifiers(),
-        };
-        let result = if callback(PlatformInput::MouseMove(event)).default_prevented {
-            Some(0)
-        } else {
-            Some(1)
-        };
-        state_ptr.state.borrow_mut().callbacks.input = Some(callback);
-        return result;
-    }
-    Some(1)
+    let Some(mut func) = lock.callbacks.input.take() else {
+        return Some(1);
+    };
+    let scale_factor = lock.scale_factor;
+    drop(lock);
+
+    let pressed_button = match MODIFIERKEYS_FLAGS(wparam.loword() as u32) {
+        flags if flags.contains(MK_LBUTTON) => Some(MouseButton::Left),
+        flags if flags.contains(MK_RBUTTON) => Some(MouseButton::Right),
+        flags if flags.contains(MK_MBUTTON) => Some(MouseButton::Middle),
+        flags if flags.contains(MK_XBUTTON1) => {
+            Some(MouseButton::Navigate(NavigationDirection::Back))
+        }
+        flags if flags.contains(MK_XBUTTON2) => {
+            Some(MouseButton::Navigate(NavigationDirection::Forward))
+        }
+        _ => None,
+    };
+    let x = lparam.signed_loword() as f32;
+    let y = lparam.signed_hiword() as f32;
+    let input = PlatformInput::MouseMove(MouseMoveEvent {
+        position: logical_point(x, y, scale_factor),
+        pressed_button,
+        modifiers: current_modifiers(),
+    });
+    let handled = !func(input).propagate;
+    state_ptr.state.borrow_mut().callbacks.input = Some(func);
+
+    if handled { Some(0) } else { Some(1) }
 }
 
 fn handle_mouse_leave_msg(state_ptr: Rc<WindowsWindowStatePtr>) -> Option<isize> {
@@ -337,140 +347,146 @@ fn handle_mouse_leave_msg(state_ptr: Rc<WindowsWindowStatePtr>) -> Option<isize>
 }
 
 fn handle_syskeydown_msg(
+    handle: HWND,
     wparam: WPARAM,
     lparam: LPARAM,
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> Option<isize> {
-    // we need to call `DefWindowProcW`, or we will lose the system-wide `Alt+F4`, `Alt+{other keys}`
-    // shortcuts.
-    let keystroke = parse_syskeydown_msg_keystroke(wparam)?;
-    let mut func = state_ptr.state.borrow_mut().callbacks.input.take()?;
-    let event = KeyDownEvent {
-        keystroke,
-        is_held: lparam.0 & (0x1 << 30) > 0,
-    };
-    let result = if !func(PlatformInput::KeyDown(event)).propagate {
-        state_ptr.state.borrow_mut().system_key_handled = true;
+    let mut lock = state_ptr.state.borrow_mut();
+    let input = handle_key_event(handle, wparam, lparam, &mut lock, |keystroke| {
+        PlatformInput::KeyDown(KeyDownEvent {
+            keystroke,
+            is_held: lparam.0 & (0x1 << 30) > 0,
+        })
+    })?;
+    let mut func = lock.callbacks.input.take()?;
+    drop(lock);
+
+    let handled = !func(input).propagate;
+
+    let mut lock = state_ptr.state.borrow_mut();
+    lock.callbacks.input = Some(func);
+
+    if handled {
+        lock.system_key_handled = true;
         Some(0)
     } else {
+        // we need to call `DefWindowProcW`, or we will lose the system-wide `Alt+F4`, `Alt+{other keys}`
+        // shortcuts.
         None
-    };
-    state_ptr.state.borrow_mut().callbacks.input = Some(func);
-
-    result
+    }
 }
 
-fn handle_syskeyup_msg(wparam: WPARAM, state_ptr: Rc<WindowsWindowStatePtr>) -> Option<isize> {
-    // we need to call `DefWindowProcW`, or we will lose the system-wide `Alt+F4`, `Alt+{other keys}`
-    // shortcuts.
-    let keystroke = parse_syskeydown_msg_keystroke(wparam)?;
-    let mut func = state_ptr.state.borrow_mut().callbacks.input.take()?;
-    let event = KeyUpEvent { keystroke };
-    let result = if func(PlatformInput::KeyUp(event)).default_prevented {
-        Some(0)
-    } else {
-        Some(1)
-    };
+fn handle_syskeyup_msg(
+    handle: HWND,
+    wparam: WPARAM,
+    lparam: LPARAM,
+    state_ptr: Rc<WindowsWindowStatePtr>,
+) -> Option<isize> {
+    let mut lock = state_ptr.state.borrow_mut();
+    let input = handle_key_event(handle, wparam, lparam, &mut lock, |keystroke| {
+        PlatformInput::KeyUp(KeyUpEvent { keystroke })
+    })?;
+    let mut func = lock.callbacks.input.take()?;
+    drop(lock);
+    func(input);
     state_ptr.state.borrow_mut().callbacks.input = Some(func);
 
-    result
+    // Always return 0 to indicate that the message was handled, so we could properly handle `ModifiersChanged` event.
+    Some(0)
 }
 
+// It's a known bug that you can't trigger `ctrl-shift-0`. See:
+// https://superuser.com/questions/1455762/ctrl-shift-number-key-combination-has-stopped-working-for-a-few-numbers
 fn handle_keydown_msg(
+    handle: HWND,
     wparam: WPARAM,
     lparam: LPARAM,
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> Option<isize> {
-    let Some(keystroke_or_modifier) = parse_keystroke_from_vkey(wparam, false) else {
-        return Some(1);
-    };
     let mut lock = state_ptr.state.borrow_mut();
-    let Some(mut func) = lock.callbacks.input.take() else {
-        return Some(1);
-    };
-    drop(lock);
-
-    let event = match keystroke_or_modifier {
-        KeystrokeOrModifier::Keystroke(keystroke) => PlatformInput::KeyDown(KeyDownEvent {
+    let Some(input) = handle_key_event(handle, wparam, lparam, &mut lock, |keystroke| {
+        PlatformInput::KeyDown(KeyDownEvent {
             keystroke,
             is_held: lparam.0 & (0x1 << 30) > 0,
-        }),
-        KeystrokeOrModifier::Modifier(modifiers) => {
-            PlatformInput::ModifiersChanged(ModifiersChangedEvent { modifiers })
-        }
-    };
-
-    let result = if func(event).default_prevented {
-        Some(0)
-    } else {
-        Some(1)
+        })
+    }) else {
+        return Some(1);
     };
-    state_ptr.state.borrow_mut().callbacks.input = Some(func);
+    drop(lock);
 
-    result
-}
+    let is_composing = with_input_handler(&state_ptr, |input_handler| {
+        input_handler.marked_text_range()
+    })
+    .flatten()
+    .is_some();
+    if is_composing {
+        translate_message(handle, wparam, lparam);
+        return Some(0);
+    }
 
-fn handle_keyup_msg(wparam: WPARAM, state_ptr: Rc<WindowsWindowStatePtr>) -> Option<isize> {
-    let Some(keystroke_or_modifier) = parse_keystroke_from_vkey(wparam, true) else {
+    let Some(mut func) = state_ptr.state.borrow_mut().callbacks.input.take() else {
         return Some(1);
     };
-    let mut lock = state_ptr.state.borrow_mut();
-    let Some(mut func) = lock.callbacks.input.take() else {
-        return Some(1);
-    };
-    drop(lock);
 
-    let event = match keystroke_or_modifier {
-        KeystrokeOrModifier::Keystroke(keystroke) => PlatformInput::KeyUp(KeyUpEvent { keystroke }),
-        KeystrokeOrModifier::Modifier(modifiers) => {
-            PlatformInput::ModifiersChanged(ModifiersChangedEvent { modifiers })
-        }
-    };
+    let handled = !func(input).propagate;
+
+    state_ptr.state.borrow_mut().callbacks.input = Some(func);
 
-    let result = if func(event).default_prevented {
+    if handled {
         Some(0)
     } else {
+        translate_message(handle, wparam, lparam);
         Some(1)
-    };
-    state_ptr.state.borrow_mut().callbacks.input = Some(func);
-
-    result
+    }
 }
 
-fn handle_char_msg(
+fn handle_keyup_msg(
+    handle: HWND,
     wparam: WPARAM,
     lparam: LPARAM,
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> Option<isize> {
-    let Some(keystroke) = parse_char_msg_keystroke(wparam) else {
+    let mut lock = state_ptr.state.borrow_mut();
+    let Some(input) = handle_key_event(handle, wparam, lparam, &mut lock, |keystroke| {
+        PlatformInput::KeyUp(KeyUpEvent { keystroke })
+    }) else {
         return Some(1);
     };
-    let mut lock = state_ptr.state.borrow_mut();
+
     let Some(mut func) = lock.callbacks.input.take() else {
         return Some(1);
     };
     drop(lock);
-    let key_char = keystroke.key_char.clone();
-    let event = KeyDownEvent {
-        keystroke,
-        is_held: lparam.0 & (0x1 << 30) > 0,
-    };
-    let dispatch_event_result = func(PlatformInput::KeyDown(event));
+
+    let handled = !func(input).propagate;
     state_ptr.state.borrow_mut().callbacks.input = Some(func);
 
-    if dispatch_event_result.default_prevented || !dispatch_event_result.propagate {
-        return Some(0);
-    }
-    let Some(ime_char) = key_char else {
+    if handled { Some(0) } else { Some(1) }
+}
+
+fn handle_char_msg(wparam: WPARAM, state_ptr: Rc<WindowsWindowStatePtr>) -> Option<isize> {
+    let Some(input) = char::from_u32(wparam.0 as u32)
+        .filter(|c| !c.is_control())
+        .map(String::from)
+    else {
         return Some(1);
     };
     with_input_handler(&state_ptr, |input_handler| {
-        input_handler.replace_text_in_range(None, &ime_char);
+        input_handler.replace_text_in_range(None, &input);
     });
 
     Some(0)
 }
 
+fn handle_dead_char_msg(wparam: WPARAM, state_ptr: Rc<WindowsWindowStatePtr>) -> Option<isize> {
+    let ch = char::from_u32(wparam.0 as u32)?.to_string();
+    with_input_handler(&state_ptr, |input_handler| {
+        input_handler.replace_and_mark_text_in_range(None, &ch, None);
+    });
+    None
+}
+
 fn handle_mouse_down_msg(
     handle: HWND,
     button: MouseButton,
@@ -479,32 +495,27 @@ fn handle_mouse_down_msg(
 ) -> Option<isize> {
     unsafe { SetCapture(handle) };
     let mut lock = state_ptr.state.borrow_mut();
-    if let Some(mut callback) = lock.callbacks.input.take() {
-        let x = lparam.signed_loword() as f32;
-        let y = lparam.signed_hiword() as f32;
-        let physical_point = point(DevicePixels(x as i32), DevicePixels(y as i32));
-        let click_count = lock.click_state.update(button, physical_point);
-        let scale_factor = lock.scale_factor;
-        drop(lock);
+    let Some(mut func) = lock.callbacks.input.take() else {
+        return Some(1);
+    };
+    let x = lparam.signed_loword();
+    let y = lparam.signed_hiword();
+    let physical_point = point(DevicePixels(x as i32), DevicePixels(y as i32));
+    let click_count = lock.click_state.update(button, physical_point);
+    let scale_factor = lock.scale_factor;
+    drop(lock);
 
-        let event = MouseDownEvent {
-            button,
-            position: logical_point(x, y, scale_factor),
-            modifiers: current_modifiers(),
-            click_count,
-            first_mouse: false,
-        };
-        let result = if callback(PlatformInput::MouseDown(event)).default_prevented {
-            Some(0)
-        } else {
-            Some(1)
-        };
-        state_ptr.state.borrow_mut().callbacks.input = Some(callback);
+    let input = PlatformInput::MouseDown(MouseDownEvent {
+        button,
+        position: logical_point(x as f32, y as f32, scale_factor),
+        modifiers: current_modifiers(),
+        click_count,
+        first_mouse: false,
+    });
+    let handled = !func(input).propagate;
+    state_ptr.state.borrow_mut().callbacks.input = Some(func);
 
-        result
-    } else {
-        Some(1)
-    }
+    if handled { Some(0) } else { Some(1) }
 }
 
 fn handle_mouse_up_msg(
@@ -515,30 +526,25 @@ fn handle_mouse_up_msg(
 ) -> Option<isize> {
     unsafe { ReleaseCapture().log_err() };
     let mut lock = state_ptr.state.borrow_mut();
-    if let Some(mut callback) = lock.callbacks.input.take() {
-        let x = lparam.signed_loword() as f32;
-        let y = lparam.signed_hiword() as f32;
-        let click_count = lock.click_state.current_count;
-        let scale_factor = lock.scale_factor;
-        drop(lock);
+    let Some(mut func) = lock.callbacks.input.take() else {
+        return Some(1);
+    };
+    let x = lparam.signed_loword() as f32;
+    let y = lparam.signed_hiword() as f32;
+    let click_count = lock.click_state.current_count;
+    let scale_factor = lock.scale_factor;
+    drop(lock);
 
-        let event = MouseUpEvent {
-            button,
-            position: logical_point(x, y, scale_factor),
-            modifiers: current_modifiers(),
-            click_count,
-        };
-        let result = if callback(PlatformInput::MouseUp(event)).default_prevented {
-            Some(0)
-        } else {
-            Some(1)
-        };
-        state_ptr.state.borrow_mut().callbacks.input = Some(callback);
+    let input = PlatformInput::MouseUp(MouseUpEvent {
+        button,
+        position: logical_point(x, y, scale_factor),
+        modifiers: current_modifiers(),
+        click_count,
+    });
+    let handled = !func(input).propagate;
+    state_ptr.state.borrow_mut().callbacks.input = Some(func);
 
-        result
-    } else {
-        Some(1)
-    }
+    if handled { Some(0) } else { Some(1) }
 }
 
 fn handle_xbutton_msg(
@@ -564,46 +570,42 @@ fn handle_mouse_wheel_msg(
 ) -> Option<isize> {
     let modifiers = current_modifiers();
     let mut lock = state_ptr.state.borrow_mut();
-    if let Some(mut callback) = lock.callbacks.input.take() {
-        let scale_factor = lock.scale_factor;
-        let wheel_scroll_amount = match modifiers.shift {
-            true => lock.system_settings.mouse_wheel_settings.wheel_scroll_chars,
-            false => lock.system_settings.mouse_wheel_settings.wheel_scroll_lines,
-        };
-        drop(lock);
-        let wheel_distance =
-            (wparam.signed_hiword() as f32 / WHEEL_DELTA as f32) * wheel_scroll_amount as f32;
-        let mut cursor_point = POINT {
-            x: lparam.signed_loword().into(),
-            y: lparam.signed_hiword().into(),
-        };
-        unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() };
-        let event = ScrollWheelEvent {
-            position: logical_point(cursor_point.x as f32, cursor_point.y as f32, scale_factor),
-            delta: ScrollDelta::Lines(match modifiers.shift {
-                true => Point {
-                    x: wheel_distance,
-                    y: 0.0,
-                },
-                false => Point {
-                    y: wheel_distance,
-                    x: 0.0,
-                },
-            }),
-            modifiers: current_modifiers(),
-            touch_phase: TouchPhase::Moved,
-        };
-        let result = if callback(PlatformInput::ScrollWheel(event)).default_prevented {
-            Some(0)
-        } else {
-            Some(1)
-        };
-        state_ptr.state.borrow_mut().callbacks.input = Some(callback);
+    let Some(mut func) = lock.callbacks.input.take() else {
+        return Some(1);
+    };
+    let scale_factor = lock.scale_factor;
+    let wheel_scroll_amount = match modifiers.shift {
+        true => lock.system_settings.mouse_wheel_settings.wheel_scroll_chars,
+        false => lock.system_settings.mouse_wheel_settings.wheel_scroll_lines,
+    };
+    drop(lock);
 
-        result
-    } else {
-        Some(1)
-    }
+    let wheel_distance =
+        (wparam.signed_hiword() as f32 / WHEEL_DELTA as f32) * wheel_scroll_amount as f32;
+    let mut cursor_point = POINT {
+        x: lparam.signed_loword().into(),
+        y: lparam.signed_hiword().into(),
+    };
+    unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() };
+    let input = PlatformInput::ScrollWheel(ScrollWheelEvent {
+        position: logical_point(cursor_point.x as f32, cursor_point.y as f32, scale_factor),
+        delta: ScrollDelta::Lines(match modifiers.shift {
+            true => Point {
+                x: wheel_distance,
+                y: 0.0,
+            },
+            false => Point {
+                y: wheel_distance,
+                x: 0.0,
+            },
+        }),
+        modifiers,
+        touch_phase: TouchPhase::Moved,
+    });
+    let handled = !func(input).propagate;
+    state_ptr.state.borrow_mut().callbacks.input = Some(func);
+
+    if handled { Some(0) } else { Some(1) }
 }
 
 fn handle_mouse_horizontal_wheel_msg(
@@ -613,37 +615,33 @@ fn handle_mouse_horizontal_wheel_msg(
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> Option<isize> {
     let mut lock = state_ptr.state.borrow_mut();
-    if let Some(mut callback) = lock.callbacks.input.take() {
-        let scale_factor = lock.scale_factor;
-        let wheel_scroll_chars = lock.system_settings.mouse_wheel_settings.wheel_scroll_chars;
-        drop(lock);
-        let wheel_distance =
-            (-wparam.signed_hiword() as f32 / WHEEL_DELTA as f32) * wheel_scroll_chars as f32;
-        let mut cursor_point = POINT {
-            x: lparam.signed_loword().into(),
-            y: lparam.signed_hiword().into(),
-        };
-        unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() };
-        let event = ScrollWheelEvent {
-            position: logical_point(cursor_point.x as f32, cursor_point.y as f32, scale_factor),
-            delta: ScrollDelta::Lines(Point {
-                x: wheel_distance,
-                y: 0.0,
-            }),
-            modifiers: current_modifiers(),
-            touch_phase: TouchPhase::Moved,
-        };
-        let result = if callback(PlatformInput::ScrollWheel(event)).default_prevented {
-            Some(0)
-        } else {
-            Some(1)
-        };
-        state_ptr.state.borrow_mut().callbacks.input = Some(callback);
+    let Some(mut func) = lock.callbacks.input.take() else {
+        return Some(1);
+    };
+    let scale_factor = lock.scale_factor;
+    let wheel_scroll_chars = lock.system_settings.mouse_wheel_settings.wheel_scroll_chars;
+    drop(lock);
 
-        result
-    } else {
-        Some(1)
-    }
+    let wheel_distance =
+        (-wparam.signed_hiword() as f32 / WHEEL_DELTA as f32) * wheel_scroll_chars as f32;
+    let mut cursor_point = POINT {
+        x: lparam.signed_loword().into(),
+        y: lparam.signed_hiword().into(),
+    };
+    unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() };
+    let event = PlatformInput::ScrollWheel(ScrollWheelEvent {
+        position: logical_point(cursor_point.x as f32, cursor_point.y as f32, scale_factor),
+        delta: ScrollDelta::Lines(Point {
+            x: wheel_distance,
+            y: 0.0,
+        }),
+        modifiers: current_modifiers(),
+        touch_phase: TouchPhase::Moved,
+    });
+    let handled = !func(event).propagate;
+    state_ptr.state.borrow_mut().callbacks.input = Some(func);
+
+    if handled { Some(0) } else { Some(1) }
 }
 
 fn retrieve_caret_position(state_ptr: &Rc<WindowsWindowStatePtr>) -> Option<POINT> {
@@ -703,38 +701,36 @@ fn handle_ime_composition_inner(
     lparam: LPARAM,
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> Option<isize> {
-    let mut ime_input = None;
-    if lparam.0 as u32 & GCS_COMPSTR.0 > 0 {
-        let (comp_string, string_len) = parse_ime_compostion_string(ctx)?;
+    let lparam = lparam.0 as u32;
+    if lparam == 0 {
+        // Japanese IME may send this message with lparam = 0, which indicates that
+        // there is no composition string.
         with_input_handler(&state_ptr, |input_handler| {
-            input_handler.replace_and_mark_text_in_range(
-                None,
-                &comp_string,
-                Some(string_len..string_len),
-            );
+            input_handler.replace_text_in_range(None, "");
         })?;
-        ime_input = Some(comp_string);
-    }
-    if lparam.0 as u32 & GCS_CURSORPOS.0 > 0 {
-        let comp_string = &ime_input?;
-        let caret_pos = retrieve_composition_cursor_position(ctx);
-        with_input_handler(&state_ptr, |input_handler| {
-            input_handler.replace_and_mark_text_in_range(
-                None,
-                comp_string,
-                Some(caret_pos..caret_pos),
-            );
-        })?;
-    }
-    if lparam.0 as u32 & GCS_RESULTSTR.0 > 0 {
-        let comp_result = parse_ime_compostion_result(ctx)?;
-        with_input_handler(&state_ptr, |input_handler| {
-            input_handler.replace_text_in_range(None, &comp_result);
-        })?;
-        return Some(0);
+        Some(0)
+    } else {
+        if lparam & GCS_COMPSTR.0 > 0 {
+            let comp_string = parse_ime_composition_string(ctx, GCS_COMPSTR)?;
+            let caret_pos = (!comp_string.is_empty() && lparam & GCS_CURSORPOS.0 > 0).then(|| {
+                let pos = retrieve_composition_cursor_position(ctx);
+                pos..pos
+            });
+            with_input_handler(&state_ptr, |input_handler| {
+                input_handler.replace_and_mark_text_in_range(None, &comp_string, caret_pos);
+            })?;
+        }
+        if lparam & GCS_RESULTSTR.0 > 0 {
+            let comp_result = parse_ime_composition_string(ctx, GCS_RESULTSTR)?;
+            with_input_handler(&state_ptr, |input_handler| {
+                input_handler.replace_text_in_range(None, &comp_result);
+            })?;
+            return Some(0);
+        }
+
+        // currently, we don't care other stuff
+        None
     }
-    // currently, we don't care other stuff
-    None
 }
 
 /// SEE: https://learn.microsoft.com/en-us/windows/win32/winmsg/wm-nccalcsize
@@ -792,30 +788,17 @@ fn handle_calc_client_size(
     Some(0)
 }
 
-fn handle_activate_msg(
-    handle: HWND,
-    wparam: WPARAM,
-    state_ptr: Rc<WindowsWindowStatePtr>,
-) -> Option<isize> {
+fn handle_activate_msg(wparam: WPARAM, state_ptr: Rc<WindowsWindowStatePtr>) -> Option<isize> {
     let activated = wparam.loword() > 0;
-    if state_ptr.hide_title_bar {
-        if let Some(titlebar_rect) = state_ptr.state.borrow().get_titlebar_rect().log_err() {
-            unsafe {
-                InvalidateRect(Some(handle), Some(&titlebar_rect), false)
-                    .ok()
-                    .log_err()
-            };
-        }
-    }
     let this = state_ptr.clone();
     state_ptr
         .executor
         .spawn(async move {
             let mut lock = this.state.borrow_mut();
-            if let Some(mut cb) = lock.callbacks.active_status_change.take() {
+            if let Some(mut func) = lock.callbacks.active_status_change.take() {
                 drop(lock);
-                cb(activated);
-                this.state.borrow_mut().callbacks.active_status_change = Some(cb);
+                func(activated);
+                this.state.borrow_mut().callbacks.active_status_change = Some(func);
             }
         })
         .detach();
@@ -882,7 +865,7 @@ fn handle_display_change_msg(handle: HWND, state_ptr: Rc<WindowsWindowStatePtr>)
     // Because WM_DPICHANGED, WM_MOVE, WM_SIZE will come first, window reposition and resize
     // are handled there.
     // So we only care about if monitor is disconnected.
-    let previous_monitor = state_ptr.as_ref().state.borrow().display;
+    let previous_monitor = state_ptr.state.borrow().display;
     if WindowsDisplay::is_connected(previous_monitor.handle) {
         // we are fine, other display changed
         return None;
@@ -900,7 +883,7 @@ fn handle_display_change_msg(handle: HWND, state_ptr: Rc<WindowsWindowStatePtr>)
         return None;
     }
     let new_display = WindowsDisplay::new_with_handle(new_monitor);
-    state_ptr.as_ref().state.borrow_mut().display = new_display;
+    state_ptr.state.borrow_mut().display = new_display;
     Some(0)
 }
 
@@ -911,10 +894,33 @@ fn handle_hit_test_msg(
     lparam: LPARAM,
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> Option<isize> {
-    if !state_ptr.is_movable {
+    if !state_ptr.is_movable || state_ptr.state.borrow().is_fullscreen() {
         return None;
     }
+
+    let mut lock = state_ptr.state.borrow_mut();
+    if let Some(mut callback) = lock.callbacks.hit_test_window_control.take() {
+        drop(lock);
+        let area = callback();
+        state_ptr
+            .state
+            .borrow_mut()
+            .callbacks
+            .hit_test_window_control = Some(callback);
+        if let Some(area) = area {
+            return match area {
+                WindowControlArea::Drag => Some(HTCAPTION as _),
+                WindowControlArea::Close => Some(HTCLOSE as _),
+                WindowControlArea::Max => Some(HTMAXBUTTON as _),
+                WindowControlArea::Min => Some(HTMINBUTTON as _),
+            };
+        }
+    } else {
+        drop(lock);
+    }
+
     if !state_ptr.hide_title_bar {
+        // If the OS draws the title bar, we don't need to handle hit test messages.
         return None;
     }
 
@@ -952,23 +958,6 @@ fn handle_hit_test_msg(
         return Some(HTTOP as _);
     }
 
-    let titlebar_rect = state_ptr.state.borrow().get_titlebar_rect();
-    if let Ok(titlebar_rect) = titlebar_rect {
-        if cursor_point.y < titlebar_rect.bottom {
-            let caption_btn_width = (state_ptr.state.borrow().caption_button_width().0
-                * state_ptr.state.borrow().scale_factor) as i32;
-            if cursor_point.x >= titlebar_rect.right - caption_btn_width {
-                return Some(HTCLOSE as _);
-            } else if cursor_point.x >= titlebar_rect.right - caption_btn_width * 2 {
-                return Some(HTMAXBUTTON as _);
-            } else if cursor_point.x >= titlebar_rect.right - caption_btn_width * 3 {
-                return Some(HTMINBUTTON as _);
-            }
-
-            return Some(HTCAPTION as _);
-        }
-    }
-
     Some(HTCLIENT as _)
 }
 
@@ -977,37 +966,27 @@ fn handle_nc_mouse_move_msg(
     lparam: LPARAM,
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> Option<isize> {
-    if !state_ptr.hide_title_bar {
-        return None;
-    }
-
     start_tracking_mouse(handle, &state_ptr, TME_LEAVE | TME_NONCLIENT);
 
     let mut lock = state_ptr.state.borrow_mut();
-    if let Some(mut callback) = lock.callbacks.input.take() {
-        let scale_factor = lock.scale_factor;
-        drop(lock);
-        let mut cursor_point = POINT {
-            x: lparam.signed_loword().into(),
-            y: lparam.signed_hiword().into(),
-        };
-        unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() };
-        let event = MouseMoveEvent {
-            position: logical_point(cursor_point.x as f32, cursor_point.y as f32, scale_factor),
-            pressed_button: None,
-            modifiers: current_modifiers(),
-        };
-        let result = if callback(PlatformInput::MouseMove(event)).default_prevented {
-            Some(0)
-        } else {
-            Some(1)
-        };
-        state_ptr.state.borrow_mut().callbacks.input = Some(callback);
+    let mut func = lock.callbacks.input.take()?;
+    let scale_factor = lock.scale_factor;
+    drop(lock);
 
-        result
-    } else {
-        None
-    }
+    let mut cursor_point = POINT {
+        x: lparam.signed_loword().into(),
+        y: lparam.signed_hiword().into(),
+    };
+    unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() };
+    let input = PlatformInput::MouseMove(MouseMoveEvent {
+        position: logical_point(cursor_point.x as f32, cursor_point.y as f32, scale_factor),
+        pressed_button: None,
+        modifiers: current_modifiers(),
+    });
+    let handled = !func(input).propagate;
+    state_ptr.state.borrow_mut().callbacks.input = Some(func);
+
+    if handled { Some(0) } else { None }
 }
 
 fn handle_nc_mouse_down_msg(
@@ -1017,12 +996,8 @@ fn handle_nc_mouse_down_msg(
     lparam: LPARAM,
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> Option<isize> {
-    if !state_ptr.hide_title_bar {
-        return None;
-    }
-
     let mut lock = state_ptr.state.borrow_mut();
-    if let Some(mut callback) = lock.callbacks.input.take() {
+    if let Some(mut func) = lock.callbacks.input.take() {
         let scale_factor = lock.scale_factor;
         let mut cursor_point = POINT {
             x: lparam.signed_loword().into(),
@@ -1032,22 +1007,20 @@ fn handle_nc_mouse_down_msg(
         let physical_point = point(DevicePixels(cursor_point.x), DevicePixels(cursor_point.y));
         let click_count = lock.click_state.update(button, physical_point);
         drop(lock);
-        let event = MouseDownEvent {
+
+        let input = PlatformInput::MouseDown(MouseDownEvent {
             button,
             position: logical_point(cursor_point.x as f32, cursor_point.y as f32, scale_factor),
             modifiers: current_modifiers(),
             click_count,
             first_mouse: false,
-        };
-        let result = if callback(PlatformInput::MouseDown(event)).default_prevented {
-            Some(0)
-        } else {
-            None
-        };
-        state_ptr.state.borrow_mut().callbacks.input = Some(callback);
+        });
+        let result = func(input.clone());
+        let handled = !result.propagate || result.default_prevented;
+        state_ptr.state.borrow_mut().callbacks.input = Some(func);
 
-        if result.is_some() {
-            return result;
+        if handled {
+            return Some(0);
         }
     } else {
         drop(lock);
@@ -1074,69 +1047,57 @@ fn handle_nc_mouse_up_msg(
     lparam: LPARAM,
     state_ptr: Rc<WindowsWindowStatePtr>,
 ) -> Option<isize> {
-    if !state_ptr.hide_title_bar {
-        return None;
-    }
-
     let mut lock = state_ptr.state.borrow_mut();
-    if let Some(mut callback) = lock.callbacks.input.take() {
+    if let Some(mut func) = lock.callbacks.input.take() {
         let scale_factor = lock.scale_factor;
         drop(lock);
+
         let mut cursor_point = POINT {
             x: lparam.signed_loword().into(),
             y: lparam.signed_hiword().into(),
         };
         unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() };
-        let event = MouseUpEvent {
+        let input = PlatformInput::MouseUp(MouseUpEvent {
             button,
             position: logical_point(cursor_point.x as f32, cursor_point.y as f32, scale_factor),
             modifiers: current_modifiers(),
             click_count: 1,
-        };
-        let result = if callback(PlatformInput::MouseUp(event)).default_prevented {
-            Some(0)
-        } else {
-            None
-        };
-        state_ptr.state.borrow_mut().callbacks.input = Some(callback);
-        if result.is_some() {
-            return result;
+        });
+        let handled = !func(input).propagate;
+        state_ptr.state.borrow_mut().callbacks.input = Some(func);
+
+        if handled {
+            return Some(0);
         }
     } else {
         drop(lock);
     }
 
     let last_pressed = state_ptr.state.borrow_mut().nc_button_pressed.take();
-    if button == MouseButton::Left && last_pressed.is_some() {
-        let last_button = last_pressed.unwrap();
-        let mut handled = false;
-        match wparam.0 as u32 {
-            HTMINBUTTON => {
-                if last_button == HTMINBUTTON {
-                    unsafe { ShowWindowAsync(handle, SW_MINIMIZE).ok().log_err() };
-                    handled = true;
-                }
+    if button == MouseButton::Left
+        && let Some(last_pressed) = last_pressed
+    {
+        let handled = match (wparam.0 as u32, last_pressed) {
+            (HTMINBUTTON, HTMINBUTTON) => {
+                unsafe { ShowWindowAsync(handle, SW_MINIMIZE).ok().log_err() };
+                true
             }
-            HTMAXBUTTON => {
-                if last_button == HTMAXBUTTON {
-                    if state_ptr.state.borrow().is_maximized() {
-                        unsafe { ShowWindowAsync(handle, SW_NORMAL).ok().log_err() };
-                    } else {
-                        unsafe { ShowWindowAsync(handle, SW_MAXIMIZE).ok().log_err() };
-                    }
-                    handled = true;
+            (HTMAXBUTTON, HTMAXBUTTON) => {
+                if state_ptr.state.borrow().is_maximized() {
+                    unsafe { ShowWindowAsync(handle, SW_NORMAL).ok().log_err() };
+                } else {
+                    unsafe { ShowWindowAsync(handle, SW_MAXIMIZE).ok().log_err() };
                 }
+                true
             }
-            HTCLOSE => {
-                if last_button == HTCLOSE {
-                    unsafe {
-                        PostMessageW(Some(handle), WM_CLOSE, WPARAM::default(), LPARAM::default())
-                            .log_err()
-                    };
-                    handled = true;
-                }
+            (HTCLOSE, HTCLOSE) => {
+                unsafe {
+                    PostMessageW(Some(handle), WM_CLOSE, WPARAM::default(), LPARAM::default())
+                        .log_err()
+                };
+                true
             }
-            _ => {}
+            _ => false,
         };
         if handled {
             return Some(0);

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

@@ -1,10 +1,16 @@
 use anyhow::Result;
 use windows::Win32::UI::{
-    Input::KeyboardAndMouse::GetKeyboardLayoutNameW, WindowsAndMessaging::KL_NAMELENGTH,
+    Input::KeyboardAndMouse::{
+        GetKeyboardLayoutNameW, MAPVK_VK_TO_CHAR, MapVirtualKeyW, ToUnicode, VIRTUAL_KEY, VK_0,
+        VK_1, VK_2, VK_3, VK_4, VK_5, VK_6, VK_7, VK_8, VK_9, VK_ABNT_C1, VK_CONTROL, VK_MENU,
+        VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7, VK_OEM_8, VK_OEM_102,
+        VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_SHIFT,
+    },
+    WindowsAndMessaging::KL_NAMELENGTH,
 };
 use windows_core::HSTRING;
 
-use crate::PlatformKeyboardLayout;
+use crate::{Modifiers, PlatformKeyboardLayout};
 
 pub(crate) struct WindowsKeyboardLayout {
     id: String,
@@ -41,3 +47,94 @@ impl WindowsKeyboardLayout {
         }
     }
 }
+
+pub(crate) fn get_keystroke_key(
+    vkey: VIRTUAL_KEY,
+    scan_code: u32,
+    modifiers: &mut Modifiers,
+) -> Option<String> {
+    if modifiers.shift && need_to_convert_to_shifted_key(vkey) {
+        get_shifted_key(vkey, scan_code).inspect(|_| {
+            modifiers.shift = false;
+        })
+    } else {
+        get_key_from_vkey(vkey)
+    }
+}
+
+fn get_key_from_vkey(vkey: VIRTUAL_KEY) -> Option<String> {
+    let key_data = unsafe { MapVirtualKeyW(vkey.0 as u32, MAPVK_VK_TO_CHAR) };
+    if key_data == 0 {
+        return None;
+    }
+
+    // The high word contains dead key flag, the low word contains the character
+    let key = char::from_u32(key_data & 0xFFFF)?;
+
+    Some(key.to_ascii_lowercase().to_string())
+}
+
+#[inline]
+fn need_to_convert_to_shifted_key(vkey: VIRTUAL_KEY) -> bool {
+    matches!(
+        vkey,
+        VK_OEM_3
+            | VK_OEM_MINUS
+            | VK_OEM_PLUS
+            | VK_OEM_4
+            | VK_OEM_5
+            | VK_OEM_6
+            | VK_OEM_1
+            | VK_OEM_7
+            | VK_OEM_COMMA
+            | VK_OEM_PERIOD
+            | VK_OEM_2
+            | VK_OEM_102
+            | VK_OEM_8
+            | VK_ABNT_C1
+            | VK_0
+            | VK_1
+            | VK_2
+            | VK_3
+            | VK_4
+            | VK_5
+            | VK_6
+            | VK_7
+            | VK_8
+            | VK_9
+    )
+}
+
+fn get_shifted_key(vkey: VIRTUAL_KEY, scan_code: u32) -> Option<String> {
+    generate_key_char(vkey, scan_code, false, true, false)
+}
+
+pub(crate) fn generate_key_char(
+    vkey: VIRTUAL_KEY,
+    scan_code: u32,
+    control: bool,
+    shift: bool,
+    alt: bool,
+) -> Option<String> {
+    let mut state = [0; 256];
+    if control {
+        state[VK_CONTROL.0 as usize] = 0x80;
+    }
+    if shift {
+        state[VK_SHIFT.0 as usize] = 0x80;
+    }
+    if alt {
+        state[VK_MENU.0 as usize] = 0x80;
+    }
+
+    let mut buffer = [0; 8];
+    let len = unsafe { ToUnicode(vkey.0 as u32, scan_code, Some(&state), &mut buffer, 1 << 2) };
+
+    if len > 0 {
+        let candidate = String::from_utf16_lossy(&buffer[..len as usize]);
+        if !candidate.is_empty() && !candidate.chars().next().unwrap().is_control() {
+            return Some(candidate);
+        }
+    }
+    None
+}

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

@@ -33,8 +33,8 @@ use crate::{platform::blade::BladeContext, *};
 pub(crate) struct WindowsPlatform {
     state: RefCell<WindowsPlatformState>,
     raw_window_handles: RwLock<SmallVec<[HWND; 4]>>,
-    gpu_context: BladeContext,
     // The below members will never change throughout the entire lifecycle of the app.
+    gpu_context: BladeContext,
     icon: HICON,
     main_receiver: flume::Receiver<Runnable>,
     background_executor: BackgroundExecutor,
@@ -42,6 +42,7 @@ pub(crate) struct WindowsPlatform {
     text_system: Arc<DirectWriteTextSystem>,
     windows_version: WindowsVersion,
     bitmap_factory: ManuallyDrop<IWICImagingFactory>,
+    drop_target_helper: IDropTargetHelper,
     validation_number: usize,
     main_thread_id_win32: u32,
 }
@@ -62,6 +63,7 @@ struct PlatformCallbacks {
     app_menu_action: Option<Box<dyn FnMut(&dyn Action)>>,
     will_open_app_menu: Option<Box<dyn FnMut()>>,
     validate_app_menu_command: Option<Box<dyn FnMut(&dyn Action) -> bool>>,
+    keyboard_layout_change: Option<Box<dyn FnMut()>>,
 }
 
 impl WindowsPlatformState {
@@ -80,9 +82,9 @@ impl WindowsPlatformState {
 }
 
 impl WindowsPlatform {
-    pub(crate) fn new() -> Self {
+    pub(crate) fn new() -> Result<Self> {
         unsafe {
-            OleInitialize(None).expect("unable to initialize Windows OLE");
+            OleInitialize(None).context("unable to initialize Windows OLE")?;
         }
         let (main_sender, main_receiver) = flume::unbounded::<Runnable>();
         let main_thread_id_win32 = unsafe { GetCurrentThreadId() };
@@ -96,19 +98,23 @@ impl WindowsPlatform {
         let foreground_executor = ForegroundExecutor::new(dispatcher);
         let bitmap_factory = ManuallyDrop::new(unsafe {
             CoCreateInstance(&CLSID_WICImagingFactory, None, CLSCTX_INPROC_SERVER)
-                .expect("Error creating bitmap factory.")
+                .context("Error creating bitmap factory.")?
         });
         let text_system = Arc::new(
             DirectWriteTextSystem::new(&bitmap_factory)
-                .expect("Error creating DirectWriteTextSystem"),
+                .context("Error creating DirectWriteTextSystem")?,
         );
+        let drop_target_helper: IDropTargetHelper = unsafe {
+            CoCreateInstance(&CLSID_DragDropHelper, None, CLSCTX_INPROC_SERVER)
+                .context("Error creating drop target helper.")?
+        };
         let icon = load_icon().unwrap_or_default();
         let state = RefCell::new(WindowsPlatformState::new());
         let raw_window_handles = RwLock::new(SmallVec::new());
-        let gpu_context = BladeContext::new().expect("Unable to init GPU context");
-        let windows_version = WindowsVersion::new().expect("Error retrieve windows version");
+        let gpu_context = BladeContext::new().context("Unable to init GPU context")?;
+        let windows_version = WindowsVersion::new().context("Error retrieve windows version")?;
 
-        Self {
+        Ok(Self {
             state,
             raw_window_handles,
             gpu_context,
@@ -119,9 +125,10 @@ impl WindowsPlatform {
             text_system,
             windows_version,
             bitmap_factory,
+            drop_target_helper,
             validation_number,
             main_thread_id_win32,
-        }
+        })
     }
 
     fn redraw_all(&self) {
@@ -176,6 +183,7 @@ impl WindowsPlatform {
             executor: self.foreground_executor.clone(),
             current_cursor: self.state.borrow().current_cursor,
             windows_version: self.windows_version,
+            drop_target_helper: self.drop_target_helper.clone(),
             validation_number: self.validation_number,
             main_receiver: self.main_receiver.clone(),
             main_thread_id_win32: self.main_thread_id_win32,
@@ -201,6 +209,19 @@ impl WindowsPlatform {
         }
     }
 
+    fn handle_input_lang_change(&self) {
+        let mut lock = self.state.borrow_mut();
+        if let Some(mut callback) = lock.callbacks.keyboard_layout_change.take() {
+            drop(lock);
+            callback();
+            self.state
+                .borrow_mut()
+                .callbacks
+                .keyboard_layout_change
+                .get_or_insert(callback);
+        }
+    }
+
     // Returns true if the app should quit.
     fn handle_events(&self) -> bool {
         let mut msg = MSG::default();
@@ -208,7 +229,8 @@ impl WindowsPlatform {
             while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
                 match msg.message {
                     WM_QUIT => return true,
-                    WM_GPUI_CLOSE_ONE_WINDOW
+                    WM_INPUTLANGCHANGE
+                    | WM_GPUI_CLOSE_ONE_WINDOW
                     | WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD
                     | WM_GPUI_DOCK_MENU_ACTION => {
                         if self.handle_gpui_evnets(msg.message, msg.wParam, msg.lParam, &msg) {
@@ -216,9 +238,6 @@ impl WindowsPlatform {
                         }
                     }
                     _ => {
-                        // todo(windows)
-                        // crate `windows 0.56` reports true as Err
-                        TranslateMessage(&msg).as_bool();
                         DispatchMessageW(&msg);
                     }
                 }
@@ -247,6 +266,7 @@ impl WindowsPlatform {
             }
             WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD => self.run_foreground_task(),
             WM_GPUI_DOCK_MENU_ACTION => self.handle_dock_action_event(lparam.0 as _),
+            WM_INPUTLANGCHANGE => self.handle_input_lang_change(),
             _ => unreachable!(),
         }
         false
@@ -282,6 +302,18 @@ impl WindowsPlatform {
             .log_err()
             .unwrap_or_default()
     }
+
+    fn find_current_active_window(&self) -> Option<HWND> {
+        let active_window_hwnd = unsafe { GetActiveWindow() };
+        if active_window_hwnd.is_invalid() {
+            return None;
+        }
+        self.raw_window_handles
+            .read()
+            .iter()
+            .find(|&&hwnd| hwnd == active_window_hwnd)
+            .copied()
+    }
 }
 
 impl Platform for WindowsPlatform {
@@ -305,8 +337,8 @@ impl Platform for WindowsPlatform {
         )
     }
 
-    fn on_keyboard_layout_change(&self, _callback: Box<dyn FnMut()>) {
-        // todo(windows)
+    fn on_keyboard_layout_change(&self, callback: Box<dyn FnMut()>) {
+        self.state.borrow_mut().callbacks.keyboard_layout_change = Some(callback);
     }
 
     fn run(&self, on_finish_launching: Box<dyn 'static + FnOnce()>) {
@@ -460,9 +492,10 @@ impl Platform for WindowsPlatform {
         options: PathPromptOptions,
     ) -> Receiver<Result<Option<Vec<PathBuf>>>> {
         let (tx, rx) = oneshot::channel();
+        let window = self.find_current_active_window();
         self.foreground_executor()
             .spawn(async move {
-                let _ = tx.send(file_open_dialog(options));
+                let _ = tx.send(file_open_dialog(options, window));
             })
             .detach();
 
@@ -472,9 +505,10 @@ impl Platform for WindowsPlatform {
     fn prompt_for_new_path(&self, directory: &Path) -> Receiver<Result<Option<PathBuf>>> {
         let directory = directory.to_owned();
         let (tx, rx) = oneshot::channel();
+        let window = self.find_current_active_window();
         self.foreground_executor()
             .spawn(async move {
-                let _ = tx.send(file_save_dialog(directory));
+                let _ = tx.send(file_save_dialog(directory, window));
             })
             .detach();
 
@@ -560,7 +594,7 @@ impl Platform for WindowsPlatform {
 
     // todo(windows)
     fn path_for_auxiliary_executable(&self, _name: &str) -> Result<PathBuf> {
-        Err(anyhow!("not yet implemented"))
+        anyhow::bail!("not yet implemented");
     }
 
     fn set_cursor_style(&self, style: CursorStyle) {
@@ -701,6 +735,7 @@ pub(crate) struct WindowCreationInfo {
     pub(crate) executor: ForegroundExecutor,
     pub(crate) current_cursor: Option<HCURSOR>,
     pub(crate) windows_version: WindowsVersion,
+    pub(crate) drop_target_helper: IDropTargetHelper,
     pub(crate) validation_number: usize,
     pub(crate) main_receiver: flume::Receiver<Runnable>,
     pub(crate) main_thread_id_win32: u32,
@@ -741,7 +776,10 @@ fn open_target_in_explorer(target: &str) {
     }
 }
 
-fn file_open_dialog(options: PathPromptOptions) -> Result<Option<Vec<PathBuf>>> {
+fn file_open_dialog(
+    options: PathPromptOptions,
+    window: Option<HWND>,
+) -> Result<Option<Vec<PathBuf>>> {
     let folder_dialog: IFileOpenDialog =
         unsafe { CoCreateInstance(&FileOpenDialog, None, CLSCTX_ALL)? };
 
@@ -755,7 +793,7 @@ fn file_open_dialog(options: PathPromptOptions) -> Result<Option<Vec<PathBuf>>>
 
     unsafe {
         folder_dialog.SetOptions(dialog_options)?;
-        if folder_dialog.Show(None).is_err() {
+        if folder_dialog.Show(window).is_err() {
             // User cancelled
             return Ok(None);
         }
@@ -767,7 +805,7 @@ fn file_open_dialog(options: PathPromptOptions) -> Result<Option<Vec<PathBuf>>>
         return Ok(None);
     }
 
-    let mut paths = Vec::new();
+    let mut paths = Vec::with_capacity(file_count as usize);
     for i in 0..file_count {
         let item = unsafe { results.GetItemAt(i)? };
         let path = unsafe { item.GetDisplayName(SIGDN_FILESYSPATH)?.to_string()? };
@@ -777,7 +815,7 @@ fn file_open_dialog(options: PathPromptOptions) -> Result<Option<Vec<PathBuf>>>
     Ok(Some(paths))
 }
 
-fn file_save_dialog(directory: PathBuf) -> Result<Option<PathBuf>> {
+fn file_save_dialog(directory: PathBuf, window: Option<HWND>) -> Result<Option<PathBuf>> {
     let dialog: IFileSaveDialog = unsafe { CoCreateInstance(&FileSaveDialog, None, CLSCTX_ALL)? };
     if !directory.to_string_lossy().is_empty() {
         if let Some(full_path) = directory.canonicalize().log_err() {
@@ -793,7 +831,7 @@ fn file_save_dialog(directory: PathBuf) -> Result<Option<PathBuf>> {
             pszName: windows::core::w!("All files"),
             pszSpec: windows::core::w!("*.*"),
         }])?;
-        if dialog.Show(None).is_err() {
+        if dialog.Show(window).is_err() {
             // User cancelled
             return Ok(None);
         }

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

@@ -8,7 +8,7 @@ use windows::{
     },
     Wdk::System::SystemServices::RtlGetVersion,
     Win32::{Foundation::*, Graphics::Dwm::*, UI::WindowsAndMessaging::*},
-    core::BOOL,
+    core::{BOOL, HSTRING},
 };
 
 use crate::*;
@@ -186,3 +186,14 @@ pub(crate) fn system_appearance() -> Result<WindowAppearance> {
 fn is_color_light(color: &Color) -> bool {
     ((5 * color.G as u32) + (2 * color.R as u32) + color.B as u32) > (8 * 128)
 }
+
+pub(crate) fn show_error(title: &str, content: String) {
+    let _ = unsafe {
+        MessageBoxW(
+            None,
+            &HSTRING::from(content),
+            &HSTRING::from(title),
+            MB_ICONERROR | MB_SYSTEMMODAL,
+        )
+    };
+}

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

@@ -42,6 +42,8 @@ pub struct WindowsWindowState {
 
     pub callbacks: Callbacks,
     pub input_handler: Option<PlatformInputHandler>,
+    pub last_reported_modifiers: Option<Modifiers>,
+    pub last_reported_capslock: Option<Capslock>,
     pub system_key_handled: bool,
     pub hovered: bool,
 
@@ -61,6 +63,7 @@ pub struct WindowsWindowState {
 pub(crate) struct WindowsWindowStatePtr {
     hwnd: HWND,
     this: Weak<Self>,
+    drop_target_helper: IDropTargetHelper,
     pub(crate) state: RefCell<WindowsWindowState>,
     pub(crate) handle: AnyWindowHandle,
     pub(crate) hide_title_bar: bool,
@@ -100,6 +103,8 @@ impl WindowsWindowState {
         let renderer = windows_renderer::init(gpu_context, hwnd, transparent)?;
         let callbacks = Callbacks::default();
         let input_handler = None;
+        let last_reported_modifiers = None;
+        let last_reported_capslock = None;
         let system_key_handled = false;
         let hovered = false;
         let click_state = ClickState::new();
@@ -118,6 +123,8 @@ impl WindowsWindowState {
             min_size,
             callbacks,
             input_handler,
+            last_reported_modifiers,
+            last_reported_capslock,
             system_key_handled,
             hovered,
             renderer,
@@ -187,40 +194,6 @@ impl WindowsWindowState {
     fn content_size(&self) -> Size<Pixels> {
         self.logical_size
     }
-
-    fn title_bar_padding(&self) -> Pixels {
-        // using USER_DEFAULT_SCREEN_DPI because GPUI handles the scale with the scale factor
-        let padding = unsafe { GetSystemMetricsForDpi(SM_CXPADDEDBORDER, USER_DEFAULT_SCREEN_DPI) };
-        px(padding as f32)
-    }
-
-    fn title_bar_top_offset(&self) -> Pixels {
-        if self.is_maximized() {
-            self.title_bar_padding() * 2
-        } else {
-            px(0.)
-        }
-    }
-
-    fn title_bar_height(&self) -> Pixels {
-        // todo(windows) this is hardcoded to match the ui title bar
-        //               in the future the ui title bar component will report the size
-        px(32.) + self.title_bar_top_offset()
-    }
-
-    pub(crate) fn caption_button_width(&self) -> Pixels {
-        // todo(windows) this is hardcoded to match the ui title bar
-        //               in the future the ui title bar component will report the size
-        px(36.)
-    }
-
-    pub(crate) fn get_titlebar_rect(&self) -> anyhow::Result<RECT> {
-        let height = self.title_bar_height();
-        let mut rect = RECT::default();
-        unsafe { GetClientRect(self.hwnd, &mut rect) }?;
-        rect.bottom = rect.top + ((height.0 * self.scale_factor).round() as i32);
-        Ok(rect)
-    }
 }
 
 impl WindowsWindowStatePtr {
@@ -238,6 +211,7 @@ impl WindowsWindowStatePtr {
         Ok(Rc::new_cyclic(|this| Self {
             hwnd,
             this: this.clone(),
+            drop_target_helper: context.drop_target_helper.clone(),
             state,
             handle: context.handle,
             hide_title_bar: context.hide_title_bar,
@@ -344,6 +318,7 @@ pub(crate) struct Callbacks {
     pub(crate) moved: Option<Box<dyn FnMut()>>,
     pub(crate) should_close: Option<Box<dyn FnMut() -> bool>>,
     pub(crate) close: Option<Box<dyn FnOnce()>>,
+    pub(crate) hit_test_window_control: Option<Box<dyn FnMut() -> Option<WindowControlArea>>>,
     pub(crate) appearance_changed: Option<Box<dyn FnMut()>>,
 }
 
@@ -358,6 +333,7 @@ struct WindowCreateContext<'a> {
     executor: ForegroundExecutor,
     current_cursor: Option<HCURSOR>,
     windows_version: WindowsVersion,
+    drop_target_helper: IDropTargetHelper,
     validation_number: usize,
     main_receiver: flume::Receiver<Runnable>,
     gpu_context: &'a BladeContext,
@@ -376,6 +352,7 @@ impl WindowsWindow {
             executor,
             current_cursor,
             windows_version,
+            drop_target_helper,
             validation_number,
             main_receiver,
             main_thread_id_win32,
@@ -421,6 +398,7 @@ impl WindowsWindow {
             executor,
             current_cursor,
             windows_version,
+            drop_target_helper,
             validation_number,
             main_receiver,
             gpu_context,
@@ -589,6 +567,10 @@ impl PlatformWindow for WindowsWindow {
         current_modifiers()
     }
 
+    fn capslock(&self) -> Capslock {
+        current_capslock()
+    }
+
     fn set_input_handler(&mut self, input_handler: PlatformInputHandler) {
         self.0.state.borrow_mut().input_handler = Some(input_handler);
     }
@@ -602,7 +584,7 @@ impl PlatformWindow for WindowsWindow {
         level: PromptLevel,
         msg: &str,
         detail: Option<&str>,
-        answers: &[&str],
+        answers: &[PromptButton],
     ) -> Option<Receiver<usize>> {
         let (done_tx, done_rx) = oneshot::channel();
         let msg = msg.to_string();
@@ -610,8 +592,8 @@ impl PlatformWindow for WindowsWindow {
             Some(info) => Some(info.to_string()),
             None => None,
         };
-        let answers = answers.iter().map(|s| s.to_string()).collect::<Vec<_>>();
         let handle = self.0.hwnd;
+        let answers = answers.to_vec();
         self.0
             .executor
             .spawn(async move {
@@ -647,9 +629,9 @@ impl PlatformWindow for WindowsWindow {
                     let mut button_id_map = Vec::with_capacity(answers.len());
                     let mut buttons = Vec::new();
                     let mut btn_encoded = Vec::new();
-                    for (index, btn_string) in answers.iter().enumerate() {
-                        let encoded = HSTRING::from(btn_string);
-                        let button_id = if btn_string == "Cancel" {
+                    for (index, btn) in answers.iter().enumerate() {
+                        let encoded = HSTRING::from(btn.label().as_ref());
+                        let button_id = if btn.is_cancel() {
                             IDCANCEL.0
                         } else {
                             index as i32 - 100
@@ -793,6 +775,10 @@ impl PlatformWindow for WindowsWindow {
         self.0.state.borrow_mut().callbacks.close = Some(callback);
     }
 
+    fn on_hit_test_window_control(&self, callback: Box<dyn FnMut() -> Option<WindowControlArea>>) {
+        self.0.state.borrow_mut().callbacks.hit_test_window_control = Some(callback);
+    }
+
     fn on_appearance_changed(&self, callback: Box<dyn FnMut()>) {
         self.0.state.borrow_mut().callbacks.appearance_changed = Some(callback);
     }
@@ -850,8 +836,9 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl {
                 lindex: -1,
                 tymed: TYMED_HGLOBAL.0 as _,
             };
+            let cursor_position = POINT { x: pt.x, y: pt.y };
             if idata_obj.QueryGetData(&config as _) == S_OK {
-                *pdweffect = DROPEFFECT_LINK;
+                *pdweffect = DROPEFFECT_COPY;
                 let Some(mut idata) = idata_obj.GetData(&config as _).log_err() else {
                     return Ok(());
                 };
@@ -866,7 +853,7 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl {
                     }
                 });
                 ReleaseStgMedium(&mut idata);
-                let mut cursor_position = POINT { x: pt.x, y: pt.y };
+                let mut cursor_position = cursor_position;
                 ScreenToClient(self.0.hwnd, &mut cursor_position)
                     .ok()
                     .log_err();
@@ -883,6 +870,10 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl {
             } else {
                 *pdweffect = DROPEFFECT_NONE;
             }
+            self.0
+                .drop_target_helper
+                .DragEnter(self.0.hwnd, idata_obj, &cursor_position, *pdweffect)
+                .log_err();
         }
         Ok(())
     }
@@ -891,10 +882,15 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl {
         &self,
         _grfkeystate: MODIFIERKEYS_FLAGS,
         pt: &POINTL,
-        _pdweffect: *mut DROPEFFECT,
+        pdweffect: *mut DROPEFFECT,
     ) -> windows::core::Result<()> {
         let mut cursor_position = POINT { x: pt.x, y: pt.y };
         unsafe {
+            *pdweffect = DROPEFFECT_COPY;
+            self.0
+                .drop_target_helper
+                .DragOver(&cursor_position, *pdweffect)
+                .log_err();
             ScreenToClient(self.0.hwnd, &mut cursor_position)
                 .ok()
                 .log_err();
@@ -913,6 +909,9 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl {
     }
 
     fn DragLeave(&self) -> windows::core::Result<()> {
+        unsafe {
+            self.0.drop_target_helper.DragLeave().log_err();
+        }
         let input = PlatformInput::FileDrop(FileDropEvent::Exited);
         self.handle_drag_drop(input);
 
@@ -921,13 +920,19 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl {
 
     fn Drop(
         &self,
-        _pdataobj: windows::core::Ref<IDataObject>,
+        pdataobj: windows::core::Ref<IDataObject>,
         _grfkeystate: MODIFIERKEYS_FLAGS,
         pt: &POINTL,
-        _pdweffect: *mut DROPEFFECT,
+        pdweffect: *mut DROPEFFECT,
     ) -> windows::core::Result<()> {
+        let idata_obj = pdataobj.ok()?;
         let mut cursor_position = POINT { x: pt.x, y: pt.y };
         unsafe {
+            *pdweffect = DROPEFFECT_COPY;
+            self.0
+                .drop_target_helper
+                .Drop(idata_obj, &cursor_position, *pdweffect)
+                .log_err();
             ScreenToClient(self.0.hwnd, &mut cursor_position)
                 .ok()
                 .log_err();
@@ -1245,11 +1250,13 @@ fn set_window_composition_attribute(hwnd: HWND, color: Option<Color>, state: u32
         type SetWindowCompositionAttributeType =
             unsafe extern "system" fn(HWND, *mut WINDOWCOMPOSITIONATTRIBDATA) -> BOOL;
         let module_name = PCSTR::from_raw(c"user32.dll".as_ptr() as *const u8);
-        let user32 = GetModuleHandleA(module_name);
-        if user32.is_ok() {
+        if let Some(user32) = GetModuleHandleA(module_name)
+            .context("Unable to get user32.dll handle")
+            .log_err()
+        {
             let func_name = PCSTR::from_raw(c"SetWindowCompositionAttribute".as_ptr() as *const u8);
             let set_window_composition_attribute: SetWindowCompositionAttributeType =
-                std::mem::transmute(GetProcAddress(user32.unwrap(), func_name));
+                std::mem::transmute(GetProcAddress(user32, func_name));
             let mut color = color.unwrap_or_default();
             let is_acrylic = state == 4;
             if is_acrylic && color.3 == 0 {
@@ -1270,10 +1277,6 @@ fn set_window_composition_attribute(hwnd: HWND, color: Option<Color>, state: u32
                 cb_data: std::mem::size_of::<AccentPolicy>(),
             };
             let _ = set_window_composition_attribute(hwnd, &mut data as *mut _ as _);
-        } else {
-            let _ = user32
-                .inspect_err(|e| log::error!("Error getting module: {e}"))
-                .ok();
         }
     }
 }
@@ -1284,7 +1287,7 @@ mod windows_renderer {
     use std::num::NonZeroIsize;
     use windows::Win32::{Foundation::HWND, UI::WindowsAndMessaging::GWLP_HINSTANCE};
 
-    use crate::get_window_long;
+    use crate::{get_window_long, show_error};
 
     pub(super) fn init(
         context: &BladeContext,
@@ -1296,7 +1299,12 @@ mod windows_renderer {
             size: Default::default(),
             transparent,
         };
-        BladeRenderer::new(context, &raw, config)
+        BladeRenderer::new(context, &raw, config).inspect_err(|err| {
+            show_error(
+                "Error: Zed failed to initialize BladeRenderer",
+                err.to_string(),
+            )
+        })
     }
 
     struct RawWindow {

crates/gpui/src/scene.rs 🔗

@@ -1,6 +1,9 @@
 // todo("windows"): remove
 #![cfg_attr(windows, allow(dead_code))]
 
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
 use crate::{
     AtlasTextureId, AtlasTile, Background, Bounds, ContentMask, Corners, Edges, Hsla, Pixels,
     Point, Radians, ScaledPixels, Size, bounds_tree::BoundsTree, point,
@@ -146,7 +149,7 @@ impl Scene {
         ),
         allow(dead_code)
     )]
-    pub(crate) fn batches(&self) -> impl Iterator<Item = PrimitiveBatch> {
+    pub(crate) fn batches(&self) -> impl Iterator<Item = PrimitiveBatch<'_>> {
         BatchIterator {
             shadows: &self.shadows,
             shadows_start: 0,
@@ -506,7 +509,7 @@ impl From<Shadow> for Primitive {
 }
 
 /// The style of a border.
-#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
+#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
 #[repr(C)]
 pub enum BorderStyle {
     /// A solid border.
@@ -676,7 +679,7 @@ pub(crate) struct PathId(pub(crate) usize);
 
 /// A line made up of a series of vertices and control points.
 #[derive(Clone, Debug)]
-pub struct Path<P: Clone + Default + Debug> {
+pub struct Path<P: Clone + Debug + Default + PartialEq> {
     pub(crate) id: PathId,
     order: DrawOrder,
     pub(crate) bounds: Bounds<P>,
@@ -809,7 +812,7 @@ impl From<Path<ScaledPixels>> for Primitive {
 
 #[derive(Clone, Debug)]
 #[repr(C)]
-pub(crate) struct PathVertex<P: Clone + Default + Debug> {
+pub(crate) struct PathVertex<P: Clone + Debug + Default + PartialEq> {
     pub(crate) xy_position: Point<P>,
     pub(crate) st_position: Point<f32>,
     pub(crate) content_mask: ContentMask<P>,

crates/gpui/src/style.rs 🔗

@@ -13,11 +13,8 @@ use crate::{
 };
 use collections::HashSet;
 use refineable::Refineable;
-use smallvec::SmallVec;
-pub use taffy::style::{
-    AlignContent, AlignItems, AlignSelf, Display, FlexDirection, FlexWrap, JustifyContent,
-    Overflow, Position,
-};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
 
 /// Use this struct for interfacing with the 'debug_below' styling from your own elements.
 /// If a parent element has this style set on it, then this struct will be set as a global in
@@ -143,7 +140,7 @@ impl ObjectFit {
 
 /// The CSS styling that can be applied to an element via the `Styled` trait
 #[derive(Clone, Refineable, Debug)]
-#[refineable(Debug)]
+#[refineable(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub struct Style {
     /// What layout strategy should be used?
     pub display: Display,
@@ -252,7 +249,7 @@ pub struct Style {
     pub corner_radii: Corners<AbsoluteLength>,
 
     /// Box shadow of the element
-    pub box_shadow: SmallVec<[BoxShadow; 2]>,
+    pub box_shadow: Vec<BoxShadow>,
 
     /// The text style of this element
     pub text: TextStyleRefinement,
@@ -279,7 +276,7 @@ impl Styled for StyleRefinement {
 }
 
 /// The value of the visibility property, similar to the CSS property `visibility`
-#[derive(Default, Clone, Copy, Debug, Eq, PartialEq)]
+#[derive(Default, Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub enum Visibility {
     /// The element should be drawn as normal.
     #[default]
@@ -289,7 +286,7 @@ pub enum Visibility {
 }
 
 /// The possible values of the box-shadow property
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub struct BoxShadow {
     /// What color should the shadow have?
     pub color: Hsla,
@@ -302,7 +299,7 @@ pub struct BoxShadow {
 }
 
 /// How to handle whitespace in text
-#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 pub enum WhiteSpace {
     /// Normal line wrapping when text overflows the width of the element
     #[default]
@@ -312,14 +309,15 @@ pub enum WhiteSpace {
 }
 
 /// How to truncate text that overflows the width of the element
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 pub enum TextOverflow {
-    /// Truncate the text with an ellipsis, same as: `text-overflow: ellipsis;` in CSS
-    Ellipsis(&'static str),
+    /// Truncate the text when it doesn't fit, and represent this truncation by displaying the
+    /// provided string.
+    Truncate(SharedString),
 }
 
 /// How to align text within the element
-#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 pub enum TextAlign {
     /// Align the text to the left of the element
     #[default]
@@ -334,7 +332,7 @@ pub enum TextAlign {
 
 /// The properties that can be used to style text in GPUI
 #[derive(Refineable, Clone, Debug, PartialEq)]
-#[refineable(Debug)]
+#[refineable(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub struct TextStyle {
     /// The color of the text
     pub color: Hsla,
@@ -769,8 +767,9 @@ impl Default for Style {
 }
 
 /// The properties that can be applied to an underline.
-#[derive(Refineable, Copy, Clone, Default, Debug, PartialEq, Eq, Hash)]
-#[refineable(Debug)]
+#[derive(
+    Refineable, Copy, Clone, Default, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema,
+)]
 pub struct UnderlineStyle {
     /// The thickness of the underline.
     pub thickness: Pixels,
@@ -783,8 +782,9 @@ pub struct UnderlineStyle {
 }
 
 /// The properties that can be applied to a strikethrough.
-#[derive(Refineable, Copy, Clone, Default, Debug, PartialEq, Eq, Hash)]
-#[refineable(Debug)]
+#[derive(
+    Refineable, Copy, Clone, Default, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema,
+)]
 pub struct StrikethroughStyle {
     /// The thickness of the strikethrough.
     pub thickness: Pixels,
@@ -794,7 +794,7 @@ pub struct StrikethroughStyle {
 }
 
 /// The kinds of fill that can be applied to a shape.
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub enum Fill {
     /// A solid color fill.
     Color(Background),
@@ -984,6 +984,305 @@ pub fn combine_highlights(
     })
 }
 
+/// Used to control how child nodes are aligned.
+/// For Flexbox it controls alignment in the cross axis
+/// For Grid it controls alignment in the block axis
+///
+/// [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/align-items)
+#[derive(Copy, Clone, PartialEq, Eq, Debug, Serialize, Deserialize, JsonSchema)]
+// Copy of taffy::style type of the same name, to derive JsonSchema.
+pub enum AlignItems {
+    /// Items are packed toward the start of the axis
+    Start,
+    /// Items are packed toward the end of the axis
+    End,
+    /// Items are packed towards the flex-relative start of the axis.
+    ///
+    /// For flex containers with flex_direction RowReverse or ColumnReverse this is equivalent
+    /// to End. In all other cases it is equivalent to Start.
+    FlexStart,
+    /// Items are packed towards the flex-relative end of the axis.
+    ///
+    /// For flex containers with flex_direction RowReverse or ColumnReverse this is equivalent
+    /// to Start. In all other cases it is equivalent to End.
+    FlexEnd,
+    /// Items are packed along the center of the cross axis
+    Center,
+    /// Items are aligned such as their baselines align
+    Baseline,
+    /// Stretch to fill the container
+    Stretch,
+}
+/// Used to control how child nodes are aligned.
+/// Does not apply to Flexbox, and will be ignored if specified on a flex container
+/// For Grid it controls alignment in the inline axis
+///
+/// [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/justify-items)
+pub type JustifyItems = AlignItems;
+/// Used to control how the specified nodes is aligned.
+/// Overrides the parent Node's `AlignItems` property.
+/// For Flexbox it controls alignment in the cross axis
+/// For Grid it controls alignment in the block axis
+///
+/// [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/align-self)
+pub type AlignSelf = AlignItems;
+/// Used to control how the specified nodes is aligned.
+/// Overrides the parent Node's `JustifyItems` property.
+/// Does not apply to Flexbox, and will be ignored if specified on a flex child
+/// For Grid it controls alignment in the inline axis
+///
+/// [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/justify-self)
+pub type JustifySelf = AlignItems;
+
+/// Sets the distribution of space between and around content items
+/// For Flexbox it controls alignment in the cross axis
+/// For Grid it controls alignment in the block axis
+///
+/// [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/align-content)
+#[derive(Copy, Clone, PartialEq, Eq, Debug, Serialize, Deserialize, JsonSchema)]
+// Copy of taffy::style type of the same name, to derive JsonSchema.
+pub enum AlignContent {
+    /// Items are packed toward the start of the axis
+    Start,
+    /// Items are packed toward the end of the axis
+    End,
+    /// Items are packed towards the flex-relative start of the axis.
+    ///
+    /// For flex containers with flex_direction RowReverse or ColumnReverse this is equivalent
+    /// to End. In all other cases it is equivalent to Start.
+    FlexStart,
+    /// Items are packed towards the flex-relative end of the axis.
+    ///
+    /// For flex containers with flex_direction RowReverse or ColumnReverse this is equivalent
+    /// to Start. In all other cases it is equivalent to End.
+    FlexEnd,
+    /// Items are centered around the middle of the axis
+    Center,
+    /// Items are stretched to fill the container
+    Stretch,
+    /// The first and last items are aligned flush with the edges of the container (no gap)
+    /// The gap between items is distributed evenly.
+    SpaceBetween,
+    /// The gap between the first and last items is exactly THE SAME as the gap between items.
+    /// The gaps are distributed evenly
+    SpaceEvenly,
+    /// The gap between the first and last items is exactly HALF the gap between items.
+    /// The gaps are distributed evenly in proportion to these ratios.
+    SpaceAround,
+}
+
+/// Sets the distribution of space between and around content items
+/// For Flexbox it controls alignment in the main axis
+/// For Grid it controls alignment in the inline axis
+///
+/// [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/justify-content)
+pub type JustifyContent = AlignContent;
+
+/// Sets the layout used for the children of this node
+///
+/// The default values depends on on which feature flags are enabled. The order of precedence is: Flex, Grid, Block, None.
+#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize, JsonSchema)]
+// Copy of taffy::style type of the same name, to derive JsonSchema.
+pub enum Display {
+    /// The children will follow the block layout algorithm
+    Block,
+    /// The children will follow the flexbox layout algorithm
+    #[default]
+    Flex,
+    /// The children will follow the CSS Grid layout algorithm
+    Grid,
+    /// The children will not be laid out, and will follow absolute positioning
+    None,
+}
+
+/// Controls whether flex items are forced onto one line or can wrap onto multiple lines.
+///
+/// Defaults to [`FlexWrap::NoWrap`]
+///
+/// [Specification](https://www.w3.org/TR/css-flexbox-1/#flex-wrap-property)
+#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize, JsonSchema)]
+// Copy of taffy::style type of the same name, to derive JsonSchema.
+pub enum FlexWrap {
+    /// Items will not wrap and stay on a single line
+    #[default]
+    NoWrap,
+    /// Items will wrap according to this item's [`FlexDirection`]
+    Wrap,
+    /// Items will wrap in the opposite direction to this item's [`FlexDirection`]
+    WrapReverse,
+}
+
+/// The direction of the flexbox layout main axis.
+///
+/// There are always two perpendicular layout axes: main (or primary) and cross (or secondary).
+/// Adding items will cause them to be positioned adjacent to each other along the main axis.
+/// By varying this value throughout your tree, you can create complex axis-aligned layouts.
+///
+/// Items are always aligned relative to the cross axis, and justified relative to the main axis.
+///
+/// The default behavior is [`FlexDirection::Row`].
+///
+/// [Specification](https://www.w3.org/TR/css-flexbox-1/#flex-direction-property)
+#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize, JsonSchema)]
+// Copy of taffy::style type of the same name, to derive JsonSchema.
+pub enum FlexDirection {
+    /// Defines +x as the main axis
+    ///
+    /// Items will be added from left to right in a row.
+    #[default]
+    Row,
+    /// Defines +y as the main axis
+    ///
+    /// Items will be added from top to bottom in a column.
+    Column,
+    /// Defines -x as the main axis
+    ///
+    /// Items will be added from right to left in a row.
+    RowReverse,
+    /// Defines -y as the main axis
+    ///
+    /// Items will be added from bottom to top in a column.
+    ColumnReverse,
+}
+
+/// How children overflowing their container should affect layout
+///
+/// In CSS the primary effect of this property is to control whether contents of a parent container that overflow that container should
+/// be displayed anyway, be clipped, or trigger the container to become a scroll container. However it also has secondary effects on layout,
+/// the main ones being:
+///
+///   - The automatic minimum size Flexbox/CSS Grid items with non-`Visible` overflow is `0` rather than being content based
+///   - `Overflow::Scroll` nodes have space in the layout reserved for a scrollbar (width controlled by the `scrollbar_width` property)
+///
+/// In Taffy, we only implement the layout related secondary effects as we are not concerned with drawing/painting. The amount of space reserved for
+/// a scrollbar is controlled by the `scrollbar_width` property. If this is `0` then `Scroll` behaves identically to `Hidden`.
+///
+/// <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow>
+#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize, JsonSchema)]
+// Copy of taffy::style type of the same name, to derive JsonSchema.
+pub enum Overflow {
+    /// The automatic minimum size of this node as a flexbox/grid item should be based on the size of its content.
+    /// Content that overflows this node *should* contribute to the scroll region of its parent.
+    #[default]
+    Visible,
+    /// The automatic minimum size of this node as a flexbox/grid item should be based on the size of its content.
+    /// Content that overflows this node should *not* contribute to the scroll region of its parent.
+    Clip,
+    /// The automatic minimum size of this node as a flexbox/grid item should be `0`.
+    /// Content that overflows this node should *not* contribute to the scroll region of its parent.
+    Hidden,
+    /// The automatic minimum size of this node as a flexbox/grid item should be `0`. Additionally, space should be reserved
+    /// for a scrollbar. The amount of space reserved is controlled by the `scrollbar_width` property.
+    /// Content that overflows this node should *not* contribute to the scroll region of its parent.
+    Scroll,
+}
+
+/// The positioning strategy for this item.
+///
+/// This controls both how the origin is determined for the [`Style::position`] field,
+/// and whether or not the item will be controlled by flexbox's layout algorithm.
+///
+/// WARNING: this enum follows the behavior of [CSS's `position` property](https://developer.mozilla.org/en-US/docs/Web/CSS/position),
+/// which can be unintuitive.
+///
+/// [`Position::Relative`] is the default value, in contrast to the default behavior in CSS.
+#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize, JsonSchema)]
+// Copy of taffy::style type of the same name, to derive JsonSchema.
+pub enum Position {
+    /// The offset is computed relative to the final position given by the layout algorithm.
+    /// Offsets do not affect the position of any other items; they are effectively a correction factor applied at the end.
+    #[default]
+    Relative,
+    /// The offset is computed relative to this item's closest positioned ancestor, if any.
+    /// Otherwise, it is placed relative to the origin.
+    /// No space is created for the item in the page layout, and its size will not be altered.
+    ///
+    /// WARNING: to opt-out of layouting entirely, you must use [`Display::None`] instead on your [`Style`] object.
+    Absolute,
+}
+
+impl From<AlignItems> for taffy::style::AlignItems {
+    fn from(value: AlignItems) -> Self {
+        match value {
+            AlignItems::Start => Self::Start,
+            AlignItems::End => Self::End,
+            AlignItems::FlexStart => Self::FlexStart,
+            AlignItems::FlexEnd => Self::FlexEnd,
+            AlignItems::Center => Self::Center,
+            AlignItems::Baseline => Self::Baseline,
+            AlignItems::Stretch => Self::Stretch,
+        }
+    }
+}
+
+impl From<AlignContent> for taffy::style::AlignContent {
+    fn from(value: AlignContent) -> Self {
+        match value {
+            AlignContent::Start => Self::Start,
+            AlignContent::End => Self::End,
+            AlignContent::FlexStart => Self::FlexStart,
+            AlignContent::FlexEnd => Self::FlexEnd,
+            AlignContent::Center => Self::Center,
+            AlignContent::Stretch => Self::Stretch,
+            AlignContent::SpaceBetween => Self::SpaceBetween,
+            AlignContent::SpaceEvenly => Self::SpaceEvenly,
+            AlignContent::SpaceAround => Self::SpaceAround,
+        }
+    }
+}
+
+impl From<Display> for taffy::style::Display {
+    fn from(value: Display) -> Self {
+        match value {
+            Display::Block => Self::Block,
+            Display::Flex => Self::Flex,
+            Display::Grid => Self::Grid,
+            Display::None => Self::None,
+        }
+    }
+}
+
+impl From<FlexWrap> for taffy::style::FlexWrap {
+    fn from(value: FlexWrap) -> Self {
+        match value {
+            FlexWrap::NoWrap => Self::NoWrap,
+            FlexWrap::Wrap => Self::Wrap,
+            FlexWrap::WrapReverse => Self::WrapReverse,
+        }
+    }
+}
+
+impl From<FlexDirection> for taffy::style::FlexDirection {
+    fn from(value: FlexDirection) -> Self {
+        match value {
+            FlexDirection::Row => Self::Row,
+            FlexDirection::Column => Self::Column,
+            FlexDirection::RowReverse => Self::RowReverse,
+            FlexDirection::ColumnReverse => Self::ColumnReverse,
+        }
+    }
+}
+
+impl From<Overflow> for taffy::style::Overflow {
+    fn from(value: Overflow) -> Self {
+        match value {
+            Overflow::Visible => Self::Visible,
+            Overflow::Clip => Self::Clip,
+            Overflow::Hidden => Self::Hidden,
+            Overflow::Scroll => Self::Scroll,
+        }
+    }
+}
+
+impl From<Position> for taffy::style::Position {
+    fn from(value: Position) -> Self {
+        match value {
+            Position::Relative => Self::Relative,
+            Position::Absolute => Self::Absolute,
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use crate::{blue, green, red, yellow};

crates/gpui/src/styled.rs 🔗

@@ -1,21 +1,23 @@
 use crate::{
-    self as gpui, AbsoluteLength, AlignItems, BorderStyle, CursorStyle, DefiniteLength, Fill,
-    FlexDirection, FlexWrap, Font, FontStyle, FontWeight, Hsla, JustifyContent, Length,
-    SharedString, StrikethroughStyle, StyleRefinement, TextOverflow, UnderlineStyle, WhiteSpace,
-    px, relative, rems,
+    self as gpui, AbsoluteLength, AlignContent, AlignItems, BorderStyle, CursorStyle,
+    DefiniteLength, Display, Fill, FlexDirection, FlexWrap, Font, FontStyle, FontWeight, Hsla,
+    JustifyContent, Length, SharedString, StrikethroughStyle, StyleRefinement, TextAlign,
+    TextOverflow, TextStyleRefinement, UnderlineStyle, WhiteSpace, px, relative, rems,
 };
-use crate::{TextAlign, TextStyleRefinement};
 pub use gpui_macros::{
     border_style_methods, box_shadow_style_methods, cursor_style_methods, margin_style_methods,
     overflow_style_methods, padding_style_methods, position_style_methods,
     visibility_style_methods,
 };
-use taffy::style::{AlignContent, Display};
 
-const ELLIPSIS: &str = "…";
+const ELLIPSIS: SharedString = SharedString::new_static("…");
 
 /// A trait for elements that can be styled.
 /// Use this to opt-in to a utility CSS-like styling API.
+#[cfg_attr(
+    any(feature = "inspector", debug_assertions),
+    gpui_macros::derive_inspector_reflection
+)]
 pub trait Styled: Sized {
     /// Returns a reference to the style memory of this element.
     fn style(&mut self) -> &mut StyleRefinement;
@@ -67,7 +69,7 @@ pub trait Styled: Sized {
     fn text_ellipsis(mut self) -> Self {
         self.text_style()
             .get_or_insert_with(Default::default)
-            .text_overflow = Some(TextOverflow::Ellipsis(ELLIPSIS));
+            .text_overflow = Some(TextOverflow::Truncate(ELLIPSIS));
         self
     }
 

crates/gpui/src/subscription.rs 🔗

@@ -1,10 +1,14 @@
 use collections::{BTreeMap, BTreeSet};
-use parking_lot::Mutex;
-use std::{cell::Cell, fmt::Debug, mem, rc::Rc, sync::Arc};
+use std::{
+    cell::{Cell, RefCell},
+    fmt::Debug,
+    mem,
+    rc::Rc,
+};
 use util::post_inc;
 
 pub(crate) struct SubscriberSet<EmitterKey, Callback>(
-    Arc<Mutex<SubscriberSetState<EmitterKey, Callback>>>,
+    Rc<RefCell<SubscriberSetState<EmitterKey, Callback>>>,
 );
 
 impl<EmitterKey, Callback> Clone for SubscriberSet<EmitterKey, Callback> {
@@ -30,7 +34,7 @@ where
     Callback: 'static,
 {
     pub fn new() -> Self {
-        Self(Arc::new(Mutex::new(SubscriberSetState {
+        Self(Rc::new(RefCell::new(SubscriberSetState {
             subscribers: Default::default(),
             dropped_subscribers: Default::default(),
             next_subscriber_id: 0,
@@ -47,7 +51,7 @@ where
         callback: Callback,
     ) -> (Subscription, impl FnOnce() + use<EmitterKey, Callback>) {
         let active = Rc::new(Cell::new(false));
-        let mut lock = self.0.lock();
+        let mut lock = self.0.borrow_mut();
         let subscriber_id = post_inc(&mut lock.next_subscriber_id);
         lock.subscribers
             .entry(emitter_key.clone())
@@ -64,7 +68,7 @@ where
 
         let subscription = Subscription {
             unsubscribe: Some(Box::new(move || {
-                let mut lock = this.lock();
+                let mut lock = this.borrow_mut();
                 let Some(subscribers) = lock.subscribers.get_mut(&emitter_key) else {
                     // remove was called with this emitter_key
                     return;
@@ -92,7 +96,7 @@ where
         &self,
         emitter: &EmitterKey,
     ) -> impl IntoIterator<Item = Callback> + use<EmitterKey, Callback> {
-        let subscribers = self.0.lock().subscribers.remove(emitter);
+        let subscribers = self.0.borrow_mut().subscribers.remove(emitter);
         subscribers
             .unwrap_or_default()
             .map(|s| s.into_values())
@@ -115,7 +119,7 @@ where
     {
         let Some(mut subscribers) = self
             .0
-            .lock()
+            .borrow_mut()
             .subscribers
             .get_mut(emitter)
             .and_then(|s| s.take())
@@ -130,7 +134,7 @@ where
                 true
             }
         });
-        let mut lock = self.0.lock();
+        let mut lock = self.0.borrow_mut();
 
         // Add any new subscribers that were added while invoking the callback.
         if let Some(Some(new_subscribers)) = lock.subscribers.remove(emitter) {

crates/gpui/src/svg_renderer.rs 🔗

@@ -1,5 +1,4 @@
 use crate::{AssetSource, DevicePixels, IsZero, Result, SharedString, Size};
-use anyhow::anyhow;
 use resvg::tiny_skia::Pixmap;
 use std::{
     hash::Hash,
@@ -56,9 +55,7 @@ impl SvgRenderer {
     }
 
     pub(crate) fn render(&self, params: &RenderSvgParams) -> Result<Option<Vec<u8>>> {
-        if params.size.is_zero() {
-            return Err(anyhow!("can't render at a zero size"));
-        }
+        anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size");
 
         // Load the tree.
         let Some(bytes) = self.asset_source.load(&params.path)? else {

crates/gpui/src/taffy.rs 🔗

@@ -28,8 +28,10 @@ const EXPECT_MESSAGE: &str = "we should avoid taffy layout errors by constructio
 
 impl TaffyLayoutEngine {
     pub fn new() -> Self {
+        let mut taffy = TaffyTree::new();
+        taffy.disable_rounding();
         TaffyLayoutEngine {
-            taffy: TaffyTree::new(),
+            taffy,
             absolute_layout_bounds: FxHashMap::default(),
             computed_layouts: FxHashSet::default(),
         }
@@ -250,10 +252,10 @@ trait ToTaffy<Output> {
 impl ToTaffy<taffy::style::Style> for Style {
     fn to_taffy(&self, rem_size: Pixels) -> taffy::style::Style {
         taffy::style::Style {
-            display: self.display,
+            display: self.display.into(),
             overflow: self.overflow.into(),
             scrollbar_width: self.scrollbar_width,
-            position: self.position,
+            position: self.position.into(),
             inset: self.inset.to_taffy(rem_size),
             size: self.size.to_taffy(rem_size),
             min_size: self.min_size.to_taffy(rem_size),
@@ -262,13 +264,13 @@ impl ToTaffy<taffy::style::Style> for Style {
             margin: self.margin.to_taffy(rem_size),
             padding: self.padding.to_taffy(rem_size),
             border: self.border_widths.to_taffy(rem_size),
-            align_items: self.align_items,
-            align_self: self.align_self,
-            align_content: self.align_content,
-            justify_content: self.justify_content,
+            align_items: self.align_items.map(|x| x.into()),
+            align_self: self.align_self.map(|x| x.into()),
+            align_content: self.align_content.map(|x| x.into()),
+            justify_content: self.justify_content.map(|x| x.into()),
             gap: self.gap.to_taffy(rem_size),
-            flex_direction: self.flex_direction,
-            flex_wrap: self.flex_wrap,
+            flex_direction: self.flex_direction.into(),
+            flex_wrap: self.flex_wrap.into(),
             flex_basis: self.flex_basis.to_taffy(rem_size),
             flex_grow: self.flex_grow,
             flex_shrink: self.flex_shrink,
@@ -359,7 +361,7 @@ impl ToTaffy<taffy::style::LengthPercentage> for AbsoluteLength {
 impl<T, T2> From<TaffyPoint<T>> for Point<T2>
 where
     T: Into<T2>,
-    T2: Clone + Default + Debug,
+    T2: Clone + Debug + Default + PartialEq,
 {
     fn from(point: TaffyPoint<T>) -> Point<T2> {
         Point {
@@ -371,7 +373,7 @@ where
 
 impl<T, T2> From<Point<T>> for TaffyPoint<T2>
 where
-    T: Into<T2> + Clone + Default + Debug,
+    T: Into<T2> + Clone + Debug + Default + PartialEq,
 {
     fn from(val: Point<T>) -> Self {
         TaffyPoint {
@@ -383,7 +385,7 @@ where
 
 impl<T, U> ToTaffy<TaffySize<U>> for Size<T>
 where
-    T: ToTaffy<U> + Clone + Default + Debug,
+    T: ToTaffy<U> + Clone + Debug + Default + PartialEq,
 {
     fn to_taffy(&self, rem_size: Pixels) -> TaffySize<U> {
         TaffySize {
@@ -395,7 +397,7 @@ where
 
 impl<T, U> ToTaffy<TaffyRect<U>> for Edges<T>
 where
-    T: ToTaffy<U> + Clone + Default + Debug,
+    T: ToTaffy<U> + Clone + Debug + Default + PartialEq,
 {
     fn to_taffy(&self, rem_size: Pixels) -> TaffyRect<U> {
         TaffyRect {
@@ -410,7 +412,7 @@ where
 impl<T, U> From<TaffySize<T>> for Size<U>
 where
     T: Into<U>,
-    U: Clone + Default + Debug,
+    U: Clone + Debug + Default + PartialEq,
 {
     fn from(taffy_size: TaffySize<T>) -> Self {
         Size {
@@ -422,7 +424,7 @@ where
 
 impl<T, U> From<Size<T>> for TaffySize<U>
 where
-    T: Into<U> + Clone + Default + Debug,
+    T: Into<U> + Clone + Debug + Default + PartialEq,
 {
     fn from(size: Size<T>) -> Self {
         TaffySize {

crates/gpui/src/text_system.rs 🔗

@@ -16,7 +16,7 @@ use crate::{
     Bounds, DevicePixels, Hsla, Pixels, PlatformTextSystem, Point, Result, SharedString, Size,
     StrikethroughStyle, UnderlineStyle, px,
 };
-use anyhow::anyhow;
+use anyhow::{Context as _, anyhow};
 use collections::FxHashMap;
 use core::fmt;
 use derive_more::Deref;
@@ -100,7 +100,7 @@ impl TextSystem {
         fn clone_font_id_result(font_id: &Result<FontId>) -> Result<FontId> {
             match font_id {
                 Ok(font_id) => Ok(*font_id),
-                Err(err) => Err(anyhow!("{}", err)),
+                Err(err) => Err(anyhow!("{err}")),
             }
         }
 
@@ -174,7 +174,7 @@ impl TextSystem {
         let glyph_id = self
             .platform_text_system
             .glyph_for_char(font_id, character)
-            .ok_or_else(|| anyhow!("glyph not found for character '{}'", character))?;
+            .with_context(|| format!("glyph not found for character '{character}'"))?;
         let bounds = self
             .platform_text_system
             .typographic_bounds(font_id, glyph_id)?;
@@ -188,7 +188,7 @@ impl TextSystem {
         let glyph_id = self
             .platform_text_system
             .glyph_for_char(font_id, ch)
-            .ok_or_else(|| anyhow!("glyph not found for character '{}'", ch))?;
+            .with_context(|| format!("glyph not found for character '{ch}'"))?;
         let result = self.platform_text_system.advance(font_id, glyph_id)?
             / self.units_per_em(font_id) as f32;
 
@@ -209,6 +209,20 @@ impl TextSystem {
         Ok(self.advance(font_id, font_size, 'm')?.width)
     }
 
+    /// Returns the width of an `ch`.
+    ///
+    /// Uses the width of the `0` character in the given font and size.
+    pub fn ch_width(&self, font_id: FontId, font_size: Pixels) -> Result<Pixels> {
+        Ok(self.typographic_bounds(font_id, font_size, '0')?.size.width)
+    }
+
+    /// Returns the advance width of an `ch`.
+    ///
+    /// Uses the advance width of the `0` character in the given font and size.
+    pub fn ch_advance(&self, font_id: FontId, font_size: Pixels) -> Result<Pixels> {
+        Ok(self.advance(font_id, font_size, '0')?.width)
+    }
+
     /// Get the number of font size units per 'em square',
     /// Per MDN: "an abstract square whose height is the intended distance between
     /// lines of type in the same type size"
@@ -343,7 +357,7 @@ impl WindowTextSystem {
         text: SharedString,
         font_size: Pixels,
         runs: &[TextRun],
-    ) -> Result<ShapedLine> {
+    ) -> ShapedLine {
         debug_assert!(
             text.find('\n').is_none(),
             "text argument should not contain newlines"
@@ -370,13 +384,13 @@ impl WindowTextSystem {
             });
         }
 
-        let layout = self.layout_line(&text, font_size, runs)?;
+        let layout = self.layout_line(&text, font_size, runs);
 
-        Ok(ShapedLine {
+        ShapedLine {
             layout,
             text,
             decoration_runs,
-        })
+        }
     }
 
     /// Shape a multi line string of text, at the given font_size, for painting to the screen.
@@ -510,7 +524,7 @@ impl WindowTextSystem {
         text: Text,
         font_size: Pixels,
         runs: &[TextRun],
-    ) -> Result<Arc<LineLayout>>
+    ) -> Arc<LineLayout>
     where
         Text: AsRef<str>,
         SharedString: From<Text>,
@@ -537,7 +551,7 @@ impl WindowTextSystem {
         font_runs.clear();
         self.font_runs_pool.lock().push(font_runs);
 
-        Ok(layout)
+        layout
     }
 }
 
@@ -583,7 +597,7 @@ impl DerefMut for LineWrapperHandle {
 
 /// The degree of blackness or stroke thickness of a font. This value ranges from 100.0 to 900.0,
 /// with 400.0 as normal.
-#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Deserialize, Serialize, JsonSchema)]
+#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Serialize, Deserialize, JsonSchema)]
 pub struct FontWeight(pub f32);
 
 impl Default for FontWeight {
@@ -636,7 +650,7 @@ impl FontWeight {
 }
 
 /// Allows italic or oblique faces to be selected.
-#[derive(Clone, Copy, Eq, PartialEq, Debug, Hash, Default)]
+#[derive(Clone, Copy, Eq, PartialEq, Debug, Hash, Default, Serialize, Deserialize, JsonSchema)]
 pub enum FontStyle {
     /// A face that is neither italic not obliqued.
     #[default]

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

@@ -34,7 +34,7 @@ pub struct ShapedRun {
     /// The font id for this run
     pub font_id: FontId,
     /// The glyphs that make up this run
-    pub glyphs: SmallVec<[ShapedGlyph; 8]>,
+    pub glyphs: Vec<ShapedGlyph>,
 }
 
 /// A single glyph, ready to paint.
@@ -582,7 +582,7 @@ pub struct FontRun {
 }
 
 trait AsCacheKeyRef {
-    fn as_cache_key_ref(&self) -> CacheKeyRef;
+    fn as_cache_key_ref(&self) -> CacheKeyRef<'_>;
 }
 
 #[derive(Clone, Debug, Eq)]
@@ -616,7 +616,7 @@ impl Hash for (dyn AsCacheKeyRef + '_) {
 }
 
 impl AsCacheKeyRef for CacheKey {
-    fn as_cache_key_ref(&self) -> CacheKeyRef {
+    fn as_cache_key_ref(&self) -> CacheKeyRef<'_> {
         CacheKeyRef {
             text: &self.text,
             font_size: self.font_size,
@@ -645,7 +645,7 @@ impl<'a> Borrow<dyn AsCacheKeyRef + 'a> for Arc<CacheKey> {
 }
 
 impl AsCacheKeyRef for CacheKeyRef<'_> {
-    fn as_cache_key_ref(&self) -> CacheKeyRef {
+    fn as_cache_key_ref(&self) -> CacheKeyRef<'_> {
         *self
     }
 }

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

@@ -133,21 +133,18 @@ impl LineWrapper {
         &mut self,
         line: SharedString,
         truncate_width: Pixels,
-        ellipsis: Option<&str>,
+        truncation_suffix: &str,
         runs: &mut Vec<TextRun>,
     ) -> SharedString {
         let mut width = px(0.);
-        let mut ellipsis_width = px(0.);
-        if let Some(ellipsis) = ellipsis {
-            for c in ellipsis.chars() {
-                ellipsis_width += self.width_for_char(c);
-            }
-        }
-
+        let mut suffix_width = truncation_suffix
+            .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 + ellipsis_width < truncate_width {
+            if width + suffix_width < truncate_width {
                 truncate_ix = ix;
             }
 
@@ -155,9 +152,9 @@ impl LineWrapper {
             width += char_width;
 
             if width.floor() > truncate_width {
-                let ellipsis = ellipsis.unwrap_or("");
-                let result = SharedString::from(format!("{}{}", &line[..truncate_ix], ellipsis));
-                update_runs_after_truncation(&result, ellipsis, runs);
+                let result =
+                    SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix));
+                update_runs_after_truncation(&result, truncation_suffix, runs);
 
                 return result;
             }
@@ -500,7 +497,7 @@ mod tests {
             wrapper: &mut LineWrapper,
             text: &'static str,
             result: &'static str,
-            ellipsis: Option<&str>,
+            ellipsis: &str,
         ) {
             let dummy_run_lens = vec![text.len()];
             let mut dummy_runs = generate_test_runs(&dummy_run_lens);
@@ -515,19 +512,19 @@ mod tests {
             &mut wrapper,
             "aa bbb cccc ddddd eeee ffff gggg",
             "aa bbb cccc ddddd eeee",
-            None,
+            "",
         );
         perform_test(
             &mut wrapper,
             "aa bbb cccc ddddd eeee ffff gggg",
             "aa bbb cccc ddddd eee…",
-            Some("…"),
+            "…",
         );
         perform_test(
             &mut wrapper,
             "aa bbb cccc ddddd eeee ffff gggg",
             "aa bbb cccc dddd......",
-            Some("......"),
+            "......",
         );
     }
 
@@ -545,7 +542,7 @@ mod tests {
         ) {
             let mut dummy_runs = generate_test_runs(run_lens);
             assert_eq!(
-                wrapper.truncate_line(text.into(), line_width, Some("…"), &mut dummy_runs),
+                wrapper.truncate_line(text.into(), line_width, "…", &mut dummy_runs),
                 result
             );
             for (run, result_len) in dummy_runs.iter().zip(result_run_len) {

crates/gpui/src/util.rs 🔗

@@ -1,3 +1,5 @@
+use std::sync::atomic::AtomicUsize;
+use std::sync::atomic::Ordering::SeqCst;
 #[cfg(any(test, feature = "test-support"))]
 use std::time::Duration;
 
@@ -27,6 +29,19 @@ pub trait FluentBuilder {
         self.map(|this| if condition { then(this) } else { this })
     }
 
+    /// Conditionally modify self with the given closure.
+    fn when_else(
+        self,
+        condition: bool,
+        then: impl FnOnce(Self) -> Self,
+        else_fn: impl FnOnce(Self) -> Self,
+    ) -> Self
+    where
+        Self: Sized,
+    {
+        self.map(|this| if condition { then(this) } else { else_fn(this) })
+    }
+
     /// Conditionally unwrap and modify self with the given closure, if the given option is Some.
     fn when_some<T>(self, option: Option<T>, then: impl FnOnce(Self, T) -> Self) -> Self
     where
@@ -68,30 +83,17 @@ where
     timer.race(future).await
 }
 
-#[cfg(any(test, feature = "test-support"))]
-pub struct CwdBacktrace<'a>(pub &'a backtrace::Backtrace);
-
-#[cfg(any(test, feature = "test-support"))]
-impl std::fmt::Debug for CwdBacktrace<'_> {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        use backtrace::{BacktraceFmt, BytesOrWideString};
-
-        let cwd = std::env::current_dir().unwrap();
-        let cwd = cwd.parent().unwrap();
-        let mut print_path = |fmt: &mut std::fmt::Formatter<'_>, path: BytesOrWideString<'_>| {
-            std::fmt::Display::fmt(&path, fmt)
-        };
-        let mut fmt = BacktraceFmt::new(f, backtrace::PrintFmt::Full, &mut print_path);
-        for frame in self.0.frames() {
-            let mut formatted_frame = fmt.frame();
-            if frame
-                .symbols()
-                .iter()
-                .any(|s| s.filename().map_or(false, |f| f.starts_with(cwd)))
-            {
-                formatted_frame.backtrace_frame(frame)?;
-            }
+/// Increment the given atomic counter if it is not zero.
+/// Return the new value of the counter.
+pub(crate) fn atomic_incr_if_not_zero(counter: &AtomicUsize) -> usize {
+    let mut loaded = counter.load(SeqCst);
+    loop {
+        if loaded == 0 {
+            return 0;
+        }
+        match counter.compare_exchange_weak(loaded, loaded + 1, SeqCst, SeqCst) {
+            Ok(x) => return x + 1,
+            Err(actual) => loaded = actual,
         }
-        fmt.finish()
     }
 }

crates/gpui/src/view.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{
     AnyElement, AnyEntity, AnyWeakEntity, App, Bounds, ContentMask, Context, Element, ElementId,
-    Entity, EntityId, GlobalElementId, IntoElement, LayoutId, PaintIndex, Pixels,
-    PrepaintStateIndex, Render, Style, StyleRefinement, TextStyle, WeakEntity,
+    Entity, EntityId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, PaintIndex,
+    Pixels, PrepaintStateIndex, Render, Style, StyleRefinement, TextStyle, WeakEntity,
 };
 use crate::{Empty, Window};
 use anyhow::Result;
@@ -33,9 +33,14 @@ impl<V: Render> Element for Entity<V> {
         Some(ElementId::View(self.entity_id()))
     }
 
+    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
@@ -49,6 +54,7 @@ impl<V: Render> Element for Entity<V> {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _: Bounds<Pixels>,
         element: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -61,6 +67,7 @@ impl<V: Render> Element for Entity<V> {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _: Bounds<Pixels>,
         element: &mut Self::RequestLayoutState,
         _: &mut Self::PrepaintState,
@@ -146,22 +153,32 @@ impl Element for AnyView {
         Some(ElementId::View(self.entity_id()))
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
         window.with_rendered_view(self.entity_id(), |window| {
-            if let Some(style) = self.cached_style.as_ref() {
-                let mut root_style = Style::default();
-                root_style.refine(style);
-                let layout_id = window.request_layout(root_style, None, cx);
-                (layout_id, None)
-            } else {
-                let mut element = (self.render)(self, window, cx);
-                let layout_id = element.request_layout(window, cx);
-                (layout_id, Some(element))
+            // Disable caching when inspecting so that mouse_hit_test has all hitboxes.
+            let caching_disabled = window.is_inspector_picking(cx);
+            match self.cached_style.as_ref() {
+                Some(style) if !caching_disabled => {
+                    let mut root_style = Style::default();
+                    root_style.refine(style);
+                    let layout_id = window.request_layout(root_style, None, cx);
+                    (layout_id, None)
+                }
+                _ => {
+                    let mut element = (self.render)(self, window, cx);
+                    let layout_id = element.request_layout(window, cx);
+                    (layout_id, Some(element))
+                }
             }
         })
     }
@@ -169,6 +186,7 @@ impl Element for AnyView {
     fn prepaint(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         bounds: Bounds<Pixels>,
         element: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -176,70 +194,69 @@ impl Element for AnyView {
     ) -> Option<AnyElement> {
         window.set_view_id(self.entity_id());
         window.with_rendered_view(self.entity_id(), |window| {
-            if self.cached_style.is_some() {
-                window.with_element_state::<AnyViewState, _>(
-                    global_id.unwrap(),
-                    |element_state, window| {
-                        let content_mask = window.content_mask();
-                        let text_style = window.text_style();
-
-                        if let Some(mut element_state) = element_state {
-                            if element_state.cache_key.bounds == bounds
-                                && element_state.cache_key.content_mask == content_mask
-                                && element_state.cache_key.text_style == text_style
-                                && !window.dirty_views.contains(&self.entity_id())
-                                && !window.refreshing
-                            {
-                                let prepaint_start = window.prepaint_index();
-                                window.reuse_prepaint(element_state.prepaint_range.clone());
-                                cx.entities
-                                    .extend_accessed(&element_state.accessed_entities);
-                                let prepaint_end = window.prepaint_index();
-                                element_state.prepaint_range = prepaint_start..prepaint_end;
-
-                                return (None, element_state);
-                            }
-                        }
-
-                        let refreshing = mem::replace(&mut window.refreshing, true);
-                        let prepaint_start = window.prepaint_index();
-                        let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| {
-                            let mut element = (self.render)(self, window, cx);
-                            element.layout_as_root(bounds.size.into(), window, cx);
-                            element.prepaint_at(bounds.origin, window, cx);
-                            element
-                        });
-
-                        let prepaint_end = window.prepaint_index();
-                        window.refreshing = refreshing;
-
-                        (
-                            Some(element),
-                            AnyViewState {
-                                accessed_entities,
-                                prepaint_range: prepaint_start..prepaint_end,
-                                paint_range: PaintIndex::default()..PaintIndex::default(),
-                                cache_key: ViewCacheKey {
-                                    bounds,
-                                    content_mask,
-                                    text_style,
-                                },
-                            },
-                        )
-                    },
-                )
-            } else {
-                let mut element = element.take().unwrap();
+            if let Some(mut element) = element.take() {
                 element.prepaint(window, cx);
-
-                Some(element)
+                return Some(element);
             }
+
+            window.with_element_state::<AnyViewState, _>(
+                global_id.unwrap(),
+                |element_state, window| {
+                    let content_mask = window.content_mask();
+                    let text_style = window.text_style();
+
+                    if let Some(mut element_state) = element_state {
+                        if element_state.cache_key.bounds == bounds
+                            && element_state.cache_key.content_mask == content_mask
+                            && element_state.cache_key.text_style == text_style
+                            && !window.dirty_views.contains(&self.entity_id())
+                            && !window.refreshing
+                        {
+                            let prepaint_start = window.prepaint_index();
+                            window.reuse_prepaint(element_state.prepaint_range.clone());
+                            cx.entities
+                                .extend_accessed(&element_state.accessed_entities);
+                            let prepaint_end = window.prepaint_index();
+                            element_state.prepaint_range = prepaint_start..prepaint_end;
+
+                            return (None, element_state);
+                        }
+                    }
+
+                    let refreshing = mem::replace(&mut window.refreshing, true);
+                    let prepaint_start = window.prepaint_index();
+                    let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| {
+                        let mut element = (self.render)(self, window, cx);
+                        element.layout_as_root(bounds.size.into(), window, cx);
+                        element.prepaint_at(bounds.origin, window, cx);
+                        element
+                    });
+
+                    let prepaint_end = window.prepaint_index();
+                    window.refreshing = refreshing;
+
+                    (
+                        Some(element),
+                        AnyViewState {
+                            accessed_entities,
+                            prepaint_range: prepaint_start..prepaint_end,
+                            paint_range: PaintIndex::default()..PaintIndex::default(),
+                            cache_key: ViewCacheKey {
+                                bounds,
+                                content_mask,
+                                text_style,
+                            },
+                        },
+                    )
+                },
+            )
         })
     }
 
     fn paint(
         &mut self,
         global_id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
         _bounds: Bounds<Pixels>,
         _: &mut Self::RequestLayoutState,
         element: &mut Self::PrepaintState,
@@ -247,7 +264,8 @@ impl Element for AnyView {
         cx: &mut App,
     ) {
         window.with_rendered_view(self.entity_id(), |window| {
-            if self.cached_style.is_some() {
+            let caching_disabled = window.is_inspector_picking(cx);
+            if self.cached_style.is_some() && !caching_disabled {
                 window.with_element_state::<AnyViewState, _>(
                     global_id.unwrap(),
                     |element_state, window| {

crates/gpui/src/window.rs 🔗

@@ -1,19 +1,21 @@
+#[cfg(any(feature = "inspector", debug_assertions))]
+use crate::Inspector;
 use crate::{
     Action, AnyDrag, AnyElement, AnyImageCache, AnyTooltip, AnyView, App, AppContext, Arena, Asset,
-    AsyncWindowContext, AvailableSpace, Background, BorderStyle, Bounds, BoxShadow, Context,
-    Corners, CursorStyle, Decorations, DevicePixels, DispatchActionListener, DispatchNodeId,
-    DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter, FileDropEvent, FontId,
-    Global, GlobalElementId, GlyphId, GpuSpecs, Hsla, InputHandler, IsZero, KeyBinding, KeyContext,
-    KeyDownEvent, KeyEvent, Keystroke, KeystrokeEvent, LayoutId, LineLayoutIndex, Modifiers,
-    ModifiersChangedEvent, MonochromeSprite, MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent,
-    Path, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler,
-    PlatformWindow, Point, PolychromeSprite, PromptLevel, Quad, Render, RenderGlyphParams,
-    RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR,
-    SUBPIXEL_VARIANTS, ScaledPixels, Scene, Shadow, SharedString, Size, StrikethroughStyle, Style,
-    SubscriberSet, Subscription, TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement,
-    TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance,
-    WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem,
-    point, prelude::*, px, size, transparent_black,
+    AsyncWindowContext, AvailableSpace, Background, BorderStyle, Bounds, BoxShadow, Capslock,
+    Context, Corners, CursorStyle, Decorations, DevicePixels, DispatchActionListener,
+    DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter,
+    FileDropEvent, FontId, Global, GlobalElementId, GlyphId, GpuSpecs, Hsla, InputHandler, IsZero,
+    KeyBinding, KeyContext, KeyDownEvent, KeyEvent, Keystroke, KeystrokeEvent, LayoutId,
+    LineLayoutIndex, Modifiers, ModifiersChangedEvent, MonochromeSprite, MouseButton, MouseEvent,
+    MouseMoveEvent, MouseUpEvent, Path, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput,
+    PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, PromptButton, PromptLevel, Quad,
+    Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge,
+    SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS, ScaledPixels, Scene, Shadow, SharedString, Size,
+    StrikethroughStyle, Style, SubscriberSet, Subscription, TaffyLayoutEngine, Task, TextStyle,
+    TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle, WindowAppearance,
+    WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, WindowOptions,
+    WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, transparent_black,
 };
 use anyhow::{Context as _, Result, anyhow};
 use collections::{FxHashMap, FxHashSet};
@@ -22,8 +24,10 @@ use core_video::pixel_buffer::CVPixelBuffer;
 use derive_more::{Deref, DerefMut};
 use futures::FutureExt;
 use futures::channel::oneshot;
+use itertools::FoldWhile::{Continue, Done};
+use itertools::Itertools;
 use parking_lot::RwLock;
-use raw_window_handle::{HandleError, HasWindowHandle};
+use raw_window_handle::{HandleError, HasDisplayHandle, HasWindowHandle};
 use refineable::Refineable;
 use slotmap::SlotMap;
 use smallvec::SmallVec;
@@ -50,6 +54,7 @@ use uuid::Uuid;
 
 mod prompts;
 
+use crate::util::atomic_incr_if_not_zero;
 pub use prompts::*;
 
 pub(crate) const DEFAULT_WINDOW_SIZE: Size<Pixels> = size(px(1024.), px(700.));
@@ -201,8 +206,20 @@ slotmap::new_key_type! {
 }
 
 thread_local! {
-    /// 8MB wasn't quite enough...
-    pub(crate) static ELEMENT_ARENA: RefCell<Arena> = RefCell::new(Arena::new(32 * 1024 * 1024));
+    pub(crate) static ELEMENT_ARENA: RefCell<Arena> = RefCell::new(Arena::new(1024 * 1024));
+}
+
+/// Returned when the element arena has been used and so must be cleared before the next draw.
+#[must_use]
+pub struct ArenaClearNeeded;
+
+impl ArenaClearNeeded {
+    /// Clear the element arena.
+    pub fn clear(self) {
+        ELEMENT_ARENA.with_borrow_mut(|element_arena| {
+            element_arena.clear();
+        });
+    }
 }
 
 pub(crate) type FocusMap = RwLock<SlotMap<FocusId, AtomicUsize>>;
@@ -261,15 +278,13 @@ impl FocusHandle {
     pub(crate) fn for_id(id: FocusId, handles: &Arc<FocusMap>) -> Option<Self> {
         let lock = handles.read();
         let ref_count = lock.get(id)?;
-        if ref_count.load(SeqCst) == 0 {
-            None
-        } else {
-            ref_count.fetch_add(1, SeqCst);
-            Some(Self {
-                id,
-                handles: handles.clone(),
-            })
+        if atomic_incr_if_not_zero(ref_count) == 0 {
+            return None;
         }
+        Some(Self {
+            id,
+            handles: handles.clone(),
+        })
     }
 
     /// Converts this focus handle into a weak variant, which does not prevent it from being released.
@@ -407,18 +422,59 @@ pub(crate) type AnyMouseListener =
 
 #[derive(Clone)]
 pub(crate) struct CursorStyleRequest {
-    pub(crate) hitbox_id: Option<HitboxId>, // None represents whole window
+    pub(crate) hitbox_id: Option<HitboxId>,
     pub(crate) style: CursorStyle,
 }
 
-/// An identifier for a [Hitbox].
-#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
-pub struct HitboxId(usize);
+#[derive(Default, Eq, PartialEq)]
+pub(crate) struct HitTest {
+    pub(crate) ids: SmallVec<[HitboxId; 8]>,
+    pub(crate) hover_hitbox_count: usize,
+}
+
+/// A type of window control area that corresponds to the platform window.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum WindowControlArea {
+    /// An area that allows dragging of the platform window.
+    Drag,
+    /// An area that allows closing of the platform window.
+    Close,
+    /// An area that allows maximizing of the platform window.
+    Max,
+    /// An area that allows minimizing of the platform window.
+    Min,
+}
+
+/// An identifier for a [Hitbox] which also includes [HitboxBehavior].
+#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
+pub struct HitboxId(u64);
 
 impl HitboxId {
-    /// Checks if the hitbox with this id is currently hovered.
-    pub fn is_hovered(&self, window: &Window) -> bool {
-        window.mouse_hit_test.0.contains(self)
+    /// Checks if the hitbox with this ID is currently hovered. Except when handling
+    /// `ScrollWheelEvent`, this is typically what you want when determining whether to handle mouse
+    /// events or paint hover styles.
+    ///
+    /// See [`Hitbox::is_hovered`] for details.
+    pub fn is_hovered(self, window: &Window) -> bool {
+        let hit_test = &window.mouse_hit_test;
+        for id in hit_test.ids.iter().take(hit_test.hover_hitbox_count) {
+            if self == *id {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /// Checks if the hitbox with this ID contains the mouse and should handle scroll events.
+    /// Typically this should only be used when handling `ScrollWheelEvent`, and otherwise
+    /// `is_hovered` should be used. See the documentation of `Hitbox::is_hovered` for details about
+    /// this distinction.
+    pub fn should_handle_scroll(self, window: &Window) -> bool {
+        window.mouse_hit_test.ids.contains(&self)
+    }
+
+    fn next(mut self) -> HitboxId {
+        HitboxId(self.0.wrapping_add(1))
     }
 }
 
@@ -433,19 +489,98 @@ pub struct Hitbox {
     pub bounds: Bounds<Pixels>,
     /// The content mask when the hitbox was inserted.
     pub content_mask: ContentMask<Pixels>,
-    /// Whether the hitbox occludes other hitboxes inserted prior.
-    pub opaque: bool,
+    /// Flags that specify hitbox behavior.
+    pub behavior: HitboxBehavior,
 }
 
 impl Hitbox {
-    /// Checks if the hitbox is currently hovered.
+    /// Checks if the hitbox is currently hovered. Except when handling `ScrollWheelEvent`, this is
+    /// typically what you want when determining whether to handle mouse events or paint hover
+    /// styles.
+    ///
+    /// This can return `false` even when the hitbox contains the mouse, if a hitbox in front of
+    /// this sets `HitboxBehavior::BlockMouse` (`InteractiveElement::occlude`) or
+    /// `HitboxBehavior::BlockMouseExceptScroll` (`InteractiveElement::block_mouse_except_scroll`).
+    ///
+    /// Handling of `ScrollWheelEvent` should typically use `should_handle_scroll` instead.
+    /// Concretely, this is due to use-cases like overlays that cause the elements under to be
+    /// non-interactive while still allowing scrolling. More abstractly, this is because
+    /// `is_hovered` is about element interactions directly under the mouse - mouse moves, clicks,
+    /// hover styling, etc. In contrast, scrolling is about finding the current outer scrollable
+    /// container.
     pub fn is_hovered(&self, window: &Window) -> bool {
         self.id.is_hovered(window)
     }
+
+    /// Checks if the hitbox contains the mouse and should handle scroll events. Typically this
+    /// should only be used when handling `ScrollWheelEvent`, and otherwise `is_hovered` should be
+    /// used. See the documentation of `Hitbox::is_hovered` for details about this distinction.
+    ///
+    /// This can return `false` even when the hitbox contains the mouse, if a hitbox in front of
+    /// this sets `HitboxBehavior::BlockMouse` (`InteractiveElement::occlude`).
+    pub fn should_handle_scroll(&self, window: &Window) -> bool {
+        self.id.should_handle_scroll(window)
+    }
 }
 
-#[derive(Default, Eq, PartialEq)]
-pub(crate) struct HitTest(SmallVec<[HitboxId; 8]>);
+/// How the hitbox affects mouse behavior.
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
+pub enum HitboxBehavior {
+    /// Normal hitbox mouse behavior, doesn't affect mouse handling for other hitboxes.
+    #[default]
+    Normal,
+
+    /// All hitboxes behind this hitbox will be ignored and so will have `hitbox.is_hovered() ==
+    /// false` and `hitbox.should_handle_scroll() == false`. Typically for elements this causes
+    /// skipping of all mouse events, hover styles, and tooltips. This flag is set by
+    /// [`InteractiveElement::occlude`].
+    ///
+    /// For mouse handlers that check those hitboxes, this behaves the same as registering a
+    /// bubble-phase handler for every mouse event type:
+    ///
+    /// ```
+    /// window.on_mouse_event(move |_: &EveryMouseEventTypeHere, phase, window, cx| {
+    ///     if phase == DispatchPhase::Capture && hitbox.is_hovered(window) {
+    ///         cx.stop_propagation();
+    ///     }
+    /// }
+    /// ```
+    ///
+    /// This has effects beyond event handling - any use of hitbox checking, such as hover
+    /// styles and tooltops. These other behaviors are the main point of this mechanism. An
+    /// alternative might be to not affect mouse event handling - but this would allow
+    /// inconsistent UI where clicks and moves interact with elements that are not considered to
+    /// be hovered.
+    BlockMouse,
+
+    /// All hitboxes behind this hitbox will have `hitbox.is_hovered() == false`, even when
+    /// `hitbox.should_handle_scroll() == true`. Typically for elements this causes all mouse
+    /// interaction except scroll events to be ignored - see the documentation of
+    /// [`Hitbox::is_hovered`] for details. This flag is set by
+    /// [`InteractiveElement::block_mouse_except_scroll`].
+    ///
+    /// For mouse handlers that check those hitboxes, this behaves the same as registering a
+    /// bubble-phase handler for every mouse event type **except** `ScrollWheelEvent`:
+    ///
+    /// ```
+    /// window.on_mouse_event(move |_: &EveryMouseEventTypeExceptScroll, phase, window, _cx| {
+    ///     if phase == DispatchPhase::Bubble && hitbox.should_handle_scroll(window) {
+    ///         cx.stop_propagation();
+    ///     }
+    /// }
+    /// ```
+    ///
+    /// See the documentation of [`Hitbox::is_hovered`] for details of why `ScrollWheelEvent` is
+    /// handled differently than other mouse events. If also blocking these scroll events is
+    /// desired, then a `cx.stop_propagation()` handler like the one above can be used.
+    ///
+    /// This has effects beyond event handling - this affects any use of `is_hovered`, such as
+    /// hover styles and tooltops. These other behaviors are the main point of this mechanism.
+    /// An alternative might be to not affect mouse event handling - but this would allow
+    /// inconsistent UI where clicks and moves interact with elements that are not considered to
+    /// be hovered.
+    BlockMouseExceptScroll,
+}
 
 /// An identifier for a tooltip.
 #[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
@@ -496,12 +631,17 @@ pub(crate) struct Frame {
     pub(crate) dispatch_tree: DispatchTree,
     pub(crate) scene: Scene,
     pub(crate) hitboxes: Vec<Hitbox>,
+    pub(crate) window_control_hitboxes: Vec<(WindowControlArea, Hitbox)>,
     pub(crate) deferred_draws: Vec<DeferredDraw>,
     pub(crate) input_handlers: Vec<Option<PlatformInputHandler>>,
     pub(crate) tooltip_requests: Vec<Option<TooltipRequest>>,
     pub(crate) cursor_styles: Vec<CursorStyleRequest>,
     #[cfg(any(test, feature = "test-support"))]
     pub(crate) debug_bounds: FxHashMap<String, Bounds<Pixels>>,
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub(crate) next_inspector_instance_ids: FxHashMap<Rc<crate::InspectorElementPath>, usize>,
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub(crate) inspector_hitboxes: FxHashMap<HitboxId, crate::InspectorElementId>,
 }
 
 #[derive(Clone, Default)]
@@ -535,6 +675,7 @@ impl Frame {
             dispatch_tree,
             scene: Scene::default(),
             hitboxes: Vec::new(),
+            window_control_hitboxes: Vec::new(),
             deferred_draws: Vec::new(),
             input_handlers: Vec::new(),
             tooltip_requests: Vec::new(),
@@ -542,6 +683,12 @@ impl Frame {
 
             #[cfg(any(test, feature = "test-support"))]
             debug_bounds: FxHashMap::default(),
+
+            #[cfg(any(feature = "inspector", debug_assertions))]
+            next_inspector_instance_ids: FxHashMap::default(),
+
+            #[cfg(any(feature = "inspector", debug_assertions))]
+            inspector_hitboxes: FxHashMap::default(),
         }
     }
 
@@ -555,21 +702,51 @@ impl Frame {
         self.tooltip_requests.clear();
         self.cursor_styles.clear();
         self.hitboxes.clear();
+        self.window_control_hitboxes.clear();
         self.deferred_draws.clear();
         self.focus = None;
+
+        #[cfg(any(feature = "inspector", debug_assertions))]
+        {
+            self.next_inspector_instance_ids.clear();
+            self.inspector_hitboxes.clear();
+        }
+    }
+
+    pub(crate) fn cursor_style(&self, window: &Window) -> Option<CursorStyle> {
+        self.cursor_styles
+            .iter()
+            .rev()
+            .fold_while(None, |style, request| match request.hitbox_id {
+                None => Done(Some(request.style)),
+                Some(hitbox_id) => Continue(
+                    style.or_else(|| hitbox_id.is_hovered(window).then_some(request.style)),
+                ),
+            })
+            .into_inner()
     }
 
     pub(crate) fn hit_test(&self, position: Point<Pixels>) -> HitTest {
+        let mut set_hover_hitbox_count = false;
         let mut hit_test = HitTest::default();
         for hitbox in self.hitboxes.iter().rev() {
             let bounds = hitbox.bounds.intersect(&hitbox.content_mask.bounds);
             if bounds.contains(&position) {
-                hit_test.0.push(hitbox.id);
-                if hitbox.opaque {
+                hit_test.ids.push(hitbox.id);
+                if !set_hover_hitbox_count
+                    && hitbox.behavior == HitboxBehavior::BlockMouseExceptScroll
+                {
+                    hit_test.hover_hitbox_count = hit_test.ids.len();
+                    set_hover_hitbox_count = true;
+                }
+                if hitbox.behavior == HitboxBehavior::BlockMouse {
                     break;
                 }
             }
         }
+        if !set_hover_hitbox_count {
+            hit_test.hover_hitbox_count = hit_test.ids.len();
+        }
         hit_test
     }
 
@@ -620,7 +797,7 @@ pub struct Window {
     pub(crate) image_cache_stack: Vec<AnyImageCache>,
     pub(crate) rendered_frame: Frame,
     pub(crate) next_frame: Frame,
-    pub(crate) next_hitbox_id: HitboxId,
+    next_hitbox_id: HitboxId,
     pub(crate) next_tooltip_id: TooltipId,
     pub(crate) tooltip_bounds: Option<TooltipBounds>,
     next_frame_callbacks: Rc<RefCell<Vec<FrameCallback>>>,
@@ -631,6 +808,7 @@ pub struct Window {
     mouse_position: Point<Pixels>,
     mouse_hit_test: HitTest,
     modifiers: Modifiers,
+    capslock: Capslock,
     scale_factor: f32,
     pub(crate) bounds_observers: SubscriberSet<(), AnyObserver>,
     appearance: WindowAppearance,
@@ -648,6 +826,8 @@ pub struct Window {
     pub(crate) pending_input_observers: SubscriberSet<(), AnyObserver>,
     prompt: Option<RenderablePromptHandle>,
     pub(crate) client_inset: Option<Pixels>,
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    inspector: Option<Entity<Inspector>>,
 }
 
 #[derive(Clone, Debug, Default)]
@@ -740,6 +920,7 @@ impl Window {
         let sprite_atlas = platform_window.sprite_atlas();
         let mouse_position = platform_window.mouse_position();
         let modifiers = platform_window.modifiers();
+        let capslock = platform_window.capslock();
         let content_size = platform_window.content_size();
         let scale_factor = platform_window.scale_factor();
         let appearance = platform_window.appearance();
@@ -799,8 +980,10 @@ impl Window {
                     measure("frame duration", || {
                         handle
                             .update(&mut cx, |_, window, cx| {
-                                window.draw(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();
                     })
@@ -848,6 +1031,7 @@ impl Window {
                     .update(&mut cx, |_, window, cx| {
                         window.active.set(active);
                         window.modifiers = window.platform_window.modifiers();
+                        window.capslock = window.platform_window.capslock();
                         window
                             .activation_observers
                             .clone()
@@ -877,6 +1061,22 @@ impl Window {
                     .unwrap_or(DispatchEventResult::default())
             })
         });
+        platform_window.on_hit_test_window_control({
+            let mut cx = cx.to_async();
+            Box::new(move || {
+                handle
+                    .update(&mut cx, |_, window, _cx| {
+                        for (area, hitbox) in &window.rendered_frame.window_control_hitboxes {
+                            if window.mouse_hit_test.ids.contains(&hitbox.id) {
+                                return Some(*area);
+                            }
+                        }
+                        None
+                    })
+                    .log_err()
+                    .unwrap_or(None)
+            })
+        });
 
         if let Some(app_id) = app_id {
             platform_window.set_app_id(&app_id);
@@ -907,7 +1107,7 @@ impl Window {
             rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())),
             next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())),
             next_frame_callbacks,
-            next_hitbox_id: HitboxId::default(),
+            next_hitbox_id: HitboxId(0),
             next_tooltip_id: TooltipId::default(),
             tooltip_bounds: None,
             dirty_views: FxHashSet::default(),
@@ -917,6 +1117,7 @@ impl Window {
             mouse_position,
             mouse_hit_test: HitTest::default(),
             modifiers,
+            capslock,
             scale_factor,
             bounds_observers: SubscriberSet::new(),
             appearance,
@@ -935,6 +1136,8 @@ impl Window {
             prompt: None,
             client_inset: None,
             image_cache_stack: Vec::new(),
+            #[cfg(any(feature = "inspector", debug_assertions))]
+            inspector: None,
         })
     }
 
@@ -957,7 +1160,7 @@ pub(crate) struct DispatchEventResult {
 /// to leave room to support more complex shapes in the future.
 #[derive(Clone, Debug, Default, PartialEq, Eq)]
 #[repr(C)]
-pub struct ContentMask<P: Clone + Default + Debug> {
+pub struct ContentMask<P: Clone + Debug + Default + PartialEq> {
     /// The bounds
     pub bounds: Bounds<P>,
 }
@@ -1129,21 +1332,13 @@ impl Window {
 
     /// Dispatch the given action on the currently focused element.
     pub fn dispatch_action(&mut self, action: Box<dyn Action>, cx: &mut App) {
-        let focus_handle = self.focused(cx);
+        let focus_id = self.focused(cx).map(|handle| handle.id);
 
         let window = self.handle;
         cx.defer(move |cx| {
             window
                 .update(cx, |_, window, cx| {
-                    let node_id = focus_handle
-                        .and_then(|handle| {
-                            window
-                                .rendered_frame
-                                .dispatch_tree
-                                .focusable_node_id(handle.id)
-                        })
-                        .unwrap_or_else(|| window.rendered_frame.dispatch_tree.root_node_id());
-
+                    let node_id = window.focus_node_id_in_rendered_frame(focus_id);
                     window.dispatch_action_on_node(node_id, action.as_ref(), cx);
                 })
                 .log_err();
@@ -1520,17 +1715,11 @@ 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 {
-        let target = self
-            .focused(cx)
-            .and_then(|focused_handle| {
-                self.rendered_frame
-                    .dispatch_tree
-                    .focusable_node_id(focused_handle.id)
-            })
-            .unwrap_or_else(|| self.rendered_frame.dispatch_tree.root_node_id());
+        let node_id =
+            self.focus_node_id_in_rendered_frame(self.focused(cx).map(|handle| handle.id));
         self.rendered_frame
             .dispatch_tree
-            .is_action_available(action, target)
+            .is_action_available(action, node_id)
     }
 
     /// The position of the mouse relative to the window.
@@ -1543,6 +1732,11 @@ impl Window {
         self.modifiers
     }
 
+    /// The current state of the keyboard's capslock
+    pub fn capslock(&self) -> Capslock {
+        self.capslock
+    }
+
     fn complete_frame(&self) {
         self.platform_window.completed_frame();
     }
@@ -1550,7 +1744,7 @@ impl Window {
     /// Produces a new frame and assigns it to `rendered_frame`. To actually show
     /// the contents of the new [Scene], use [present].
     #[profiling::function]
-    pub fn draw(&mut self, cx: &mut App) {
+    pub fn draw(&mut self, cx: &mut App) -> ArenaClearNeeded {
         self.invalidate_entities();
         cx.entities.clear_accessed();
         debug_assert!(self.rendered_entity_stack.is_empty());
@@ -1574,13 +1768,6 @@ impl Window {
         self.layout_engine.as_mut().unwrap().clear();
         self.text_system().finish_frame();
         self.next_frame.finish(&mut self.rendered_frame);
-        ELEMENT_ARENA.with_borrow_mut(|element_arena| {
-            let percentage = (element_arena.len() as f32 / element_arena.capacity() as f32) * 100.;
-            if percentage >= 80. {
-                log::warn!("elevated element arena occupation: {}.", percentage);
-            }
-            element_arena.clear();
-        });
 
         self.invalidator.set_phase(DrawPhase::Focus);
         let previous_focus_path = self.rendered_frame.focus_path();
@@ -1622,6 +1809,8 @@ impl Window {
         self.refreshing = false;
         self.invalidator.set_phase(DrawPhase::None);
         self.needs_present.set(true);
+
+        ArenaClearNeeded
     }
 
     fn record_entities_accessed(&mut self, cx: &mut App) {
@@ -1658,9 +1847,30 @@ impl Window {
         self.invalidator.set_phase(DrawPhase::Prepaint);
         self.tooltip_bounds.take();
 
+        let _inspector_width: Pixels = rems(30.0).to_pixels(self.rem_size());
+        let root_size = {
+            #[cfg(any(feature = "inspector", debug_assertions))]
+            {
+                if self.inspector.is_some() {
+                    let mut size = self.viewport_size;
+                    size.width = (size.width - _inspector_width).max(px(0.0));
+                    size
+                } else {
+                    self.viewport_size
+                }
+            }
+            #[cfg(not(any(feature = "inspector", debug_assertions)))]
+            {
+                self.viewport_size
+            }
+        };
+
         // Layout all root elements.
         let mut root_element = self.root.as_ref().unwrap().clone().into_any();
-        root_element.prepaint_as_root(Point::default(), self.viewport_size.into(), self, cx);
+        root_element.prepaint_as_root(Point::default(), root_size.into(), self, cx);
+
+        #[cfg(any(feature = "inspector", debug_assertions))]
+        let inspector_element = self.prepaint_inspector(_inspector_width, cx);
 
         let mut sorted_deferred_draws =
             (0..self.next_frame.deferred_draws.len()).collect::<SmallVec<[_; 8]>>();
@@ -1672,7 +1882,7 @@ impl Window {
         let mut tooltip_element = None;
         if let Some(prompt) = self.prompt.take() {
             let mut element = prompt.view.any_view().into_any();
-            element.prepaint_as_root(Point::default(), self.viewport_size.into(), self, cx);
+            element.prepaint_as_root(Point::default(), root_size.into(), self, cx);
             prompt_element = Some(element);
             self.prompt = Some(prompt);
         } else if let Some(active_drag) = cx.active_drag.take() {
@@ -1691,6 +1901,9 @@ impl Window {
         self.invalidator.set_phase(DrawPhase::Paint);
         root_element.paint(self, cx);
 
+        #[cfg(any(feature = "inspector", debug_assertions))]
+        self.paint_inspector(inspector_element, cx);
+
         self.paint_deferred_draws(&sorted_deferred_draws, cx);
 
         if let Some(mut prompt_element) = prompt_element {
@@ -1700,6 +1913,9 @@ impl Window {
         } else if let Some(mut tooltip_element) = tooltip_element {
             tooltip_element.paint(self, cx);
         }
+
+        #[cfg(any(feature = "inspector", debug_assertions))]
+        self.paint_inspector_hitbox(cx);
     }
 
     fn prepaint_tooltip(&mut self, cx: &mut App) -> Option<AnyElement> {
@@ -1960,14 +2176,26 @@ impl Window {
 
     /// Updates the cursor style at the platform level. This method should only be called
     /// during the prepaint phase of element drawing.
-    pub fn set_cursor_style(&mut self, style: CursorStyle, hitbox: Option<&Hitbox>) {
+    pub fn set_cursor_style(&mut self, style: CursorStyle, hitbox: &Hitbox) {
         self.invalidator.debug_assert_paint();
         self.next_frame.cursor_styles.push(CursorStyleRequest {
-            hitbox_id: hitbox.map(|hitbox| hitbox.id),
+            hitbox_id: Some(hitbox.id),
             style,
         });
     }
 
+    /// Updates the cursor style for the entire window at the platform level. A cursor
+    /// style using this method will have precedence over any cursor style set using
+    /// `set_cursor_style`. This method should only be called during the prepaint
+    /// phase of element drawing.
+    pub fn set_window_cursor_style(&mut self, style: CursorStyle) {
+        self.invalidator.debug_assert_paint();
+        self.next_frame.cursor_styles.push(CursorStyleRequest {
+            hitbox_id: None,
+            style,
+        })
+    }
+
     /// Sets a tooltip to be rendered for the upcoming frame. This method should only be called
     /// during the paint phase of element drawing.
     pub fn set_tooltip(&mut self, tooltip: AnyTooltip) -> TooltipId {
@@ -2821,22 +3049,30 @@ impl Window {
     /// to determine whether the inserted hitbox was the topmost.
     ///
     /// This method should only be called as part of the prepaint phase of element drawing.
-    pub fn insert_hitbox(&mut self, bounds: Bounds<Pixels>, opaque: bool) -> Hitbox {
+    pub fn insert_hitbox(&mut self, bounds: Bounds<Pixels>, behavior: HitboxBehavior) -> Hitbox {
         self.invalidator.debug_assert_prepaint();
 
         let content_mask = self.content_mask();
-        let id = self.next_hitbox_id;
-        self.next_hitbox_id.0 += 1;
+        let mut id = self.next_hitbox_id;
+        self.next_hitbox_id = self.next_hitbox_id.next();
         let hitbox = Hitbox {
             id,
             bounds,
             content_mask,
-            opaque,
+            behavior,
         };
         self.next_frame.hitboxes.push(hitbox.clone());
         hitbox
     }
 
+    /// Set a hitbox which will act as a control area of the platform window.
+    ///
+    /// This method should only be called as part of the paint phase of element drawing.
+    pub fn insert_window_control_hitbox(&mut self, area: WindowControlArea, hitbox: Hitbox) {
+        self.invalidator.debug_assert_paint();
+        self.next_frame.window_control_hitboxes.push((area, hitbox));
+    }
+
     /// Sets the key context for the current element. This context will be used to translate
     /// keybindings into actions.
     ///
@@ -3040,15 +3276,7 @@ impl Window {
         if self.is_window_hovered() {
             let style = self
                 .rendered_frame
-                .cursor_styles
-                .iter()
-                .rev()
-                .find(|request| {
-                    request
-                        .hitbox_id
-                        .map_or(true, |hitbox_id| hitbox_id.is_hovered(self))
-                })
-                .map(|request| request.style)
+                .cursor_style(self)
                 .unwrap_or(CursorStyle::Arrow);
             cx.platform.set_cursor_style(style);
         }
@@ -3083,8 +3311,7 @@ impl Window {
     /// Return a key binding string for an action, to display in the UI. Uses the highest precedence
     /// binding for the action (last binding added to the keymap).
     pub fn keystroke_text_for(&self, action: &dyn Action) -> String {
-        self.bindings_for_action(action)
-            .last()
+        self.highest_precedence_binding_for_action(action)
             .map(|binding| {
                 binding
                     .keystrokes()
@@ -3129,6 +3356,7 @@ impl Window {
             }
             PlatformInput::ModifiersChanged(modifiers_changed) => {
                 self.modifiers = modifiers_changed.modifiers;
+                self.capslock = modifiers_changed.capslock;
                 PlatformInput::ModifiersChanged(modifiers_changed)
             }
             PlatformInput::ScrollWheel(scroll_wheel) => {
@@ -3200,6 +3428,13 @@ impl Window {
             self.reset_cursor_style(cx);
         }
 
+        #[cfg(any(feature = "inspector", debug_assertions))]
+        if self.is_inspector_picking(cx) {
+            self.handle_inspector_mouse_event(event, cx);
+            // When inspector is picking, all other mouse handling is skipped.
+            return;
+        }
+
         let mut mouse_listeners = mem::take(&mut self.rendered_frame.mouse_listeners);
 
         // Capture phase, events bubble from back to front. Handlers for this phase are used for
@@ -3241,18 +3476,10 @@ impl Window {
 
     fn dispatch_key_event(&mut self, event: &dyn Any, cx: &mut App) {
         if self.invalidator.is_dirty() {
-            self.draw(cx);
+            self.draw(cx).clear();
         }
 
-        let node_id = self
-            .focus
-            .and_then(|focus_id| {
-                self.rendered_frame
-                    .dispatch_tree
-                    .focusable_node_id(focus_id)
-            })
-            .unwrap_or_else(|| self.rendered_frame.dispatch_tree.root_node_id());
-
+        let node_id = self.focus_node_id_in_rendered_frame(self.focus);
         let dispatch_path = self.rendered_frame.dispatch_tree.dispatch_path(node_id);
 
         let mut keystroke: Option<Keystroke> = None;
@@ -3324,6 +3551,7 @@ impl Window {
                         return;
                     };
 
+                    let node_id = window.focus_node_id_in_rendered_frame(window.focus);
                     let dispatch_path = window.rendered_frame.dispatch_tree.dispatch_path(node_id);
 
                     let to_replay = window
@@ -3331,6 +3559,7 @@ impl Window {
                         .dispatch_tree
                         .flush_dispatch(currently_pending.keystrokes, &dispatch_path);
 
+                    window.pending_input_changed(cx);
                     window.replay_pending_input(to_replay, cx)
                 })
                 .log_err();
@@ -3454,15 +3683,7 @@ impl Window {
     }
 
     fn replay_pending_input(&mut self, replays: SmallVec<[Replay; 1]>, cx: &mut App) {
-        let node_id = self
-            .focus
-            .and_then(|focus_id| {
-                self.rendered_frame
-                    .dispatch_tree
-                    .focusable_node_id(focus_id)
-            })
-            .unwrap_or_else(|| self.rendered_frame.dispatch_tree.root_node_id());
-
+        let node_id = self.focus_node_id_in_rendered_frame(self.focus);
         let dispatch_path = self.rendered_frame.dispatch_tree.dispatch_path(node_id);
 
         'replay: for replay in replays {
@@ -3498,6 +3719,16 @@ impl Window {
         }
     }
 
+    fn focus_node_id_in_rendered_frame(&self, focus_id: Option<FocusId>) -> DispatchNodeId {
+        focus_id
+            .and_then(|focus_id| {
+                self.rendered_frame
+                    .dispatch_tree
+                    .focusable_node_id(focus_id)
+            })
+            .unwrap_or_else(|| self.rendered_frame.dispatch_tree.root_node_id())
+    }
+
     fn dispatch_action_on_node(
         &mut self,
         node_id: DispatchNodeId,
@@ -3649,28 +3880,36 @@ impl Window {
     /// Present a platform dialog.
     /// The provided message will be presented, along with buttons for each answer.
     /// When a button is clicked, the returned Receiver will receive the index of the clicked button.
-    pub fn prompt(
+    pub fn prompt<T>(
         &mut self,
         level: PromptLevel,
         message: &str,
         detail: Option<&str>,
-        answers: &[&str],
+        answers: &[T],
         cx: &mut App,
-    ) -> oneshot::Receiver<usize> {
+    ) -> oneshot::Receiver<usize>
+    where
+        T: Clone + Into<PromptButton>,
+    {
         let prompt_builder = cx.prompt_builder.take();
         let Some(prompt_builder) = prompt_builder else {
             unreachable!("Re-entrant window prompting is not supported by GPUI");
         };
 
+        let answers = answers
+            .iter()
+            .map(|answer| answer.clone().into())
+            .collect::<Vec<_>>();
+
         let receiver = match &prompt_builder {
             PromptBuilder::Default => self
                 .platform_window
-                .prompt(level, message, detail, answers)
+                .prompt(level, message, detail, &answers)
                 .unwrap_or_else(|| {
-                    self.build_custom_prompt(&prompt_builder, level, message, detail, answers, cx)
+                    self.build_custom_prompt(&prompt_builder, level, message, detail, &answers, cx)
                 }),
             PromptBuilder::Custom(_) => {
-                self.build_custom_prompt(&prompt_builder, level, message, detail, answers, cx)
+                self.build_custom_prompt(&prompt_builder, level, message, detail, &answers, cx)
             }
         };
 
@@ -3685,7 +3924,7 @@ impl Window {
         level: PromptLevel,
         message: &str,
         detail: Option<&str>,
-        answers: &[&str],
+        answers: &[PromptButton],
         cx: &mut App,
     ) -> oneshot::Receiver<usize> {
         let (sender, receiver) = oneshot::channel();
@@ -3697,12 +3936,8 @@ impl Window {
 
     /// Returns the current context stack.
     pub fn context_stack(&self) -> Vec<KeyContext> {
+        let node_id = self.focus_node_id_in_rendered_frame(self.focus);
         let dispatch_tree = &self.rendered_frame.dispatch_tree;
-        let node_id = self
-            .focus
-            .and_then(|focus_id| dispatch_tree.focusable_node_id(focus_id))
-            .unwrap_or_else(|| dispatch_tree.root_node_id());
-
         dispatch_tree
             .dispatch_path(node_id)
             .iter()
@@ -3712,15 +3947,7 @@ impl Window {
 
     /// Returns all available actions for the focused element.
     pub fn available_actions(&self, cx: &App) -> Vec<Box<dyn Action>> {
-        let node_id = self
-            .focus
-            .and_then(|focus_id| {
-                self.rendered_frame
-                    .dispatch_tree
-                    .focusable_node_id(focus_id)
-            })
-            .unwrap_or_else(|| self.rendered_frame.dispatch_tree.root_node_id());
-
+        let node_id = self.focus_node_id_in_rendered_frame(self.focus);
         let mut actions = self.rendered_frame.dispatch_tree.available_actions(node_id);
         for action_type in cx.global_action_listeners.keys() {
             if let Err(ix) = actions.binary_search_by_key(action_type, |a| a.as_any().type_id()) {
@@ -3741,6 +3968,38 @@ impl Window {
             .bindings_for_action(action, &self.rendered_frame.dispatch_tree.context_stack)
     }
 
+    /// Returns the highest precedence key binding that invokes an action on the currently focused
+    /// element. This is more efficient than getting the last result of `bindings_for_action`.
+    pub fn highest_precedence_binding_for_action(&self, action: &dyn Action) -> Option<KeyBinding> {
+        self.rendered_frame
+            .dispatch_tree
+            .highest_precedence_binding_for_action(
+                action,
+                &self.rendered_frame.dispatch_tree.context_stack,
+            )
+    }
+
+    /// Returns the key bindings for an action in a context.
+    pub fn bindings_for_action_in_context(
+        &self,
+        action: &dyn Action,
+        context: KeyContext,
+    ) -> Vec<KeyBinding> {
+        let dispatch_tree = &self.rendered_frame.dispatch_tree;
+        dispatch_tree.bindings_for_action(action, &[context])
+    }
+
+    /// Returns the highest precedence key binding for an action in a context. This is more
+    /// efficient than getting the last result of `bindings_for_action_in_context`.
+    pub fn highest_precedence_binding_for_action_in_context(
+        &self,
+        action: &dyn Action,
+        context: KeyContext,
+    ) -> Option<KeyBinding> {
+        let dispatch_tree = &self.rendered_frame.dispatch_tree;
+        dispatch_tree.highest_precedence_binding_for_action(action, &[context])
+    }
+
     /// Returns any bindings that would invoke an action on the given focus handle if it were
     /// focused. Bindings are returned in the order they were added. For display, the last binding
     /// should take precedence.
@@ -3750,26 +4009,37 @@ impl Window {
         focus_handle: &FocusHandle,
     ) -> Vec<KeyBinding> {
         let dispatch_tree = &self.rendered_frame.dispatch_tree;
-
-        let Some(node_id) = dispatch_tree.focusable_node_id(focus_handle.id) else {
+        let Some(context_stack) = self.context_stack_for_focus_handle(focus_handle) else {
             return vec![];
         };
-        let context_stack: Vec<_> = dispatch_tree
-            .dispatch_path(node_id)
-            .into_iter()
-            .filter_map(|node_id| dispatch_tree.node(node_id).context.clone())
-            .collect();
         dispatch_tree.bindings_for_action(action, &context_stack)
     }
 
-    /// Returns the key bindings for the given action in the given context.
-    pub fn bindings_for_action_in_context(
+    /// Returns the highest precedence key binding that would invoke an action on the given focus
+    /// handle if it were focused. This is more efficient than getting the last result of
+    /// `bindings_for_action_in`.
+    pub fn highest_precedence_binding_for_action_in(
         &self,
         action: &dyn Action,
-        context: KeyContext,
-    ) -> Vec<KeyBinding> {
+        focus_handle: &FocusHandle,
+    ) -> Option<KeyBinding> {
         let dispatch_tree = &self.rendered_frame.dispatch_tree;
-        dispatch_tree.bindings_for_action(action, &[context])
+        let context_stack = self.context_stack_for_focus_handle(focus_handle)?;
+        dispatch_tree.highest_precedence_binding_for_action(action, &context_stack)
+    }
+
+    fn context_stack_for_focus_handle(
+        &self,
+        focus_handle: &FocusHandle,
+    ) -> Option<Vec<KeyContext>> {
+        let dispatch_tree = &self.rendered_frame.dispatch_tree;
+        let node_id = dispatch_tree.focusable_node_id(focus_handle.id)?;
+        let context_stack: Vec<_> = dispatch_tree
+            .dispatch_path(node_id)
+            .into_iter()
+            .filter_map(|node_id| dispatch_tree.node(node_id).context.clone())
+            .collect();
+        Some(context_stack)
     }
 
     /// Returns a generic event listener that invokes the given listener with the view and context associated with the given view handle.
@@ -3830,6 +4100,203 @@ impl Window {
     pub fn gpu_specs(&self) -> Option<GpuSpecs> {
         self.platform_window.gpu_specs()
     }
+
+    /// Perform titlebar double-click action.
+    /// This is MacOS specific.
+    pub fn titlebar_double_click(&self) {
+        self.platform_window.titlebar_double_click();
+    }
+
+    /// Toggles the inspector mode on this window.
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub fn toggle_inspector(&mut self, cx: &mut App) {
+        self.inspector = match self.inspector {
+            None => Some(cx.new(|_| Inspector::new())),
+            Some(_) => None,
+        };
+        self.refresh();
+    }
+
+    /// Returns true if the window is in inspector mode.
+    pub fn is_inspector_picking(&self, _cx: &App) -> bool {
+        #[cfg(any(feature = "inspector", debug_assertions))]
+        {
+            if let Some(inspector) = &self.inspector {
+                return inspector.read(_cx).is_picking();
+            }
+        }
+        false
+    }
+
+    /// Executes the provided function with mutable access to an inspector state.
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub fn with_inspector_state<T: 'static, R>(
+        &mut self,
+        _inspector_id: Option<&crate::InspectorElementId>,
+        cx: &mut App,
+        f: impl FnOnce(&mut Option<T>, &mut Self) -> R,
+    ) -> R {
+        if let Some(inspector_id) = _inspector_id {
+            if let Some(inspector) = &self.inspector {
+                let inspector = inspector.clone();
+                let active_element_id = inspector.read(cx).active_element_id();
+                if Some(inspector_id) == active_element_id {
+                    return inspector.update(cx, |inspector, _cx| {
+                        inspector.with_active_element_state(self, f)
+                    });
+                }
+            }
+        }
+        f(&mut None, self)
+    }
+
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub(crate) fn build_inspector_element_id(
+        &mut self,
+        path: crate::InspectorElementPath,
+    ) -> crate::InspectorElementId {
+        self.invalidator.debug_assert_paint_or_prepaint();
+        let path = Rc::new(path);
+        let next_instance_id = self
+            .next_frame
+            .next_inspector_instance_ids
+            .entry(path.clone())
+            .or_insert(0);
+        let instance_id = *next_instance_id;
+        *next_instance_id += 1;
+        crate::InspectorElementId { path, instance_id }
+    }
+
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    fn prepaint_inspector(&mut self, inspector_width: Pixels, cx: &mut App) -> Option<AnyElement> {
+        if let Some(inspector) = self.inspector.take() {
+            let mut inspector_element = AnyView::from(inspector.clone()).into_any_element();
+            inspector_element.prepaint_as_root(
+                point(self.viewport_size.width - inspector_width, px(0.0)),
+                size(inspector_width, self.viewport_size.height).into(),
+                self,
+                cx,
+            );
+            self.inspector = Some(inspector);
+            Some(inspector_element)
+        } else {
+            None
+        }
+    }
+
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    fn paint_inspector(&mut self, mut inspector_element: Option<AnyElement>, cx: &mut App) {
+        if let Some(mut inspector_element) = inspector_element {
+            inspector_element.paint(self, cx);
+        };
+    }
+
+    /// Registers a hitbox that can be used for inspector picking mode, allowing users to select and
+    /// inspect UI elements by clicking on them.
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    pub fn insert_inspector_hitbox(
+        &mut self,
+        hitbox_id: HitboxId,
+        inspector_id: Option<&crate::InspectorElementId>,
+        cx: &App,
+    ) {
+        self.invalidator.debug_assert_paint_or_prepaint();
+        if !self.is_inspector_picking(cx) {
+            return;
+        }
+        if let Some(inspector_id) = inspector_id {
+            self.next_frame
+                .inspector_hitboxes
+                .insert(hitbox_id, inspector_id.clone());
+        }
+    }
+
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    fn paint_inspector_hitbox(&mut self, cx: &App) {
+        if let Some(inspector) = self.inspector.as_ref() {
+            let inspector = inspector.read(cx);
+            if let Some((hitbox_id, _)) = self.hovered_inspector_hitbox(inspector, &self.next_frame)
+            {
+                if let Some(hitbox) = self
+                    .next_frame
+                    .hitboxes
+                    .iter()
+                    .find(|hitbox| hitbox.id == hitbox_id)
+                {
+                    self.paint_quad(crate::fill(hitbox.bounds, crate::rgba(0x61afef4d)));
+                }
+            }
+        }
+    }
+
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    fn handle_inspector_mouse_event(&mut self, event: &dyn Any, cx: &mut App) {
+        let Some(inspector) = self.inspector.clone() else {
+            return;
+        };
+        if event.downcast_ref::<MouseMoveEvent>().is_some() {
+            inspector.update(cx, |inspector, _cx| {
+                if let Some((_, inspector_id)) =
+                    self.hovered_inspector_hitbox(inspector, &self.rendered_frame)
+                {
+                    inspector.hover(inspector_id, self);
+                }
+            });
+        } else if event.downcast_ref::<crate::MouseDownEvent>().is_some() {
+            inspector.update(cx, |inspector, _cx| {
+                if let Some((_, inspector_id)) =
+                    self.hovered_inspector_hitbox(inspector, &self.rendered_frame)
+                {
+                    inspector.select(inspector_id, self);
+                }
+            });
+        } else if let Some(event) = event.downcast_ref::<crate::ScrollWheelEvent>() {
+            // This should be kept in sync with SCROLL_LINES in x11 platform.
+            const SCROLL_LINES: f32 = 3.0;
+            const SCROLL_PIXELS_PER_LAYER: f32 = 36.0;
+            let delta_y = event
+                .delta
+                .pixel_delta(px(SCROLL_PIXELS_PER_LAYER / SCROLL_LINES))
+                .y;
+            if let Some(inspector) = self.inspector.clone() {
+                inspector.update(cx, |inspector, _cx| {
+                    if let Some(depth) = inspector.pick_depth.as_mut() {
+                        *depth += delta_y.0 / SCROLL_PIXELS_PER_LAYER;
+                        let max_depth = self.mouse_hit_test.ids.len() as f32 - 0.5;
+                        if *depth < 0.0 {
+                            *depth = 0.0;
+                        } else if *depth > max_depth {
+                            *depth = max_depth;
+                        }
+                        if let Some((_, inspector_id)) =
+                            self.hovered_inspector_hitbox(inspector, &self.rendered_frame)
+                        {
+                            inspector.set_active_element_id(inspector_id.clone(), self);
+                        }
+                    }
+                });
+            }
+        }
+    }
+
+    #[cfg(any(feature = "inspector", debug_assertions))]
+    fn hovered_inspector_hitbox(
+        &self,
+        inspector: &Inspector,
+        frame: &Frame,
+    ) -> Option<(HitboxId, crate::InspectorElementId)> {
+        if let Some(pick_depth) = inspector.pick_depth {
+            let depth = (pick_depth as i64).try_into().unwrap_or(0);
+            let max_skipped = self.mouse_hit_test.ids.len().saturating_sub(1);
+            let skip_count = (depth as usize).min(max_skipped);
+            for hitbox_id in self.mouse_hit_test.ids.iter().skip(skip_count) {
+                if let Some(inspector_id) = frame.inspector_hitboxes.get(hitbox_id) {
+                    return Some((*hitbox_id, inspector_id.clone()));
+                }
+            }
+        }
+        return None;
+    }
 }
 
 // #[derive(Clone, Copy, Eq, PartialEq, Hash)]

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

@@ -4,7 +4,7 @@ use futures::channel::oneshot;
 
 use crate::{
     AnyView, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable,
-    InteractiveElement, IntoElement, ParentElement, PromptLevel, Render,
+    InteractiveElement, IntoElement, ParentElement, PromptButton, PromptLevel, Render,
     StatefulInteractiveElement, Styled, div, opaque_grey, white,
 };
 
@@ -74,7 +74,7 @@ pub fn fallback_prompt_renderer(
     level: PromptLevel,
     message: &str,
     detail: Option<&str>,
-    actions: &[&str],
+    actions: &[PromptButton],
     handle: PromptHandle,
     window: &mut Window,
     cx: &mut App,
@@ -83,7 +83,7 @@ pub fn fallback_prompt_renderer(
         _level: level,
         message: message.to_string(),
         detail: detail.map(ToString::to_string),
-        actions: actions.iter().map(ToString::to_string).collect(),
+        actions: actions.to_vec(),
         focus: cx.focus_handle(),
     });
 
@@ -95,7 +95,7 @@ pub struct FallbackPromptRenderer {
     _level: PromptLevel,
     message: String,
     detail: Option<String>,
-    actions: Vec<String>,
+    actions: Vec<PromptButton>,
     focus: FocusHandle,
 }
 
@@ -138,7 +138,7 @@ impl Render for FallbackPromptRenderer {
                     .rounded_xs()
                     .cursor_pointer()
                     .text_sm()
-                    .child(action.clone())
+                    .child(action.label().clone())
                     .id(ix)
                     .on_click(cx.listener(move |_, _, _, cx| {
                         cx.emit(PromptResponse(ix));
@@ -202,7 +202,7 @@ pub(crate) enum PromptBuilder {
                 PromptLevel,
                 &str,
                 Option<&str>,
-                &[&str],
+                &[PromptButton],
                 PromptHandle,
                 &mut Window,
                 &mut App,
@@ -216,7 +216,7 @@ impl Deref for PromptBuilder {
         PromptLevel,
         &str,
         Option<&str>,
-        &[&str],
+        &[PromptButton],
         PromptHandle,
         &mut Window,
         &mut App,

crates/gpui/tests/action_macros.rs 🔗

@@ -1,16 +1,22 @@
-use gpui::{actions, impl_actions};
+use gpui::{Action, actions};
 use gpui_macros::register_action;
 use schemars::JsonSchema;
 use serde_derive::Deserialize;
 
 #[test]
 fn test_action_macros() {
-    actions!(test, [TestAction]);
-
-    #[derive(PartialEq, Clone, Deserialize, JsonSchema)]
-    struct AnotherTestAction;
-
-    impl_actions!(test, [AnotherTestAction]);
+    actions!(
+        test_only,
+        [
+            SomeAction,
+            /// Documented action
+            SomeActionWithDocs,
+        ]
+    );
+
+    #[derive(PartialEq, Clone, Deserialize, JsonSchema, Action)]
+    #[action(namespace = test_only)]
+    struct AnotherSomeAction;
 
     #[derive(PartialEq, Clone, gpui::private::serde_derive::Deserialize)]
     struct RegisterableAction {}
@@ -26,11 +32,11 @@ fn test_action_macros() {
             unimplemented!()
         }
 
-        fn name(&self) -> &str {
+        fn name(&self) -> &'static str {
             unimplemented!()
         }
 
-        fn debug_name() -> &'static str
+        fn name_for_type() -> &'static str
         where
             Self: Sized,
         {

crates/gpui_macros/Cargo.toml 🔗

@@ -8,16 +8,20 @@ license = "Apache-2.0"
 [lints]
 workspace = true
 
+[features]
+inspector = []
+
 [lib]
 path = "src/gpui_macros.rs"
 proc-macro = true
 doctest = true
 
 [dependencies]
+heck.workspace = true
 proc-macro2.workspace = true
 quote.workspace = true
 syn.workspace = true
 workspace-hack.workspace = true
 
 [dev-dependencies]
-gpui.workspace = true
+gpui = { workspace = true, features = ["inspector"] }

crates/gpui_macros/src/derive_action.rs 🔗

@@ -0,0 +1,176 @@
+use crate::register_action::generate_register_action;
+use proc_macro::TokenStream;
+use proc_macro2::Ident;
+use quote::quote;
+use syn::{Data, DeriveInput, LitStr, Token, parse::ParseStream};
+
+pub(crate) fn derive_action(input: TokenStream) -> TokenStream {
+    let input = syn::parse_macro_input!(input as DeriveInput);
+
+    let struct_name = &input.ident;
+    let mut name_argument = None;
+    let mut deprecated_aliases = Vec::new();
+    let mut no_json = false;
+    let mut no_register = false;
+    let mut namespace = None;
+    let mut deprecated = None;
+
+    for attr in &input.attrs {
+        if attr.path().is_ident("action") {
+            attr.parse_nested_meta(|meta| {
+                if meta.path.is_ident("name") {
+                    if name_argument.is_some() {
+                        return Err(meta.error("'name' argument specified multiple times"));
+                    }
+                    meta.input.parse::<Token![=]>()?;
+                    let lit: LitStr = meta.input.parse()?;
+                    name_argument = Some(lit.value());
+                } else if meta.path.is_ident("namespace") {
+                    if namespace.is_some() {
+                        return Err(meta.error("'namespace' argument specified multiple times"));
+                    }
+                    meta.input.parse::<Token![=]>()?;
+                    let ident: Ident = meta.input.parse()?;
+                    namespace = Some(ident.to_string());
+                } else if meta.path.is_ident("no_json") {
+                    if no_json {
+                        return Err(meta.error("'no_json' argument specified multiple times"));
+                    }
+                    no_json = true;
+                } else if meta.path.is_ident("no_register") {
+                    if no_register {
+                        return Err(meta.error("'no_register' argument specified multiple times"));
+                    }
+                    no_register = true;
+                } else if meta.path.is_ident("deprecated_aliases") {
+                    if !deprecated_aliases.is_empty() {
+                        return Err(
+                            meta.error("'deprecated_aliases' argument specified multiple times")
+                        );
+                    }
+                    meta.input.parse::<Token![=]>()?;
+                    // Parse array of string literals
+                    let content;
+                    syn::bracketed!(content in meta.input);
+                    let aliases = content.parse_terminated(
+                        |input: ParseStream| input.parse::<LitStr>(),
+                        Token![,],
+                    )?;
+                    deprecated_aliases.extend(aliases.into_iter().map(|lit| lit.value()));
+                } else if meta.path.is_ident("deprecated") {
+                    if deprecated.is_some() {
+                        return Err(meta.error("'deprecated' argument specified multiple times"));
+                    }
+                    meta.input.parse::<Token![=]>()?;
+                    let lit: LitStr = meta.input.parse()?;
+                    deprecated = Some(lit.value());
+                } else {
+                    return Err(meta.error(format!(
+                        "'{:?}' argument not recognized, expected \
+                        'namespace', 'no_json', 'no_register, 'deprecated_aliases', or 'deprecated'",
+                        meta.path
+                    )));
+                }
+                Ok(())
+            })
+            .unwrap_or_else(|e| panic!("in #[action] attribute: {}", e));
+        }
+    }
+
+    let name = name_argument.unwrap_or_else(|| struct_name.to_string());
+
+    if name.contains("::") {
+        panic!(
+            "in #[action] attribute: `name = \"{name}\"` must not contain `::`, \
+            also specify `namespace` instead"
+        );
+    }
+
+    let full_name = if let Some(namespace) = namespace {
+        format!("{namespace}::{name}")
+    } else {
+        name
+    };
+
+    let is_unit_struct = matches!(&input.data, Data::Struct(data) if data.fields.is_empty());
+
+    let build_fn_body = if no_json {
+        let error_msg = format!("{} cannot be built from JSON", full_name);
+        quote! { Err(gpui::private::anyhow::anyhow!(#error_msg)) }
+    } else if is_unit_struct {
+        quote! { Ok(Box::new(Self)) }
+    } else {
+        quote! { Ok(Box::new(gpui::private::serde_json::from_value::<Self>(_value)?)) }
+    };
+
+    let json_schema_fn_body = if no_json || is_unit_struct {
+        quote! { None }
+    } else {
+        quote! { Some(<Self as gpui::private::schemars::JsonSchema>::json_schema(_generator)) }
+    };
+
+    let deprecated_aliases_fn_body = if deprecated_aliases.is_empty() {
+        quote! { &[] }
+    } else {
+        let aliases = deprecated_aliases.iter();
+        quote! { &[#(#aliases),*] }
+    };
+
+    let deprecation_fn_body = if let Some(message) = deprecated {
+        quote! { Some(#message) }
+    } else {
+        quote! { None }
+    };
+
+    let registration = if no_register {
+        quote! {}
+    } else {
+        generate_register_action(struct_name)
+    };
+
+    TokenStream::from(quote! {
+        #registration
+
+        impl gpui::Action for #struct_name {
+            fn name(&self) -> &'static str {
+                #full_name
+            }
+
+            fn name_for_type() -> &'static str
+            where
+                Self: Sized
+            {
+                #full_name
+            }
+
+            fn partial_eq(&self, action: &dyn gpui::Action) -> bool {
+                action
+                    .as_any()
+                    .downcast_ref::<Self>()
+                    .map_or(false, |a| self == a)
+            }
+
+            fn boxed_clone(&self) -> Box<dyn gpui::Action> {
+                Box::new(self.clone())
+            }
+
+            fn build(_value: gpui::private::serde_json::Value) -> gpui::Result<Box<dyn gpui::Action>> {
+                #build_fn_body
+            }
+
+            fn action_json_schema(
+                _generator: &mut gpui::private::schemars::r#gen::SchemaGenerator,
+            ) -> Option<gpui::private::schemars::schema::Schema> {
+                #json_schema_fn_body
+            }
+
+            fn deprecated_aliases() -> &'static [&'static str] {
+                #deprecated_aliases_fn_body
+            }
+
+            fn deprecation_message() -> Option<&'static str> {
+                #deprecation_fn_body
+            }
+        }
+    })
+}

crates/gpui_macros/src/derive_inspector_reflection.rs 🔗

@@ -0,0 +1,307 @@
+//! Implements `#[derive_inspector_reflection]` macro to provide runtime access to trait methods
+//! that have the shape `fn method(self) -> Self`. This code was generated using Zed Agent with Claude Opus 4.
+
+use heck::ToSnakeCase as _;
+use proc_macro::TokenStream;
+use proc_macro2::{Span, TokenStream as TokenStream2};
+use quote::quote;
+use syn::{
+    Attribute, Expr, FnArg, Ident, Item, ItemTrait, Lit, Meta, Path, ReturnType, TraitItem, Type,
+    parse_macro_input, parse_quote,
+    visit_mut::{self, VisitMut},
+};
+
+pub fn derive_inspector_reflection(_args: TokenStream, input: TokenStream) -> TokenStream {
+    let mut item = parse_macro_input!(input as Item);
+
+    // First, expand any macros in the trait
+    match &mut item {
+        Item::Trait(trait_item) => {
+            let mut expander = MacroExpander;
+            expander.visit_item_trait_mut(trait_item);
+        }
+        _ => {
+            return syn::Error::new_spanned(
+                quote!(#item),
+                "#[derive_inspector_reflection] can only be applied to traits",
+            )
+            .to_compile_error()
+            .into();
+        }
+    }
+
+    // Now process the expanded trait
+    match item {
+        Item::Trait(trait_item) => generate_reflected_trait(trait_item),
+        _ => unreachable!(),
+    }
+}
+
+fn generate_reflected_trait(trait_item: ItemTrait) -> TokenStream {
+    let trait_name = &trait_item.ident;
+    let vis = &trait_item.vis;
+
+    // Determine if we're being called from within the gpui crate
+    let call_site = Span::call_site();
+    let inspector_reflection_path = if is_called_from_gpui_crate(call_site) {
+        quote! { crate::inspector_reflection }
+    } else {
+        quote! { ::gpui::inspector_reflection }
+    };
+
+    // Collect method information for methods of form fn name(self) -> Self or fn name(mut self) -> Self
+    let mut method_infos = Vec::new();
+
+    for item in &trait_item.items {
+        if let TraitItem::Fn(method) = item {
+            let method_name = &method.sig.ident;
+
+            // Check if method has self or mut self receiver
+            let has_valid_self_receiver = method
+                .sig
+                .inputs
+                .iter()
+                .any(|arg| matches!(arg, FnArg::Receiver(r) if r.reference.is_none()));
+
+            // Check if method returns Self
+            let returns_self = match &method.sig.output {
+                ReturnType::Type(_, ty) => {
+                    matches!(**ty, Type::Path(ref path) if path.path.is_ident("Self"))
+                }
+                ReturnType::Default => false,
+            };
+
+            // Check if method has exactly one parameter (self or mut self)
+            let param_count = method.sig.inputs.len();
+
+            // Include methods of form fn name(self) -> Self or fn name(mut self) -> Self
+            // This includes methods with default implementations
+            if has_valid_self_receiver && returns_self && param_count == 1 {
+                // Extract documentation and cfg attributes
+                let doc = extract_doc_comment(&method.attrs);
+                let cfg_attrs = extract_cfg_attributes(&method.attrs);
+                method_infos.push((method_name.clone(), doc, cfg_attrs));
+            }
+        }
+    }
+
+    // Generate the reflection module name
+    let reflection_mod_name = Ident::new(
+        &format!("{}_reflection", trait_name.to_string().to_snake_case()),
+        trait_name.span(),
+    );
+
+    // Generate wrapper functions for each method
+    // These wrappers use type erasure to allow runtime invocation
+    let wrapper_functions = method_infos.iter().map(|(method_name, _doc, cfg_attrs)| {
+        let wrapper_name = Ident::new(
+            &format!("__wrapper_{}", method_name),
+            method_name.span(),
+        );
+        quote! {
+            #(#cfg_attrs)*
+            fn #wrapper_name<T: #trait_name + 'static>(value: Box<dyn std::any::Any>) -> Box<dyn std::any::Any> {
+                if let Ok(concrete) = value.downcast::<T>() {
+                    Box::new(concrete.#method_name())
+                } else {
+                    panic!("Type mismatch in reflection wrapper");
+                }
+            }
+        }
+    });
+
+    // Generate method info entries
+    let method_info_entries = method_infos.iter().map(|(method_name, doc, cfg_attrs)| {
+        let method_name_str = method_name.to_string();
+        let wrapper_name = Ident::new(&format!("__wrapper_{}", method_name), method_name.span());
+        let doc_expr = match doc {
+            Some(doc_str) => quote! { Some(#doc_str) },
+            None => quote! { None },
+        };
+        quote! {
+            #(#cfg_attrs)*
+            #inspector_reflection_path::FunctionReflection {
+                name: #method_name_str,
+                function: #wrapper_name::<T>,
+                documentation: #doc_expr,
+                _type: ::std::marker::PhantomData,
+            }
+        }
+    });
+
+    // Generate the complete output
+    let output = quote! {
+        #trait_item
+
+        /// Implements function reflection
+        #vis mod #reflection_mod_name {
+            use super::*;
+
+            #(#wrapper_functions)*
+
+            /// Get all reflectable methods for a concrete type implementing the trait
+            pub fn methods<T: #trait_name + 'static>() -> Vec<#inspector_reflection_path::FunctionReflection<T>> {
+                vec![
+                    #(#method_info_entries),*
+                ]
+            }
+
+            /// Find a method by name for a concrete type implementing the trait
+            pub fn find_method<T: #trait_name + 'static>(name: &str) -> Option<#inspector_reflection_path::FunctionReflection<T>> {
+                methods::<T>().into_iter().find(|m| m.name == name)
+            }
+        }
+    };
+
+    TokenStream::from(output)
+}
+
+fn extract_doc_comment(attrs: &[Attribute]) -> Option<String> {
+    let mut doc_lines = Vec::new();
+
+    for attr in attrs {
+        if attr.path().is_ident("doc") {
+            if let Meta::NameValue(meta) = &attr.meta {
+                if let Expr::Lit(expr_lit) = &meta.value {
+                    if let Lit::Str(lit_str) = &expr_lit.lit {
+                        let line = lit_str.value();
+                        let line = line.strip_prefix(' ').unwrap_or(&line);
+                        doc_lines.push(line.to_string());
+                    }
+                }
+            }
+        }
+    }
+
+    if doc_lines.is_empty() {
+        None
+    } else {
+        Some(doc_lines.join("\n"))
+    }
+}
+
+fn extract_cfg_attributes(attrs: &[Attribute]) -> Vec<Attribute> {
+    attrs
+        .iter()
+        .filter(|attr| attr.path().is_ident("cfg"))
+        .cloned()
+        .collect()
+}
+
+fn is_called_from_gpui_crate(_span: Span) -> bool {
+    // Check if we're being called from within the gpui crate by examining the call site
+    // This is a heuristic approach - we check if the current crate name is "gpui"
+    std::env::var("CARGO_PKG_NAME").map_or(false, |name| name == "gpui")
+}
+
+struct MacroExpander;
+
+impl VisitMut for MacroExpander {
+    fn visit_item_trait_mut(&mut self, trait_item: &mut ItemTrait) {
+        let mut expanded_items = Vec::new();
+        let mut items_to_keep = Vec::new();
+
+        for item in trait_item.items.drain(..) {
+            match item {
+                TraitItem::Macro(macro_item) => {
+                    // Try to expand known macros
+                    if let Some(expanded) = try_expand_macro(&macro_item) {
+                        expanded_items.extend(expanded);
+                    } else {
+                        // Keep unknown macros as-is
+                        items_to_keep.push(TraitItem::Macro(macro_item));
+                    }
+                }
+                other => {
+                    items_to_keep.push(other);
+                }
+            }
+        }
+
+        // Rebuild the items list with expanded content first, then original items
+        trait_item.items = expanded_items;
+        trait_item.items.extend(items_to_keep);
+
+        // Continue visiting
+        visit_mut::visit_item_trait_mut(self, trait_item);
+    }
+}
+
+fn try_expand_macro(macro_item: &syn::TraitItemMacro) -> Option<Vec<TraitItem>> {
+    let path = &macro_item.mac.path;
+
+    // Check if this is one of our known style macros
+    let macro_name = path_to_string(path);
+
+    // Handle the known macros by calling their implementations
+    match macro_name.as_str() {
+        "gpui_macros::style_helpers" | "style_helpers" => {
+            let tokens = macro_item.mac.tokens.clone();
+            let expanded = crate::styles::style_helpers(TokenStream::from(tokens));
+            parse_expanded_items(expanded)
+        }
+        "gpui_macros::visibility_style_methods" | "visibility_style_methods" => {
+            let tokens = macro_item.mac.tokens.clone();
+            let expanded = crate::styles::visibility_style_methods(TokenStream::from(tokens));
+            parse_expanded_items(expanded)
+        }
+        "gpui_macros::margin_style_methods" | "margin_style_methods" => {
+            let tokens = macro_item.mac.tokens.clone();
+            let expanded = crate::styles::margin_style_methods(TokenStream::from(tokens));
+            parse_expanded_items(expanded)
+        }
+        "gpui_macros::padding_style_methods" | "padding_style_methods" => {
+            let tokens = macro_item.mac.tokens.clone();
+            let expanded = crate::styles::padding_style_methods(TokenStream::from(tokens));
+            parse_expanded_items(expanded)
+        }
+        "gpui_macros::position_style_methods" | "position_style_methods" => {
+            let tokens = macro_item.mac.tokens.clone();
+            let expanded = crate::styles::position_style_methods(TokenStream::from(tokens));
+            parse_expanded_items(expanded)
+        }
+        "gpui_macros::overflow_style_methods" | "overflow_style_methods" => {
+            let tokens = macro_item.mac.tokens.clone();
+            let expanded = crate::styles::overflow_style_methods(TokenStream::from(tokens));
+            parse_expanded_items(expanded)
+        }
+        "gpui_macros::cursor_style_methods" | "cursor_style_methods" => {
+            let tokens = macro_item.mac.tokens.clone();
+            let expanded = crate::styles::cursor_style_methods(TokenStream::from(tokens));
+            parse_expanded_items(expanded)
+        }
+        "gpui_macros::border_style_methods" | "border_style_methods" => {
+            let tokens = macro_item.mac.tokens.clone();
+            let expanded = crate::styles::border_style_methods(TokenStream::from(tokens));
+            parse_expanded_items(expanded)
+        }
+        "gpui_macros::box_shadow_style_methods" | "box_shadow_style_methods" => {
+            let tokens = macro_item.mac.tokens.clone();
+            let expanded = crate::styles::box_shadow_style_methods(TokenStream::from(tokens));
+            parse_expanded_items(expanded)
+        }
+        _ => None,
+    }
+}
+
+fn path_to_string(path: &Path) -> String {
+    path.segments
+        .iter()
+        .map(|seg| seg.ident.to_string())
+        .collect::<Vec<_>>()
+        .join("::")
+}
+
+fn parse_expanded_items(expanded: TokenStream) -> Option<Vec<TraitItem>> {
+    let tokens = TokenStream2::from(expanded);
+
+    // Try to parse the expanded tokens as trait items
+    // We need to wrap them in a dummy trait to parse properly
+    let dummy_trait: ItemTrait = parse_quote! {
+        trait Dummy {
+            #tokens
+        }
+    };
+
+    Some(dummy_trait.items)
+}

crates/gpui_macros/src/derive_into_element.rs 🔗

@@ -13,6 +13,7 @@ pub fn derive_into_element(input: TokenStream) -> TokenStream {
         {
             type Element = gpui::Component<Self>;
 
+            #[track_caller]
             fn into_element(self) -> Self::Element {
                 gpui::Component::new(self)
             }

crates/gpui_macros/src/derive_path_static_str.rs 🔗

@@ -1,73 +0,0 @@
-use proc_macro::TokenStream;
-use quote::quote;
-use syn::{Attribute, Data, DeriveInput, Lit, Meta, NestedMeta, parse_macro_input};
-
-pub fn derive_path_static_str(input: TokenStream) -> TokenStream {
-    let input = parse_macro_input!(input as DeriveInput);
-    let name = &input.ident;
-
-    let prefix = get_attr_value(&input.attrs, "prefix").unwrap_or_else(|| "".to_string());
-    let suffix = get_attr_value(&input.attrs, "suffix").unwrap_or_else(|| "".to_string());
-    let delimiter = get_attr_value(&input.attrs, "delimiter").unwrap_or_else(|| "/".to_string());
-
-    let path_str_impl = impl_path_str(name, &input.data, &prefix, &suffix, &delimiter);
-
-    let expanded = quote! {
-        impl #name {
-            pub fn path_str(&self) -> &'static str {
-                #path_str_impl
-            }
-        }
-    };
-
-    TokenStream::from(expanded)
-}
-
-fn impl_path_str(
-    name: &syn::Ident,
-    data: &Data,
-    prefix: &str,
-    suffix: &str,
-    delimiter: &str,
-) -> proc_macro2::TokenStream {
-    match *data {
-        Data::Enum(ref data) => {
-            let match_arms = data.variants.iter().map(|variant| {
-                let ident = &variant.ident;
-                let path = format!("{}{}{}{}{}", prefix, delimiter, ident, delimiter, suffix);
-                quote! {
-                    #name::#ident => #path,
-                }
-            });
-
-            quote! {
-                match self {
-                    #(#match_arms)*
-                }
-            }
-        }
-        _ => panic!("DerivePathStr only supports enums"),
-    }
-}
-
-fn get_attr_value(attrs: &[Attribute], key: &str) -> Option<String> {
-    attrs
-        .iter()
-        .filter(|attr| attr.path.is_ident("derive_path_static_str"))
-        .find_map(|attr| {
-            if let Ok(Meta::List(meta_list)) = attr.parse_meta() {
-                meta_list.nested.iter().find_map(|nested_meta| {
-                    if let NestedMeta::Meta(Meta::NameValue(name_value)) = nested_meta {
-                        if name_value.path.is_ident(key) {
-                            if let Lit::Str(lit_str) = &name_value.lit {
-                                return Some(lit_str.value());
-                            }
-                        }
-                    }
-                    None
-                })
-            } else {
-                None
-            }
-        })
-}

crates/gpui_macros/src/gpui_macros.rs 🔗

@@ -1,21 +1,30 @@
+mod derive_action;
 mod derive_app_context;
 mod derive_into_element;
-mod derive_path_static_str;
 mod derive_render;
 mod derive_visual_context;
 mod register_action;
 mod styles;
 mod test;
 
+#[cfg(any(feature = "inspector", debug_assertions))]
+mod derive_inspector_reflection;
+
 use proc_macro::TokenStream;
 use syn::{DeriveInput, Ident};
 
-/// register_action! can be used to register an action with the GPUI runtime.
-/// You should typically use `gpui::actions!` or `gpui::impl_actions!` instead,
-/// but this can be used for fine grained customization.
+/// `Action` derive macro - see the trait documentation for details.
+#[proc_macro_derive(Action, attributes(action))]
+pub fn derive_action(input: TokenStream) -> TokenStream {
+    derive_action::derive_action(input)
+}
+
+/// This can be used to register an action with the GPUI runtime when you want to manually implement
+/// the `Action` trait. Typically you should use the `Action` derive macro or `actions!` macro
+/// instead.
 #[proc_macro]
 pub fn register_action(ident: TokenStream) -> TokenStream {
-    register_action::register_action_macro(ident)
+    register_action::register_action(ident)
 }
 
 /// #[derive(IntoElement)] is used to create a Component out of anything that implements
@@ -31,12 +40,6 @@ pub fn derive_render(input: TokenStream) -> TokenStream {
     derive_render::derive_render(input)
 }
 
-#[proc_macro_derive(PathStaticStr)]
-#[doc(hidden)]
-pub fn derive_path_static_str(input: TokenStream) -> TokenStream {
-    derive_path_static_str::derive_path_static_str(input)
-}
-
 /// #[derive(AppContext)] is used to create a context out of anything that holds a `&mut App`
 /// Note that a `#[app]` attribute is required to identify the variable holding the &mut App.
 ///
@@ -185,12 +188,34 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
     test::test(args, function)
 }
 
+/// When added to a trait, `#[derive_inspector_reflection]` generates a module which provides
+/// enumeration and lookup by name of all methods that have the shape `fn method(self) -> Self`.
+/// This is used by the inspector so that it can use the builder methods in `Styled` and
+/// `StyledExt`.
+///
+/// The generated module will have the name `<snake_case_trait_name>_reflection` and contain the
+/// following functions:
+///
+/// ```ignore
+/// pub fn methods::<T: TheTrait + 'static>() -> Vec<gpui::inspector_reflection::FunctionReflection<T>>;
+///
+/// pub fn find_method::<T: TheTrait + 'static>() -> Option<gpui::inspector_reflection::FunctionReflection<T>>;
+/// ```
+///
+/// The `invoke` method on `FunctionReflection` will run the method. `FunctionReflection` also
+/// provides the method's documentation.
+#[cfg(any(feature = "inspector", debug_assertions))]
+#[proc_macro_attribute]
+pub fn derive_inspector_reflection(_args: TokenStream, input: TokenStream) -> TokenStream {
+    derive_inspector_reflection::derive_inspector_reflection(_args, input)
+}
+
 pub(crate) fn get_simple_attribute_field(ast: &DeriveInput, name: &'static str) -> Option<Ident> {
     match &ast.data {
         syn::Data::Struct(data_struct) => data_struct
             .fields
             .iter()
-            .find(|field| field.attrs.iter().any(|attr| attr.path.is_ident(name)))
+            .find(|field| field.attrs.iter().any(|attr| attr.path().is_ident(name)))
             .map(|field| field.ident.clone().unwrap()),
         syn::Data::Enum(_) => None,
         syn::Data::Union(_) => None,

crates/gpui_macros/src/register_action.rs 🔗

@@ -1,18 +1,18 @@
 use proc_macro::TokenStream;
-use proc_macro2::Ident;
+use proc_macro2::{Ident, TokenStream as TokenStream2};
 use quote::{format_ident, quote};
 use syn::parse_macro_input;
 
-pub fn register_action_macro(ident: TokenStream) -> TokenStream {
+pub(crate) fn register_action(ident: TokenStream) -> TokenStream {
     let name = parse_macro_input!(ident as Ident);
-    let registration = register_action(&name);
+    let registration = generate_register_action(&name);
 
     TokenStream::from(quote! {
         #registration
     })
 }
 
-pub(crate) fn register_action(type_name: &Ident) -> proc_macro2::TokenStream {
+pub(crate) fn generate_register_action(type_name: &Ident) -> TokenStream2 {
     let action_builder_fn_name = format_ident!(
         "__gpui_actions_builder_{}",
         type_name.to_string().to_lowercase()
@@ -28,11 +28,12 @@ pub(crate) fn register_action(type_name: &Ident) -> proc_macro2::TokenStream {
                 #[doc(hidden)]
                 fn #action_builder_fn_name() -> gpui::MacroActionData {
                     gpui::MacroActionData {
-                        name: <#type_name as gpui::Action>::debug_name(),
-                        aliases: <#type_name as gpui::Action>::deprecated_aliases(),
+                        name: <#type_name as gpui::Action>::name_for_type(),
                         type_id: ::std::any::TypeId::of::<#type_name>(),
                         build: <#type_name as gpui::Action>::build,
                         json_schema: <#type_name as gpui::Action>::action_json_schema,
+                        deprecated_aliases: <#type_name as gpui::Action>::deprecated_aliases(),
+                        deprecation_message: <#type_name as gpui::Action>::deprecation_message(),
                     }
                 }
 
@@ -41,7 +42,5 @@ pub(crate) fn register_action(type_name: &Ident) -> proc_macro2::TokenStream {
                 }
             }
         }
-
-
     }
 }

crates/gpui_macros/src/styles.rs 🔗

@@ -393,7 +393,7 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream {
     let output = quote! {
         /// Sets the box shadow of the element.
         /// [Docs](https://tailwindcss.com/docs/box-shadow)
-        #visibility fn shadow(mut self, shadows: smallvec::SmallVec<[gpui::BoxShadow; 2]>) -> Self {
+        #visibility fn shadow(mut self, shadows: std::vec::Vec<gpui::BoxShadow>) -> Self {
             self.style().box_shadow = Some(shadows);
             self
         }
@@ -409,9 +409,9 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream {
         /// [Docs](https://tailwindcss.com/docs/box-shadow)
         #visibility fn shadow_sm(mut self) -> Self {
             use gpui::{BoxShadow, hsla, point, px};
-            use smallvec::smallvec;
+            use std::vec;
 
-            self.style().box_shadow = Some(smallvec![BoxShadow {
+            self.style().box_shadow = Some(vec![BoxShadow {
                 color: hsla(0., 0., 0., 0.05),
                 offset: point(px(0.), px(1.)),
                 blur_radius: px(2.),
@@ -424,9 +424,9 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream {
         /// [Docs](https://tailwindcss.com/docs/box-shadow)
         #visibility fn shadow_md(mut self) -> Self {
             use gpui::{BoxShadow, hsla, point, px};
-            use smallvec::smallvec;
+            use std::vec;
 
-            self.style().box_shadow = Some(smallvec![
+            self.style().box_shadow = Some(vec![
                 BoxShadow {
                     color: hsla(0.5, 0., 0., 0.1),
                     offset: point(px(0.), px(4.)),
@@ -447,9 +447,9 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream {
         /// [Docs](https://tailwindcss.com/docs/box-shadow)
         #visibility fn shadow_lg(mut self) -> Self {
             use gpui::{BoxShadow, hsla, point, px};
-            use smallvec::smallvec;
+            use std::vec;
 
-            self.style().box_shadow = Some(smallvec![
+            self.style().box_shadow = Some(vec![
                 BoxShadow {
                     color: hsla(0., 0., 0., 0.1),
                     offset: point(px(0.), px(10.)),
@@ -470,9 +470,9 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream {
         /// [Docs](https://tailwindcss.com/docs/box-shadow)
         #visibility fn shadow_xl(mut self) -> Self {
             use gpui::{BoxShadow, hsla, point, px};
-            use smallvec::smallvec;
+            use std::vec;
 
-            self.style().box_shadow = Some(smallvec![
+            self.style().box_shadow = Some(vec![
                 BoxShadow {
                     color: hsla(0., 0., 0., 0.1),
                     offset: point(px(0.), px(20.)),
@@ -493,9 +493,9 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream {
         /// [Docs](https://tailwindcss.com/docs/box-shadow)
         #visibility fn shadow_2xl(mut self) -> Self {
             use gpui::{BoxShadow, hsla, point, px};
-            use smallvec::smallvec;
+            use std::vec;
 
-            self.style().box_shadow = Some(smallvec![BoxShadow {
+            self.style().box_shadow = Some(vec![BoxShadow {
                 color: hsla(0., 0., 0., 0.25),
                 offset: point(px(0.), px(25.)),
                 blur_radius: px(50.),

crates/gpui_macros/src/test.rs 🔗

@@ -3,76 +3,132 @@ use proc_macro2::Ident;
 use quote::{format_ident, quote};
 use std::mem;
 use syn::{
-    AttributeArgs, FnArg, ItemFn, Lit, Meta, MetaList, NestedMeta, PathSegment, Type, parse_quote,
+    self, Expr, ExprLit, FnArg, ItemFn, Lit, Meta, MetaList, PathSegment, Token, Type,
+    parse::{Parse, ParseStream},
+    parse_quote,
+    punctuated::Punctuated,
     spanned::Spanned,
 };
 
-pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
-    let args = syn::parse_macro_input!(args as AttributeArgs);
-    try_test(args, function).unwrap_or_else(|err| err)
+struct Args {
+    seeds: Vec<u64>,
+    max_retries: usize,
+    max_iterations: usize,
+    on_failure_fn_name: proc_macro2::TokenStream,
 }
 
-fn try_test(args: Vec<NestedMeta>, function: TokenStream) -> Result<TokenStream, TokenStream> {
-    let mut seeds = Vec::<u64>::new();
-    let mut max_retries = 0;
-    let mut num_iterations = 1;
-    let mut on_failure_fn_name = quote!(None);
-
-    for arg in args {
-        let NestedMeta::Meta(arg) = arg else {
-            return Err(error_with_message("unexpected literal", arg));
-        };
+impl Parse for Args {
+    fn parse(input: ParseStream) -> Result<Self, syn::Error> {
+        let mut seeds = Vec::<u64>::new();
+        let mut max_retries = 0;
+        let mut max_iterations = 1;
+        let mut on_failure_fn_name = quote!(None);
 
-        let ident = {
-            let meta_path = match &arg {
-                Meta::NameValue(meta) => &meta.path,
-                Meta::List(list) => &list.path,
-                Meta::Path(path) => return Err(error_with_message("invalid path argument", path)),
-            };
-            let Some(ident) = meta_path.get_ident() else {
-                return Err(error_with_message("unexpected path", meta_path));
-            };
-            ident.to_string()
-        };
+        let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
 
-        match (&arg, ident.as_str()) {
-            (Meta::NameValue(meta), "retries") => max_retries = parse_usize(&meta.lit)?,
-            (Meta::NameValue(meta), "iterations") => num_iterations = parse_usize(&meta.lit)?,
-            (Meta::NameValue(meta), "on_failure") => {
-                let Lit::Str(name) = &meta.lit else {
-                    return Err(error_with_message(
-                        "on_failure argument must be a string",
-                        &meta.lit,
-                    ));
+        for meta in metas {
+            let ident = {
+                let meta_path = match &meta {
+                    Meta::NameValue(meta) => &meta.path,
+                    Meta::List(list) => &list.path,
+                    Meta::Path(path) => {
+                        return Err(syn::Error::new(path.span(), "invalid path argument"));
+                    }
                 };
-                let segments = name
-                    .value()
-                    .split("::")
-                    .map(|part| PathSegment::from(Ident::new(part, name.span())))
-                    .collect();
-                let path = syn::Path {
-                    leading_colon: None,
-                    segments,
+                let Some(ident) = meta_path.get_ident() else {
+                    return Err(syn::Error::new(meta_path.span(), "unexpected path"));
                 };
-                on_failure_fn_name = quote!(Some(#path));
-            }
-            (Meta::NameValue(meta), "seed") => seeds = vec![parse_usize(&meta.lit)? as u64],
-            (Meta::List(list), "seeds") => seeds = parse_u64_array(&list)?,
-            (Meta::Path(path), _) => {
-                return Err(error_with_message("invalid path argument", path));
-            }
-            (_, _) => {
-                return Err(error_with_message("invalid argument name", arg));
+                ident.to_string()
+            };
+
+            match (&meta, ident.as_str()) {
+                (Meta::NameValue(meta), "retries") => {
+                    max_retries = parse_usize_from_expr(&meta.value)?
+                }
+                (Meta::NameValue(meta), "iterations") => {
+                    max_iterations = parse_usize_from_expr(&meta.value)?
+                }
+                (Meta::NameValue(meta), "on_failure") => {
+                    let Expr::Lit(ExprLit {
+                        lit: Lit::Str(name),
+                        ..
+                    }) = &meta.value
+                    else {
+                        return Err(syn::Error::new(
+                            meta.value.span(),
+                            "on_failure argument must be a string",
+                        ));
+                    };
+                    let segments = name
+                        .value()
+                        .split("::")
+                        .map(|part| PathSegment::from(Ident::new(part, name.span())))
+                        .collect();
+                    let path = syn::Path {
+                        leading_colon: None,
+                        segments,
+                    };
+                    on_failure_fn_name = quote!(Some(#path));
+                }
+                (Meta::NameValue(meta), "seed") => {
+                    seeds = vec![parse_usize_from_expr(&meta.value)? as u64]
+                }
+                (Meta::List(list), "seeds") => seeds = parse_u64_array(&list)?,
+                (Meta::Path(_), _) => {
+                    return Err(syn::Error::new(meta.span(), "invalid path argument"));
+                }
+                (_, _) => {
+                    return Err(syn::Error::new(meta.span(), "invalid argument name"));
+                }
             }
         }
+
+        Ok(Args {
+            seeds,
+            max_retries,
+            max_iterations: max_iterations,
+            on_failure_fn_name,
+        })
     }
-    let seeds = quote!( #(#seeds),* );
+}
+
+pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
+    let args = syn::parse_macro_input!(args as Args);
+    let mut inner_fn = match syn::parse::<ItemFn>(function) {
+        Ok(f) => f,
+        Err(err) => return error_to_stream(err),
+    };
 
-    let mut inner_fn = syn::parse::<ItemFn>(function).map_err(error_to_stream)?;
     let inner_fn_attributes = mem::take(&mut inner_fn.attrs);
     let inner_fn_name = format_ident!("_{}", inner_fn.sig.ident);
     let outer_fn_name = mem::replace(&mut inner_fn.sig.ident, inner_fn_name.clone());
 
+    let result = generate_test_function(
+        args,
+        inner_fn,
+        inner_fn_attributes,
+        inner_fn_name,
+        outer_fn_name,
+    );
+    match result {
+        Ok(tokens) => tokens,
+        Err(tokens) => tokens,
+    }
+}
+
+fn generate_test_function(
+    args: Args,
+    inner_fn: ItemFn,
+    inner_fn_attributes: Vec<syn::Attribute>,
+    inner_fn_name: Ident,
+    outer_fn_name: Ident,
+) -> Result<TokenStream, TokenStream> {
+    let seeds = &args.seeds;
+    let max_retries = args.max_retries;
+    let num_iterations = args.max_iterations;
+    let on_failure_fn_name = &args.on_failure_fn_name;
+    let seeds = quote!( #(#seeds),* );
+
     let mut outer_fn: ItemFn = if inner_fn.sig.asyncness.is_some() {
         // Pass to the test function the number of app contexts that it needs,
         // based on its parameter list.
@@ -230,25 +286,37 @@ fn try_test(args: Vec<NestedMeta>, function: TokenStream) -> Result<TokenStream,
     Ok(TokenStream::from(quote!(#outer_fn)))
 }
 
-fn parse_usize(literal: &Lit) -> Result<usize, TokenStream> {
-    let Lit::Int(int) = &literal else {
-        return Err(error_with_message("expected an usize", literal));
+fn parse_usize_from_expr(expr: &Expr) -> Result<usize, syn::Error> {
+    let Expr::Lit(ExprLit {
+        lit: Lit::Int(int), ..
+    }) = expr
+    else {
+        return Err(syn::Error::new(expr.span(), "expected an integer"));
     };
-    int.base10_parse().map_err(error_to_stream)
+    int.base10_parse()
+        .map_err(|_| syn::Error::new(int.span(), "failed to parse integer"))
 }
 
-fn parse_u64_array(meta_list: &MetaList) -> Result<Vec<u64>, TokenStream> {
-    meta_list
-        .nested
-        .iter()
-        .map(|meta| {
-            if let NestedMeta::Lit(literal) = &meta {
-                parse_usize(literal).map(|value| value as u64)
+fn parse_u64_array(meta_list: &MetaList) -> Result<Vec<u64>, syn::Error> {
+    let mut result = Vec::new();
+    let tokens = &meta_list.tokens;
+    let parser = |input: ParseStream| {
+        let exprs = Punctuated::<Expr, Token![,]>::parse_terminated(input)?;
+        for expr in exprs {
+            if let Expr::Lit(ExprLit {
+                lit: Lit::Int(int), ..
+            }) = expr
+            {
+                let value: usize = int.base10_parse()?;
+                result.push(value as u64);
             } else {
-                Err(error_with_message("expected an integer", meta.span()))
+                return Err(syn::Error::new(expr.span(), "expected an integer"));
             }
-        })
-        .collect()
+        }
+        Ok(())
+    };
+    syn::parse::Parser::parse2(parser, tokens.clone())?;
+    Ok(result)
 }
 
 fn error_with_message(message: &str, spanned: impl Spanned) -> TokenStream {

crates/gpui_macros/tests/derive_inspector_reflection.rs 🔗

@@ -0,0 +1,148 @@
+//! This code was generated using Zed Agent with Claude Opus 4.
+
+use gpui_macros::derive_inspector_reflection;
+
+#[derive_inspector_reflection]
+trait Transform: Clone {
+    /// Doubles the value
+    fn double(self) -> Self;
+
+    /// Triples the value
+    fn triple(self) -> Self;
+
+    /// Increments the value by one
+    ///
+    /// This method has a default implementation
+    fn increment(self) -> Self {
+        // Default implementation
+        self.add_one()
+    }
+
+    /// Quadruples the value by doubling twice
+    fn quadruple(self) -> Self {
+        // Default implementation with mut self
+        self.double().double()
+    }
+
+    // These methods will be filtered out:
+    #[allow(dead_code)]
+    fn add(&self, other: &Self) -> Self;
+    #[allow(dead_code)]
+    fn set_value(&mut self, value: i32);
+    #[allow(dead_code)]
+    fn get_value(&self) -> i32;
+
+    /// Adds one to the value
+    fn add_one(self) -> Self;
+
+    /// cfg attributes are respected
+    #[cfg(all())]
+    fn cfg_included(self) -> Self;
+
+    #[cfg(any())]
+    fn cfg_omitted(self) -> Self;
+}
+
+#[derive(Debug, Clone, PartialEq)]
+struct Number(i32);
+
+impl Transform for Number {
+    fn double(self) -> Self {
+        Number(self.0 * 2)
+    }
+
+    fn triple(self) -> Self {
+        Number(self.0 * 3)
+    }
+
+    fn add(&self, other: &Self) -> Self {
+        Number(self.0 + other.0)
+    }
+
+    fn set_value(&mut self, value: i32) {
+        self.0 = value;
+    }
+
+    fn get_value(&self) -> i32 {
+        self.0
+    }
+
+    fn add_one(self) -> Self {
+        Number(self.0 + 1)
+    }
+
+    fn cfg_included(self) -> Self {
+        Number(self.0)
+    }
+}
+
+#[test]
+fn test_derive_inspector_reflection() {
+    use transform_reflection::*;
+
+    // Get all methods that match the pattern fn(self) -> Self or fn(mut self) -> Self
+    let methods = methods::<Number>();
+
+    assert_eq!(methods.len(), 6);
+    let method_names: Vec<_> = methods.iter().map(|m| m.name).collect();
+    assert!(method_names.contains(&"double"));
+    assert!(method_names.contains(&"triple"));
+    assert!(method_names.contains(&"increment"));
+    assert!(method_names.contains(&"quadruple"));
+    assert!(method_names.contains(&"add_one"));
+    assert!(method_names.contains(&"cfg_included"));
+
+    // Invoke methods by name
+    let num = Number(5);
+
+    let doubled = find_method::<Number>("double").unwrap().invoke(num.clone());
+    assert_eq!(doubled, Number(10));
+
+    let tripled = find_method::<Number>("triple").unwrap().invoke(num.clone());
+    assert_eq!(tripled, Number(15));
+
+    let incremented = find_method::<Number>("increment")
+        .unwrap()
+        .invoke(num.clone());
+    assert_eq!(incremented, Number(6));
+
+    let quadrupled = find_method::<Number>("quadruple")
+        .unwrap()
+        .invoke(num.clone());
+    assert_eq!(quadrupled, Number(20));
+
+    // Try to invoke a non-existent method
+    let result = find_method::<Number>("nonexistent");
+    assert!(result.is_none());
+
+    // Chain operations
+    let num = Number(10);
+    let result = find_method::<Number>("double")
+        .map(|m| m.invoke(num))
+        .and_then(|n| find_method::<Number>("increment").map(|m| m.invoke(n)))
+        .and_then(|n| find_method::<Number>("triple").map(|m| m.invoke(n)));
+
+    assert_eq!(result, Some(Number(63))); // (10 * 2 + 1) * 3 = 63
+
+    // Test documentationumentation capture
+    let double_method = find_method::<Number>("double").unwrap();
+    assert_eq!(double_method.documentation, Some("Doubles the value"));
+
+    let triple_method = find_method::<Number>("triple").unwrap();
+    assert_eq!(triple_method.documentation, Some("Triples the value"));
+
+    let increment_method = find_method::<Number>("increment").unwrap();
+    assert_eq!(
+        increment_method.documentation,
+        Some("Increments the value by one\n\nThis method has a default implementation")
+    );
+
+    let quadruple_method = find_method::<Number>("quadruple").unwrap();
+    assert_eq!(
+        quadruple_method.documentation,
+        Some("Quadruples the value by doubling twice")
+    );
+
+    let add_one_method = find_method::<Number>("add_one").unwrap();
+    assert_eq!(add_one_method.documentation, Some("Adds one to the value"));
+}

crates/http_client/src/github.rs 🔗

@@ -1,5 +1,5 @@
 use crate::HttpClient;
-use anyhow::{Context, Result, anyhow, bail};
+use anyhow::{Context as _, Result, anyhow, bail};
 use futures::AsyncReadExt;
 use serde::Deserialize;
 use std::sync::Arc;
@@ -31,7 +31,7 @@ pub async fn latest_github_release(
     require_assets: bool,
     pre_release: bool,
     http: Arc<dyn HttpClient>,
-) -> Result<GithubRelease, anyhow::Error> {
+) -> anyhow::Result<GithubRelease> {
     let mut response = http
         .get(
             format!("https://api.github.com/repos/{repo_name_with_owner}/releases").as_str(),
@@ -60,12 +60,12 @@ pub async fn latest_github_release(
         Ok(releases) => releases,
 
         Err(err) => {
-            log::error!("Error deserializing: {:?}", err);
+            log::error!("Error deserializing: {err:?}");
             log::error!(
                 "GitHub API response text: {:?}",
                 String::from_utf8_lossy(body.as_slice())
             );
-            return Err(anyhow!("error deserializing latest release"));
+            anyhow::bail!("error deserializing latest release: {err:?}");
         }
     };
 
@@ -73,14 +73,14 @@ pub async fn latest_github_release(
         .into_iter()
         .filter(|release| !require_assets || !release.assets.is_empty())
         .find(|release| release.pre_release == pre_release)
-        .ok_or(anyhow!("Failed to find a release"))
+        .context("finding a prerelease")
 }
 
 pub async fn get_release_by_tag_name(
     repo_name_with_owner: &str,
     tag: &str,
     http: Arc<dyn HttpClient>,
-) -> Result<GithubRelease, anyhow::Error> {
+) -> anyhow::Result<GithubRelease> {
     let mut response = http
         .get(
             &format!("https://api.github.com/repos/{repo_name_with_owner}/releases/tags/{tag}"),
@@ -107,12 +107,12 @@ pub async fn get_release_by_tag_name(
     }
 
     let release = serde_json::from_slice::<GithubRelease>(body.as_slice()).map_err(|err| {
-        log::error!("Error deserializing: {:?}", err);
+        log::error!("Error deserializing: {err:?}");
         log::error!(
             "GitHub API response text: {:?}",
             String::from_utf8_lossy(body.as_slice())
         );
-        anyhow!("error deserializing GitHub release")
+        anyhow!("error deserializing GitHub release: {err:?}")
     })?;
 
     Ok(release)
@@ -140,7 +140,7 @@ pub fn build_asset_url(repo_name_with_owner: &str, tag: &str, kind: AssetKind) -
         }
     );
     url.path_segments_mut()
-        .map_err(|_| anyhow!("cannot modify url path segments"))?
+        .map_err(|()| anyhow!("cannot modify url path segments"))?
         .push(&asset_filename);
     Ok(url.to_string())
 }

crates/http_client/src/http_client.rs 🔗

@@ -42,14 +42,14 @@ pub trait HttpClient: 'static + Send + Sync {
     fn send(
         &self,
         req: http::Request<AsyncBody>,
-    ) -> BoxFuture<'static, Result<Response<AsyncBody>, anyhow::Error>>;
+    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>>;
 
     fn get<'a>(
         &'a self,
         uri: &str,
         body: AsyncBody,
         follow_redirects: bool,
-    ) -> BoxFuture<'a, Result<Response<AsyncBody>, anyhow::Error>> {
+    ) -> BoxFuture<'a, anyhow::Result<Response<AsyncBody>>> {
         let request = Builder::new()
             .uri(uri)
             .follow_redirects(if follow_redirects {
@@ -69,7 +69,7 @@ pub trait HttpClient: 'static + Send + Sync {
         &'a self,
         uri: &str,
         body: AsyncBody,
-    ) -> BoxFuture<'a, Result<Response<AsyncBody>, anyhow::Error>> {
+    ) -> BoxFuture<'a, anyhow::Result<Response<AsyncBody>>> {
         let request = Builder::new()
             .uri(uri)
             .method(Method::POST)
@@ -114,7 +114,7 @@ impl HttpClient for HttpClientWithProxy {
     fn send(
         &self,
         req: Request<AsyncBody>,
-    ) -> BoxFuture<'static, Result<Response<AsyncBody>, anyhow::Error>> {
+    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
         self.client.send(req)
     }
 
@@ -131,7 +131,7 @@ impl HttpClient for Arc<HttpClientWithProxy> {
     fn send(
         &self,
         req: Request<AsyncBody>,
-    ) -> BoxFuture<'static, Result<Response<AsyncBody>, anyhow::Error>> {
+    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
         self.client.send(req)
     }
 
@@ -246,7 +246,7 @@ impl HttpClient for Arc<HttpClientWithUrl> {
     fn send(
         &self,
         req: Request<AsyncBody>,
-    ) -> BoxFuture<'static, Result<Response<AsyncBody>, anyhow::Error>> {
+    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
         self.client.send(req)
     }
 
@@ -263,7 +263,7 @@ impl HttpClient for HttpClientWithUrl {
     fn send(
         &self,
         req: Request<AsyncBody>,
-    ) -> BoxFuture<'static, Result<Response<AsyncBody>, anyhow::Error>> {
+    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
         self.client.send(req)
     }
 
@@ -304,7 +304,7 @@ impl HttpClient for BlockedHttpClient {
     fn send(
         &self,
         _req: Request<AsyncBody>,
-    ) -> BoxFuture<'static, Result<Response<AsyncBody>, anyhow::Error>> {
+    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
         Box::pin(async {
             Err(std::io::Error::new(
                 std::io::ErrorKind::PermissionDenied,
@@ -325,7 +325,7 @@ impl HttpClient for BlockedHttpClient {
 
 #[cfg(feature = "test-support")]
 type FakeHttpHandler = Box<
-    dyn Fn(Request<AsyncBody>) -> BoxFuture<'static, Result<Response<AsyncBody>, anyhow::Error>>
+    dyn Fn(Request<AsyncBody>) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>>
         + Send
         + Sync
         + 'static,
@@ -340,7 +340,7 @@ pub struct FakeHttpClient {
 impl FakeHttpClient {
     pub fn create<Fut, F>(handler: F) -> Arc<HttpClientWithUrl>
     where
-        Fut: futures::Future<Output = Result<Response<AsyncBody>, anyhow::Error>> + Send + 'static,
+        Fut: futures::Future<Output = anyhow::Result<Response<AsyncBody>>> + Send + 'static,
         F: Fn(Request<AsyncBody>) -> Fut + Send + Sync + 'static,
     {
         Arc::new(HttpClientWithUrl {
@@ -385,7 +385,7 @@ impl HttpClient for FakeHttpClient {
     fn send(
         &self,
         req: Request<AsyncBody>,
-    ) -> BoxFuture<'static, Result<Response<AsyncBody>, anyhow::Error>> {
+    ) -> BoxFuture<'static, anyhow::Result<Response<AsyncBody>>> {
         let future = (self.handler)(req);
         future
     }

crates/icons/src/icons.rs 🔗

@@ -18,15 +18,19 @@ pub enum IconName {
     AiMistral,
     AiOllama,
     AiOpenAi,
+    AiOpenRouter,
+    AiVZero,
     AiZed,
     ArrowCircle,
     ArrowDown,
+    ArrowDown10,
     ArrowDownFromLine,
     ArrowDownRight,
     ArrowLeft,
     ArrowRight,
     ArrowRightLeft,
     ArrowUp,
+    ArrowUpAlt,
     ArrowUpFromLine,
     ArrowUpRight,
     ArrowUpRightAlt,
@@ -41,6 +45,8 @@ pub enum IconName {
     Binary,
     Blocks,
     Bolt,
+    BoltFilled,
+    BoltFilledAlt,
     Book,
     BookCopy,
     BookPlus,
@@ -58,6 +64,7 @@ pub enum IconName {
     ChevronUpDown,
     Circle,
     CircleOff,
+    CircleHelp,
     Clipboard,
     Close,
     Cloud,
@@ -153,10 +160,14 @@ pub enum IconName {
     LineHeight,
     Link,
     ListCollapse,
+    ListTodo,
     ListTree,
     ListX,
     LoadCircle,
     LockOutlined,
+    LspDebug,
+    LspRestart,
+    LspStop,
     MagnifyingGlass,
     MailOpen,
     Maximize,
@@ -178,6 +189,8 @@ pub enum IconName {
     PhoneIncoming,
     Pin,
     Play,
+    PlayAlt,
+    PlayBug,
     Plus,
     PocketKnife,
     Power,
@@ -200,6 +213,7 @@ pub enum IconName {
     Save,
     Scissors,
     Screen,
+    ScrollText,
     SearchCode,
     SearchSelection,
     SelectAll,
@@ -219,6 +233,7 @@ pub enum IconName {
     SparkleFilled,
     Spinner,
     Split,
+    SplitAlt,
     SquareDot,
     SquareMinus,
     SquarePlus,
@@ -255,7 +270,10 @@ pub enum IconName {
     XCircle,
     ZedAssistant,
     ZedAssistantFilled,
-    ZedMaxMode,
+    ZedBurnMode,
+    ZedBurnModeOn,
+    ZedMcpCustom,
+    ZedMcpExtension,
     ZedPredict,
     ZedPredictDisabled,
     ZedPredictDown,

crates/image_viewer/Cargo.toml 🔗

@@ -21,6 +21,7 @@ db.workspace = true
 editor.workspace = true
 file_icons.workspace = true
 gpui.workspace = true
+language.workspace = true
 log.workspace = true
 project.workspace = true
 schemars.workspace = true

crates/image_viewer/src/image_viewer.rs 🔗

@@ -11,6 +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 persistence::IMAGE_VIEWER;
 use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent};
 use settings::Settings;
@@ -104,7 +105,7 @@ impl Item for ImageView {
     }
 
     fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
-        let abs_path = self.image_item.read(cx).file.as_local()?.abs_path(cx);
+        let abs_path = self.image_item.read(cx).abs_path(cx)?;
         let file_path = abs_path.compact().to_string_lossy().to_string();
         Some(file_path.into())
     }
@@ -149,10 +150,10 @@ impl Item for ImageView {
     }
 
     fn tab_icon(&self, _: &Window, cx: &App) -> Option<Icon> {
-        let path = self.image_item.read(cx).path();
+        let path = self.image_item.read(cx).abs_path(cx)?;
         ItemSettings::get_global(cx)
             .file_icons
-            .then(|| FileIcons::get_icon(path, cx))
+            .then(|| FileIcons::get_icon(&path, cx))
             .flatten()
             .map(Icon::from_path)
     }
@@ -190,6 +191,10 @@ impl Item for ImageView {
             focus_handle: cx.focus_handle(),
         }))
     }
+
+    fn has_deleted_file(&self, cx: &App) -> bool {
+        self.image_item.read(cx).file.disk_state() == DiskState::Deleted
+    }
 }
 
 fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) -> String {
@@ -221,11 +226,11 @@ impl SerializableItem for ImageView {
         item_id: ItemId,
         window: &mut Window,
         cx: &mut App,
-    ) -> Task<gpui::Result<Entity<Self>>> {
+    ) -> Task<anyhow::Result<Entity<Self>>> {
         window.spawn(cx, async move |cx| {
             let image_path = IMAGE_VIEWER
                 .get_image_path(item_id, workspace_id)?
-                .ok_or_else(|| anyhow::anyhow!("No image path found"))?;
+                .context("No image path found")?;
 
             let (worktree, relative_path) = project
                 .update(cx, |project, cx| {
@@ -255,7 +260,7 @@ impl SerializableItem for ImageView {
         alive_items: Vec<ItemId>,
         _window: &mut Window,
         cx: &mut App,
-    ) -> Task<gpui::Result<()>> {
+    ) -> Task<anyhow::Result<()>> {
         delete_unloaded_items(
             alive_items,
             workspace_id,
@@ -272,9 +277,9 @@ impl SerializableItem for ImageView {
         _closing: bool,
         _window: &mut Window,
         cx: &mut Context<Self>,
-    ) -> Option<Task<gpui::Result<()>>> {
+    ) -> Option<Task<anyhow::Result<()>>> {
         let workspace_id = workspace.database_id()?;
-        let image_path = self.image_item.read(cx).file.as_local()?.abs_path(cx);
+        let image_path = self.image_item.read(cx).abs_path(cx)?;
 
         Some(cx.background_spawn({
             async move {

crates/image_viewer/src/image_viewer_settings.rs 🔗

@@ -28,10 +28,7 @@ impl Settings for ImageViewerSettings {
 
     type FileContent = Self;
 
-    fn load(
-        sources: SettingsSources<Self::FileContent>,
-        _: &mut App,
-    ) -> Result<Self, anyhow::Error> {
+    fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> anyhow::Result<Self> {
         SettingsSources::<Self::FileContent>::json_merge_with(
             [sources.default]
                 .into_iter()

crates/indexed_docs/src/providers/rustdoc.rs 🔗

@@ -12,7 +12,7 @@ use std::path::PathBuf;
 use std::sync::{Arc, LazyLock};
 use std::time::{Duration, Instant};
 
-use anyhow::{Context, Result, bail};
+use anyhow::{Context as _, Result, bail};
 use async_trait::async_trait;
 use collections::{HashSet, VecDeque};
 use fs::Fs;

crates/indexed_docs/src/store.rs 🔗

@@ -2,7 +2,7 @@ use std::path::PathBuf;
 use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
 
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use async_trait::async_trait;
 use collections::HashMap;
 use derive_more::{Deref, Display};
@@ -66,7 +66,7 @@ impl IndexedDocsStore {
         let registry = IndexedDocsRegistry::global(cx);
         registry
             .get_provider_store(provider.clone())
-            .ok_or_else(|| anyhow!("no indexed docs store found for {provider}"))
+            .with_context(|| format!("no indexed docs store found for {provider}"))
     }
 
     pub fn new(
@@ -215,6 +215,7 @@ impl IndexedDocsStore {
                 &candidates,
                 &query,
                 false,
+                true,
                 100,
                 &AtomicBool::default(),
                 executor,
@@ -285,7 +286,7 @@ impl IndexedDocsDatabase {
             let txn = env.read_txn()?;
             entries
                 .get(&txn, &key)?
-                .ok_or_else(|| anyhow!("no docs found for {key}"))
+                .with_context(|| format!("no docs found for {key}"))
         })
     }
 

crates/inline_completion/Cargo.toml 🔗

@@ -12,9 +12,8 @@ workspace = true
 path = "src/inline_completion.rs"
 
 [dependencies]
-anyhow.workspace = true
+client.workspace = true
 gpui.workspace = true
 language.workspace = true
 project.workspace = true
 workspace-hack.workspace = true
-zed_llm_client.workspace = true

crates/inline_completion/src/inline_completion.rs 🔗

@@ -1,14 +1,9 @@
 use std::ops::Range;
-use std::str::FromStr as _;
 
-use anyhow::{Result, anyhow};
-use gpui::http_client::http::{HeaderMap, HeaderValue};
+use client::EditPredictionUsage;
 use gpui::{App, Context, Entity, SharedString};
 use language::Buffer;
 use project::Project;
-use zed_llm_client::{
-    EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, UsageLimit,
-};
 
 // TODO: Find a better home for `Direction`.
 //
@@ -59,32 +54,6 @@ impl DataCollectionState {
     }
 }
 
-#[derive(Debug, Clone, Copy)]
-pub struct EditPredictionUsage {
-    pub limit: UsageLimit,
-    pub amount: i32,
-}
-
-impl EditPredictionUsage {
-    pub fn from_headers(headers: &HeaderMap<HeaderValue>) -> Result<Self> {
-        let limit = headers
-            .get(EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME)
-            .ok_or_else(|| {
-                anyhow!("missing {EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME:?} header")
-            })?;
-        let limit = UsageLimit::from_str(limit.to_str()?)?;
-
-        let amount = headers
-            .get(EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME)
-            .ok_or_else(|| {
-                anyhow!("missing {EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME:?} header")
-            })?;
-        let amount = amount.to_str()?.parse::<i32>()?;
-
-        Ok(Self { limit, amount })
-    }
-}
-
 pub trait EditPredictionProvider: 'static + Sized {
     fn name() -> &'static str;
     fn display_name() -> &'static str;

crates/inline_completion_button/Cargo.toml 🔗

@@ -24,13 +24,11 @@ indoc.workspace = true
 inline_completion.workspace = true
 language.workspace = true
 paths.workspace = true
-proto.workspace = true
 regex.workspace = true
 settings.workspace = true
 supermaven.workspace = true
 telemetry.workspace = true
 ui.workspace = true
-util.workspace = true
 workspace-hack.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true

crates/inline_completion_button/src/inline_completion_button.rs 🔗

@@ -2,7 +2,7 @@ use anyhow::Result;
 use client::{UserStore, zed_urls};
 use copilot::{Copilot, Status};
 use editor::{
-    Editor,
+    Editor, SelectionEffects,
     actions::{ShowEditPrediction, ToggleEditPrediction},
     scroll::Autoscroll,
 };
@@ -14,7 +14,6 @@ use gpui::{
     pulsating_between,
 };
 use indoc::indoc;
-use inline_completion::EditPredictionUsage;
 use language::{
     EditPredictionsMode, File, Language,
     language_settings::{self, AllLanguageSettings, EditPredictionProvider, all_language_settings},
@@ -30,7 +29,6 @@ use ui::{
     Clickable, ContextMenu, ContextMenuEntry, DocumentationSide, IconButton, IconButtonShape,
     Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
 };
-use util::maybe;
 use workspace::{
     StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle,
     notifications::NotificationId,
@@ -279,14 +277,31 @@ impl Render for InlineCompletionButton {
                     );
                 }
 
+                let mut over_limit = false;
+
+                if let Some(usage) = self
+                    .edit_prediction_provider
+                    .as_ref()
+                    .and_then(|provider| provider.usage(cx))
+                {
+                    over_limit = usage.over_limit()
+                }
+
                 let show_editor_predictions = self.editor_show_predictions;
 
                 let icon_button = IconButton::new("zed-predict-pending-button", zeta_icon)
                     .shape(IconButtonShape::Square)
-                    .when(enabled && !show_editor_predictions, |this| {
-                        this.indicator(Indicator::dot().color(Color::Muted))
+                    .when(
+                        enabled && (!show_editor_predictions || over_limit),
+                        |this| {
+                            this.indicator(Indicator::dot().when_else(
+                                over_limit,
+                                |dot| dot.color(Color::Error),
+                                |dot| dot.color(Color::Muted),
+                            ))
                             .indicator_border_color(Some(cx.theme().colors().status_bar_background))
-                    })
+                        },
+                    )
                     .when(!self.popover_menu_handle.is_deployed(), |element| {
                         element.tooltip(move |window, cx| {
                             if enabled {
@@ -405,66 +420,6 @@ impl InlineCompletionButton {
         let fs = self.fs.clone();
         let line_height = window.line_height();
 
-        if let Some(provider) = self.edit_prediction_provider.as_ref() {
-            let usage = provider.usage(cx).or_else(|| {
-                let user_store = self.user_store.read(cx);
-
-                maybe!({
-                    let amount = user_store.edit_predictions_usage_amount()?;
-                    let limit = user_store.edit_predictions_usage_limit()?.variant?;
-
-                    Some(EditPredictionUsage {
-                        amount: amount as i32,
-                        limit: match limit {
-                            proto::usage_limit::Variant::Limited(limited) => {
-                                zed_llm_client::UsageLimit::Limited(limited.limit as i32)
-                            }
-                            proto::usage_limit::Variant::Unlimited(_) => {
-                                zed_llm_client::UsageLimit::Unlimited
-                            }
-                        },
-                    })
-                })
-            });
-
-            if let Some(usage) = usage {
-                menu = menu.header("Usage");
-                menu = menu
-                    .custom_entry(
-                        move |_window, cx| {
-                            let used_percentage = match usage.limit {
-                                UsageLimit::Limited(limit) => {
-                                    Some((usage.amount as f32 / limit as f32) * 100.)
-                                }
-                                UsageLimit::Unlimited => None,
-                            };
-
-                            h_flex()
-                                .flex_1()
-                                .gap_1p5()
-                                .children(
-                                    used_percentage.map(|percent| {
-                                        ProgressBar::new("usage", percent, 100., cx)
-                                    }),
-                                )
-                                .child(
-                                    Label::new(match usage.limit {
-                                        UsageLimit::Limited(limit) => {
-                                            format!("{} / {limit}", usage.amount)
-                                        }
-                                        UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
-                                    })
-                                    .size(LabelSize::Small)
-                                    .color(Color::Muted),
-                                )
-                                .into_any_element()
-                        },
-                        move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
-                    )
-                    .separator();
-            }
-        }
-
         menu = menu.header("Show Edit Predictions For");
 
         let language_state = self.language.as_ref().map(|language| {
@@ -740,7 +695,109 @@ impl InlineCompletionButton {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Entity<ContextMenu> {
-        ContextMenu::build(window, cx, |menu, window, cx| {
+        ContextMenu::build(window, cx, |mut menu, window, cx| {
+            if let Some(usage) = self
+                .edit_prediction_provider
+                .as_ref()
+                .and_then(|provider| provider.usage(cx))
+            {
+                menu = menu.header("Usage");
+                menu = menu
+                    .custom_entry(
+                        move |_window, cx| {
+                            let used_percentage = match usage.limit {
+                                UsageLimit::Limited(limit) => {
+                                    Some((usage.amount as f32 / limit as f32) * 100.)
+                                }
+                                UsageLimit::Unlimited => None,
+                            };
+
+                            h_flex()
+                                .flex_1()
+                                .gap_1p5()
+                                .children(
+                                    used_percentage.map(|percent| {
+                                        ProgressBar::new("usage", percent, 100., cx)
+                                    }),
+                                )
+                                .child(
+                                    Label::new(match usage.limit {
+                                        UsageLimit::Limited(limit) => {
+                                            format!("{} / {limit}", usage.amount)
+                                        }
+                                        UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
+                                    })
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                                )
+                                .into_any_element()
+                        },
+                        move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
+                    )
+                    .when(usage.over_limit(), |menu| -> ContextMenu {
+                        menu.entry("Subscribe to increase your limit", None, |_window, cx| {
+                            cx.open_url(&zed_urls::account_url(cx))
+                        })
+                    })
+                    .separator();
+            } else if self.user_store.read(cx).account_too_young() {
+                menu = menu
+                    .custom_entry(
+                        |_window, _cx| {
+                            h_flex()
+                                .gap_1()
+                                .child(
+                                    Icon::new(IconName::Warning)
+                                        .size(IconSize::Small)
+                                        .color(Color::Warning),
+                                )
+                                .child(
+                                    Label::new("Your GitHub account is less than 30 days old")
+                                        .size(LabelSize::Small)
+                                        .color(Color::Warning),
+                                )
+                                .into_any_element()
+                        },
+                        |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
+                    )
+                    .entry(
+                        "You need to upgrade to Zed Pro or contact us.",
+                        None,
+                        |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
+                    )
+                    .separator();
+            } else if self.user_store.read(cx).has_overdue_invoices() {
+                menu = menu
+                    .custom_entry(
+                        |_window, _cx| {
+                            h_flex()
+                                .gap_1()
+                                .child(
+                                    Icon::new(IconName::Warning)
+                                        .size(IconSize::Small)
+                                        .color(Color::Warning),
+                                )
+                                .child(
+                                    Label::new("You have an outstanding invoice")
+                                        .size(LabelSize::Small)
+                                        .color(Color::Warning),
+                                )
+                                .into_any_element()
+                        },
+                        |_window, cx| {
+                            cx.open_url(&zed_urls::account_url(cx))
+                        },
+                    )
+                    .entry(
+                        "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
+                        None,
+                        |_window, cx| {
+                            cx.open_url(&zed_urls::account_url(cx))
+                        },
+                    )
+                    .separator();
+            }
+
             self.build_language_settings_menu(menu, window, cx).when(
                 cx.has_flag::<PredictEditsRateCompletionsFeatureFlag>(),
                 |this| this.action("Rate Completions", RateCompletions.boxed_clone()),
@@ -857,7 +914,7 @@ async fn open_disabled_globs_setting_in_editor(
             });
 
             if !edits.is_empty() {
-                item.edit(edits.iter().cloned(), cx);
+                item.edit(edits, cx);
             }
 
             let text = item.buffer().read(cx).snapshot(cx).text();
@@ -872,9 +929,14 @@ async fn open_disabled_globs_setting_in_editor(
                     .map(|inner_match| inner_match.start()..inner_match.end())
             });
             if let Some(range) = range {
-                item.change_selections(Some(Autoscroll::newest()), window, cx, |selections| {
-                    selections.select_ranges(vec![range]);
-                });
+                item.change_selections(
+                    SelectionEffects::scroll(Autoscroll::newest()),
+                    window,
+                    cx,
+                    |selections| {
+                        selections.select_ranges(vec![range]);
+                    },
+                );
             }
         })?;
 

crates/inspector_ui/Cargo.toml 🔗

@@ -0,0 +1,29 @@
+[package]
+name = "inspector_ui"
+version = "0.1.0"
+publish.workspace = true
+edition.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/inspector_ui.rs"
+
+[dependencies]
+anyhow.workspace = true
+command_palette_hooks.workspace = true
+editor.workspace = true
+fuzzy.workspace = true
+gpui.workspace = true
+language.workspace = true
+project.workspace = true
+serde_json.workspace = true
+serde_json_lenient.workspace = true
+theme.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace-hack.workspace = true
+workspace.workspace = true
+zed_actions.workspace = true

crates/inspector_ui/README.md 🔗

@@ -0,0 +1,112 @@
+# Inspector
+
+This is a tool for inspecting and manipulating rendered elements in Zed. It is only available in debug builds. Use the `dev::ToggleInspector` action to toggle inspector mode and click on UI elements to inspect them.
+
+# Current features
+
+* Picking of elements via the mouse, with scroll wheel to inspect occluded elements.
+
+* Temporary manipulation of the selected element.
+
+* Layout info for `Div`.
+
+* Both Rust and JSON-based style manipulation of `Div` style. The rust style editor only supports argumentless `Styled` and `StyledExt` method calls.
+
+* Navigation to code that constructed the element.
+
+# Known bugs
+
+## JSON style editor undo history doesn't get reset
+
+The JSON style editor appends to its undo stack on every change of the active inspected element.
+
+I attempted to fix it by creating a new buffer and setting the buffer associated with the `json_style_buffer` entity. Unfortunately this doesn't work because the language server uses the `version: clock::Global` to figure out the changes, so would need some way to start the new buffer's text at that version.
+
+```
+        json_style_buffer.update(cx, |json_style_buffer, cx| {
+            let language = json_style_buffer.language().cloned();
+            let file = json_style_buffer.file().cloned();
+
+            *json_style_buffer = Buffer::local("", cx);
+
+            json_style_buffer.set_language(language, cx);
+            if let Some(file) = file {
+                json_style_buffer.file_updated(file, cx);
+            }
+        });
+```
+
+# Future features
+
+* Action and keybinding for entering pick mode.
+
+* Ability to highlight current element after it's been picked.
+
+* Info and manipulation of element types other than `Div`.
+
+* Indicate when the picked element has disappeared.
+
+* To inspect elements that disappear, it would be helpful to be able to pause the UI.
+
+* Hierarchy view?
+
+## Methods that take arguments in Rust style editor
+
+Could use TreeSitter to parse out the fluent style method chain and arguments. Tricky part of this is completions - ideally the Rust Analyzer already being used by the developer's Zed would be used.
+
+## Edit original code in Rust style editor
+
+Two approaches:
+
+1. Open an excerpt of the original file.
+
+2. Communicate with the Zed process that has the repo open - it would send the code for the element. This seems like a lot of work, but would be very nice for rapid development, and it would allow use of rust analyzer.
+
+With both approaches, would need to record the buffer version and use that when referring to source locations, since editing elements can cause code layout shift.
+
+## Source location UI improvements
+
+* Mode to navigate to source code on every element change while picking.
+
+* Tracking of more source locations - currently the source location is often in a ui compoenent. Ideally this would have a way for the components to indicate that they are probably not the source location the user is looking for.
+
+  - Could have `InspectorElementId` be `Vec<(ElementId, Option<Location>)>`, but if there are multiple code paths that construct the same element this would cause them to be considered different.
+
+  - Probably better to have a separate `Vec<Option<Location>>` that uses the same indices as `GlobalElementId`.
+
+## Persistent modification
+
+Currently, element modifications disappear when picker mode is started. Handling this well is tricky. Potential features:
+
+* Support modifying multiple elements at once. This requires a way to specify which elements are modified - possibly wildcards in a match of the `InspectorElementId` path. This might default to ignoring all numeric parts and just matching on the names.
+
+* Show a list of active modifications in the UI.
+
+* Support for modifications being partial overrides instead of snapshots. A trickiness here is that multiple modifications may apply to the same element.
+
+* The code should probably distinguish the data that is provided by the element and the modifications from the inspector. Currently these are conflated in element states.
+
+If support is added for editing original code, then the logical selector in this case would be just matches of the source path.
+
+# Code cleanups
+
+## Consider removing special side pane rendering
+
+Currently the inspector has special rendering in the UI, but maybe it could just be a workspace item.
+
+## Pull more inspector logic out of GPUI
+
+Currently `crates/gpui/inspector.rs` and `crates/inspector_ui/inspector.rs` are quite entangled.  It seems cleaner to pull as much logic a possible out of GPUI.
+
+## Cleaner lifecycle for inspector state viewers / editors
+
+Currently element state inspectors are just called on render. Ideally instead they would be implementors of some trait like:
+
+```
+trait StateInspector: Render {
+    fn new(cx: &mut App) -> Task<Self>;
+    fn element_changed(inspector_id: &InspectorElementId, window: &mut Window, cx: &mut App);
+}
+```
+
+See `div_inspector.rs` - it needs to initialize itself, keep track of its own loading state, and keep track of the last inspected ID in its render function.

crates/inspector_ui/build.rs 🔗

@@ -0,0 +1,20 @@
+fn main() {
+    let cargo_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
+    let mut path = std::path::PathBuf::from(&cargo_manifest_dir);
+
+    if path.file_name().as_ref().and_then(|name| name.to_str()) != Some("inspector_ui") {
+        panic!(
+            "expected CARGO_MANIFEST_DIR to end with crates/inspector_ui, but got {cargo_manifest_dir}"
+        );
+    }
+    path.pop();
+
+    if path.file_name().as_ref().and_then(|name| name.to_str()) != Some("crates") {
+        panic!(
+            "expected CARGO_MANIFEST_DIR to end with crates/inspector_ui, but got {cargo_manifest_dir}"
+        );
+    }
+    path.pop();
+
+    println!("cargo:rustc-env=ZED_REPO_DIR={}", path.display());
+}

crates/inspector_ui/src/div_inspector.rs 🔗

@@ -0,0 +1,723 @@
+use anyhow::{Result, anyhow};
+use editor::{Bias, CompletionProvider, Editor, EditorEvent, EditorMode, ExcerptId, MultiBuffer};
+use fuzzy::StringMatch;
+use gpui::{
+    AsyncWindowContext, DivInspectorState, Entity, InspectorElementId, IntoElement,
+    StyleRefinement, Task, Window, inspector_reflection::FunctionReflection, styled_reflection,
+};
+use language::language_settings::SoftWrap;
+use language::{
+    Anchor, Buffer, BufferSnapshot, CodeLabel, Diagnostic, DiagnosticEntry, DiagnosticSet,
+    DiagnosticSeverity, LanguageServerId, Point, ToOffset as _, ToPoint as _,
+};
+use project::lsp_store::CompletionDocumentation;
+use project::{Completion, CompletionResponse, CompletionSource, Project, ProjectPath};
+use std::fmt::Write as _;
+use std::ops::Range;
+use std::path::Path;
+use std::rc::Rc;
+use std::sync::LazyLock;
+use ui::{Label, LabelSize, Tooltip, prelude::*, styled_ext_reflection, v_flex};
+use util::split_str_with_ranges;
+
+/// Path used for unsaved buffer that contains style json. To support the json language server, this
+/// matches the name used in the generated schemas.
+const ZED_INSPECTOR_STYLE_JSON: &str = "/zed-inspector-style.json";
+
+pub(crate) struct DivInspector {
+    state: State,
+    project: Entity<Project>,
+    inspector_id: Option<InspectorElementId>,
+    inspector_state: Option<DivInspectorState>,
+    /// Value of `DivInspectorState.base_style` when initially picked.
+    initial_style: StyleRefinement,
+    /// Portion of `initial_style` that can't be converted to rust code.
+    unconvertible_style: StyleRefinement,
+    /// Edits the user has made to the json buffer: `json_editor - (unconvertible_style + rust_editor)`.
+    json_style_overrides: StyleRefinement,
+    /// Error to display from parsing the json, or if serialization errors somehow occur.
+    json_style_error: Option<SharedString>,
+    /// Currently selected completion.
+    rust_completion: Option<String>,
+    /// Range that will be replaced by the completion if selected.
+    rust_completion_replace_range: Option<Range<Anchor>>,
+}
+
+enum State {
+    Loading,
+    BuffersLoaded {
+        rust_style_buffer: Entity<Buffer>,
+        json_style_buffer: Entity<Buffer>,
+    },
+    Ready {
+        rust_style_buffer: Entity<Buffer>,
+        rust_style_editor: Entity<Editor>,
+        json_style_buffer: Entity<Buffer>,
+        json_style_editor: Entity<Editor>,
+    },
+    LoadError {
+        message: SharedString,
+    },
+}
+
+impl DivInspector {
+    pub fn new(
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> DivInspector {
+        // Open the buffers once, so they can then be used for each editor.
+        cx.spawn_in(window, {
+            let languages = project.read(cx).languages().clone();
+            let project = project.clone();
+            async move |this, cx| {
+                // Open the JSON style buffer in the inspector-specific project, so that it runs the
+                // JSON language server.
+                let json_style_buffer =
+                    Self::create_buffer_in_project(ZED_INSPECTOR_STYLE_JSON, &project, cx).await;
+
+                // Create Rust style buffer without adding it to the project / buffer_store, so that
+                // Rust Analyzer doesn't get started for it.
+                let rust_language_result = languages.language_for_name("Rust").await;
+                let rust_style_buffer = rust_language_result.and_then(|rust_language| {
+                    cx.new(|cx| Buffer::local("", cx).with_language(rust_language, cx))
+                });
+
+                match json_style_buffer.and_then(|json_style_buffer| {
+                    rust_style_buffer
+                        .map(|rust_style_buffer| (json_style_buffer, rust_style_buffer))
+                }) {
+                    Ok((json_style_buffer, rust_style_buffer)) => {
+                        this.update_in(cx, |this, window, cx| {
+                            this.state = State::BuffersLoaded {
+                                json_style_buffer: json_style_buffer,
+                                rust_style_buffer: rust_style_buffer,
+                            };
+
+                            // Initialize editors immediately instead of waiting for
+                            // `update_inspected_element`. This avoids continuing to show
+                            // "Loading..." until the user moves the mouse to a different element.
+                            if let Some(id) = this.inspector_id.take() {
+                                let inspector_state =
+                                    window.with_inspector_state(Some(&id), cx, |state, _window| {
+                                        state.clone()
+                                    });
+                                if let Some(inspector_state) = inspector_state {
+                                    this.update_inspected_element(&id, inspector_state, window, cx);
+                                    cx.notify();
+                                }
+                            }
+                        })
+                        .ok();
+                    }
+                    Err(err) => {
+                        this.update(cx, |this, _cx| {
+                            this.state = State::LoadError {
+                                message: format!(
+                                    "Failed to create buffers for style editing: {err}"
+                                )
+                                .into(),
+                            };
+                        })
+                        .ok();
+                    }
+                }
+            }
+        })
+        .detach();
+
+        DivInspector {
+            state: State::Loading,
+            project,
+            inspector_id: None,
+            inspector_state: None,
+            initial_style: StyleRefinement::default(),
+            unconvertible_style: StyleRefinement::default(),
+            json_style_overrides: StyleRefinement::default(),
+            rust_completion: None,
+            rust_completion_replace_range: None,
+            json_style_error: None,
+        }
+    }
+
+    pub fn update_inspected_element(
+        &mut self,
+        id: &InspectorElementId,
+        inspector_state: DivInspectorState,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let style = (*inspector_state.base_style).clone();
+        self.inspector_state = Some(inspector_state);
+
+        if self.inspector_id.as_ref() == Some(id) {
+            return;
+        }
+
+        self.inspector_id = Some(id.clone());
+        self.initial_style = style.clone();
+
+        let (rust_style_buffer, json_style_buffer) = match &self.state {
+            State::BuffersLoaded {
+                rust_style_buffer,
+                json_style_buffer,
+            }
+            | State::Ready {
+                rust_style_buffer,
+                json_style_buffer,
+                ..
+            } => (rust_style_buffer.clone(), json_style_buffer.clone()),
+            State::Loading | State::LoadError { .. } => return,
+        };
+
+        let json_style_editor = self.create_editor(json_style_buffer.clone(), window, cx);
+        let rust_style_editor = self.create_editor(rust_style_buffer.clone(), window, cx);
+
+        rust_style_editor.update(cx, {
+            let div_inspector = cx.entity();
+            |rust_style_editor, _cx| {
+                rust_style_editor.set_completion_provider(Some(Rc::new(
+                    RustStyleCompletionProvider { div_inspector },
+                )));
+            }
+        });
+
+        let rust_style = match self.reset_style_editors(&rust_style_buffer, &json_style_buffer, cx)
+        {
+            Ok(rust_style) => {
+                self.json_style_error = None;
+                rust_style
+            }
+            Err(err) => {
+                self.json_style_error = Some(format!("{err}").into());
+                return;
+            }
+        };
+
+        cx.subscribe_in(&json_style_editor, window, {
+            let id = id.clone();
+            let rust_style_buffer = rust_style_buffer.clone();
+            move |this, editor, event: &EditorEvent, window, cx| match event {
+                EditorEvent::BufferEdited => {
+                    let style_json = editor.read(cx).text(cx);
+                    match serde_json_lenient::from_str_lenient::<StyleRefinement>(&style_json) {
+                        Ok(new_style) => {
+                            let (rust_style, _) = this.style_from_rust_buffer_snapshot(
+                                &rust_style_buffer.read(cx).snapshot(),
+                            );
+
+                            let mut unconvertible_plus_rust = this.unconvertible_style.clone();
+                            unconvertible_plus_rust.refine(&rust_style);
+
+                            // The serialization of `DefiniteLength::Fraction` does not perfectly
+                            // roundtrip because with f32, `(x / 100.0 * 100.0) == x` is not always
+                            // true (such as for `p_1_3`). This can cause these values to
+                            // erroneously appear in `json_style_overrides` since they are not
+                            // perfectly equal. Roundtripping before `subtract` fixes this.
+                            unconvertible_plus_rust =
+                                serde_json::to_string(&unconvertible_plus_rust)
+                                    .ok()
+                                    .and_then(|json| {
+                                        serde_json_lenient::from_str_lenient(&json).ok()
+                                    })
+                                    .unwrap_or(unconvertible_plus_rust);
+
+                            this.json_style_overrides =
+                                new_style.subtract(&unconvertible_plus_rust);
+
+                            window.with_inspector_state::<DivInspectorState, _>(
+                                Some(&id),
+                                cx,
+                                |inspector_state, _window| {
+                                    if let Some(inspector_state) = inspector_state.as_mut() {
+                                        *inspector_state.base_style = new_style;
+                                    }
+                                },
+                            );
+                            window.refresh();
+                            this.json_style_error = None;
+                        }
+                        Err(err) => this.json_style_error = Some(err.to_string().into()),
+                    }
+                }
+                _ => {}
+            }
+        })
+        .detach();
+
+        cx.subscribe(&rust_style_editor, {
+            let json_style_buffer = json_style_buffer.clone();
+            let rust_style_buffer = rust_style_buffer.clone();
+            move |this, _editor, event: &EditorEvent, cx| match event {
+                EditorEvent::BufferEdited => {
+                    this.update_json_style_from_rust(&json_style_buffer, &rust_style_buffer, cx);
+                }
+                _ => {}
+            }
+        })
+        .detach();
+
+        self.unconvertible_style = style.subtract(&rust_style);
+        self.json_style_overrides = StyleRefinement::default();
+        self.state = State::Ready {
+            rust_style_buffer,
+            rust_style_editor,
+            json_style_buffer,
+            json_style_editor,
+        };
+    }
+
+    fn reset_style(&mut self, cx: &mut App) {
+        match &self.state {
+            State::Ready {
+                rust_style_buffer,
+                json_style_buffer,
+                ..
+            } => {
+                if let Err(err) = self.reset_style_editors(
+                    &rust_style_buffer.clone(),
+                    &json_style_buffer.clone(),
+                    cx,
+                ) {
+                    self.json_style_error = Some(format!("{err}").into());
+                } else {
+                    self.json_style_error = None;
+                }
+            }
+            _ => {}
+        }
+    }
+
+    fn reset_style_editors(
+        &self,
+        rust_style_buffer: &Entity<Buffer>,
+        json_style_buffer: &Entity<Buffer>,
+        cx: &mut App,
+    ) -> Result<StyleRefinement> {
+        let json_text = match serde_json::to_string_pretty(&self.initial_style) {
+            Ok(json_text) => json_text,
+            Err(err) => {
+                return Err(anyhow!("Failed to convert style to JSON: {err}"));
+            }
+        };
+
+        let (rust_code, rust_style) = guess_rust_code_from_style(&self.initial_style);
+        rust_style_buffer.update(cx, |rust_style_buffer, cx| {
+            rust_style_buffer.set_text(rust_code, cx);
+            let snapshot = rust_style_buffer.snapshot();
+            let (_, unrecognized_ranges) = self.style_from_rust_buffer_snapshot(&snapshot);
+            Self::set_rust_buffer_diagnostics(
+                unrecognized_ranges,
+                rust_style_buffer,
+                &snapshot,
+                cx,
+            );
+        });
+        json_style_buffer.update(cx, |json_style_buffer, cx| {
+            json_style_buffer.set_text(json_text, cx);
+        });
+
+        Ok(rust_style)
+    }
+
+    fn handle_rust_completion_selection_change(
+        &mut self,
+        rust_completion: Option<String>,
+        cx: &mut Context<Self>,
+    ) {
+        self.rust_completion = rust_completion;
+        if let State::Ready {
+            rust_style_buffer,
+            json_style_buffer,
+            ..
+        } = &self.state
+        {
+            self.update_json_style_from_rust(
+                &json_style_buffer.clone(),
+                &rust_style_buffer.clone(),
+                cx,
+            );
+        }
+    }
+
+    fn update_json_style_from_rust(
+        &mut self,
+        json_style_buffer: &Entity<Buffer>,
+        rust_style_buffer: &Entity<Buffer>,
+        cx: &mut Context<Self>,
+    ) {
+        let rust_style = rust_style_buffer.update(cx, |rust_style_buffer, cx| {
+            let snapshot = rust_style_buffer.snapshot();
+            let (rust_style, unrecognized_ranges) = self.style_from_rust_buffer_snapshot(&snapshot);
+            Self::set_rust_buffer_diagnostics(
+                unrecognized_ranges,
+                rust_style_buffer,
+                &snapshot,
+                cx,
+            );
+            rust_style
+        });
+
+        // Preserve parts of the json style which do not come from the unconvertible style or rust
+        // style. This way user edits to the json style are preserved when they are not overridden
+        // by the rust style.
+        //
+        // This results in a behavior where user changes to the json style that do overlap with the
+        // rust style will get set to the rust style when the user edits the rust style. It would be
+        // possible to update the rust style when the json style changes, but this is undesirable
+        // as the user may be working on the actual code in the rust style.
+        let mut new_style = self.unconvertible_style.clone();
+        new_style.refine(&self.json_style_overrides);
+        let new_style = new_style.refined(rust_style);
+
+        match serde_json::to_string_pretty(&new_style) {
+            Ok(json) => {
+                json_style_buffer.update(cx, |json_style_buffer, cx| {
+                    json_style_buffer.set_text(json, cx);
+                });
+            }
+            Err(err) => {
+                self.json_style_error = Some(err.to_string().into());
+            }
+        }
+    }
+
+    fn style_from_rust_buffer_snapshot(
+        &self,
+        snapshot: &BufferSnapshot,
+    ) -> (StyleRefinement, Vec<Range<Anchor>>) {
+        let method_names = if let Some((completion, completion_range)) = self
+            .rust_completion
+            .as_ref()
+            .zip(self.rust_completion_replace_range.as_ref())
+        {
+            let before_text = snapshot
+                .text_for_range(0..completion_range.start.to_offset(&snapshot))
+                .collect::<String>();
+            let after_text = snapshot
+                .text_for_range(
+                    completion_range.end.to_offset(&snapshot)
+                        ..snapshot.clip_offset(usize::MAX, Bias::Left),
+                )
+                .collect::<String>();
+            let mut method_names = split_str_with_ranges(&before_text, is_not_identifier_char)
+                .into_iter()
+                .map(|(range, name)| (Some(range), name.to_string()))
+                .collect::<Vec<_>>();
+            method_names.push((None, completion.clone()));
+            method_names.extend(
+                split_str_with_ranges(&after_text, is_not_identifier_char)
+                    .into_iter()
+                    .map(|(range, name)| (Some(range), name.to_string())),
+            );
+            method_names
+        } else {
+            split_str_with_ranges(&snapshot.text(), is_not_identifier_char)
+                .into_iter()
+                .map(|(range, name)| (Some(range), name.to_string()))
+                .collect::<Vec<_>>()
+        };
+
+        let mut style = StyleRefinement::default();
+        let mut unrecognized_ranges = Vec::new();
+        for (range, name) in method_names {
+            if let Some((_, method)) = STYLE_METHODS.iter().find(|(_, m)| m.name == name) {
+                style = method.invoke(style);
+            } else if let Some(range) = range {
+                unrecognized_ranges
+                    .push(snapshot.anchor_before(range.start)..snapshot.anchor_before(range.end));
+            }
+        }
+
+        (style, unrecognized_ranges)
+    }
+
+    fn set_rust_buffer_diagnostics(
+        unrecognized_ranges: Vec<Range<Anchor>>,
+        rust_style_buffer: &mut Buffer,
+        snapshot: &BufferSnapshot,
+        cx: &mut Context<Buffer>,
+    ) {
+        let diagnostic_entries = unrecognized_ranges
+            .into_iter()
+            .enumerate()
+            .map(|(ix, range)| DiagnosticEntry {
+                range,
+                diagnostic: Diagnostic {
+                    message: "unrecognized".to_string(),
+                    severity: DiagnosticSeverity::WARNING,
+                    is_primary: true,
+                    group_id: ix,
+                    ..Default::default()
+                },
+            });
+        let diagnostics = DiagnosticSet::from_sorted_entries(diagnostic_entries, snapshot);
+        rust_style_buffer.update_diagnostics(LanguageServerId(0), diagnostics, cx);
+    }
+
+    async fn create_buffer_in_project(
+        path: impl AsRef<Path>,
+        project: &Entity<Project>,
+        cx: &mut AsyncWindowContext,
+    ) -> Result<Entity<Buffer>> {
+        let worktree = project
+            .update(cx, |project, cx| project.create_worktree(path, false, cx))?
+            .await?;
+
+        let project_path = worktree.read_with(cx, |worktree, _cx| ProjectPath {
+            worktree_id: worktree.id(),
+            path: Path::new("").into(),
+        })?;
+
+        let buffer = project
+            .update(cx, |project, cx| project.open_path(project_path, cx))?
+            .await?
+            .1;
+
+        Ok(buffer)
+    }
+
+    fn create_editor(
+        &self,
+        buffer: Entity<Buffer>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Entity<Editor> {
+        cx.new(|cx| {
+            let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+            let mut editor = Editor::new(
+                EditorMode::full(),
+                multi_buffer,
+                Some(self.project.clone()),
+                window,
+                cx,
+            );
+            editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
+            editor.set_show_line_numbers(false, cx);
+            editor.set_show_code_actions(false, cx);
+            editor.set_show_breakpoints(false, cx);
+            editor.set_show_git_diff_gutter(false, cx);
+            editor.set_show_runnables(false, cx);
+            editor.set_show_edit_predictions(Some(false), window, cx);
+            editor
+        })
+    }
+}
+
+impl Render for DivInspector {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex()
+            .size_full()
+            .gap_2()
+            .when_some(self.inspector_state.as_ref(), |this, inspector_state| {
+                this.child(
+                    v_flex()
+                        .child(Label::new("Layout").size(LabelSize::Large))
+                        .child(render_layout_state(inspector_state, cx)),
+                )
+            })
+            .map(|this| match &self.state {
+                State::Loading | State::BuffersLoaded { .. } => {
+                    this.child(Label::new("Loading..."))
+                }
+                State::LoadError { message } => this.child(
+                    div()
+                        .w_full()
+                        .border_1()
+                        .border_color(Color::Error.color(cx))
+                        .child(Label::new(message)),
+                ),
+                State::Ready {
+                    rust_style_editor,
+                    json_style_editor,
+                    ..
+                } => this
+                    .child(
+                        v_flex()
+                            .gap_2()
+                            .child(
+                                h_flex()
+                                    .justify_between()
+                                    .child(Label::new("Rust Style").size(LabelSize::Large))
+                                    .child(
+                                        IconButton::new("reset-style", IconName::Eraser)
+                                            .tooltip(Tooltip::text("Reset style"))
+                                            .on_click(cx.listener(|this, _, _window, cx| {
+                                                this.reset_style(cx);
+                                            })),
+                                    ),
+                            )
+                            .child(div().h_64().child(rust_style_editor.clone())),
+                    )
+                    .child(
+                        v_flex()
+                            .gap_2()
+                            .child(Label::new("JSON Style").size(LabelSize::Large))
+                            .child(div().h_128().child(json_style_editor.clone()))
+                            .when_some(self.json_style_error.as_ref(), |this, last_error| {
+                                this.child(
+                                    div()
+                                        .w_full()
+                                        .border_1()
+                                        .border_color(Color::Error.color(cx))
+                                        .child(Label::new(last_error)),
+                                )
+                            }),
+                    ),
+            })
+            .into_any_element()
+    }
+}
+
+fn render_layout_state(inspector_state: &DivInspectorState, cx: &App) -> Div {
+    v_flex()
+        .child(
+            div()
+                .text_ui(cx)
+                .child(format!("Bounds: {}", inspector_state.bounds)),
+        )
+        .child(
+            div()
+                .id("content-size")
+                .text_ui(cx)
+                .tooltip(Tooltip::text("Size of the element's children"))
+                .child(
+                    if inspector_state.content_size != inspector_state.bounds.size {
+                        format!("Content size: {}", inspector_state.content_size)
+                    } else {
+                        "".to_string()
+                    },
+                ),
+        )
+}
+
+static STYLE_METHODS: LazyLock<Vec<(Box<StyleRefinement>, FunctionReflection<StyleRefinement>)>> =
+    LazyLock::new(|| {
+        // Include StyledExt methods first so that those methods take precedence.
+        styled_ext_reflection::methods::<StyleRefinement>()
+            .into_iter()
+            .chain(styled_reflection::methods::<StyleRefinement>())
+            .map(|method| (Box::new(method.invoke(StyleRefinement::default())), method))
+            .collect()
+    });
+
+fn guess_rust_code_from_style(goal_style: &StyleRefinement) -> (String, StyleRefinement) {
+    let mut subset_methods = Vec::new();
+    for (style, method) in STYLE_METHODS.iter() {
+        if goal_style.is_superset_of(style) {
+            subset_methods.push(method);
+        }
+    }
+
+    let mut code = "fn build() -> Div {\n    div()".to_string();
+    let mut style = StyleRefinement::default();
+    for method in subset_methods {
+        let before_change = style.clone();
+        style = method.invoke(style);
+        if before_change != style {
+            let _ = write!(code, "\n        .{}()", &method.name);
+        }
+    }
+    code.push_str("\n}");
+
+    (code, style)
+}
+
+fn is_not_identifier_char(c: char) -> bool {
+    !c.is_alphanumeric() && c != '_'
+}
+
+struct RustStyleCompletionProvider {
+    div_inspector: Entity<DivInspector>,
+}
+
+impl CompletionProvider for RustStyleCompletionProvider {
+    fn completions(
+        &self,
+        _excerpt_id: ExcerptId,
+        buffer: &Entity<Buffer>,
+        position: Anchor,
+        _: editor::CompletionContext,
+        _window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) -> Task<Result<Vec<CompletionResponse>>> {
+        let Some(replace_range) = completion_replace_range(&buffer.read(cx).snapshot(), &position)
+        else {
+            return Task::ready(Ok(Vec::new()));
+        };
+
+        self.div_inspector.update(cx, |div_inspector, _cx| {
+            div_inspector.rust_completion_replace_range = Some(replace_range.clone());
+        });
+
+        Task::ready(Ok(vec![CompletionResponse {
+            completions: STYLE_METHODS
+                .iter()
+                .map(|(_, method)| Completion {
+                    replace_range: replace_range.clone(),
+                    new_text: format!(".{}()", method.name),
+                    label: CodeLabel::plain(method.name.to_string(), None),
+                    icon_path: None,
+                    documentation: method.documentation.map(|documentation| {
+                        CompletionDocumentation::MultiLineMarkdown(documentation.into())
+                    }),
+                    source: CompletionSource::Custom,
+                    insert_text_mode: None,
+                    confirm: None,
+                })
+                .collect(),
+            is_incomplete: false,
+        }]))
+    }
+
+    fn is_completion_trigger(
+        &self,
+        buffer: &Entity<language::Buffer>,
+        position: language::Anchor,
+        _text: &str,
+        _trigger_in_words: bool,
+        _menu_is_open: bool,
+        cx: &mut Context<Editor>,
+    ) -> bool {
+        completion_replace_range(&buffer.read(cx).snapshot(), &position).is_some()
+    }
+
+    fn selection_changed(&self, mat: Option<&StringMatch>, _window: &mut Window, cx: &mut App) {
+        let div_inspector = self.div_inspector.clone();
+        let rust_completion = mat.as_ref().map(|mat| mat.string.clone());
+        cx.defer(move |cx| {
+            div_inspector.update(cx, |div_inspector, cx| {
+                div_inspector.handle_rust_completion_selection_change(rust_completion, cx);
+            });
+        });
+    }
+
+    fn sort_completions(&self) -> bool {
+        false
+    }
+}
+
+fn completion_replace_range(snapshot: &BufferSnapshot, anchor: &Anchor) -> Option<Range<Anchor>> {
+    let point = anchor.to_point(&snapshot);
+    let offset = point.to_offset(&snapshot);
+    let line_start = Point::new(point.row, 0).to_offset(&snapshot);
+    let line_end = Point::new(point.row, snapshot.line_len(point.row)).to_offset(&snapshot);
+    let mut lines = snapshot.text_for_range(line_start..line_end).lines();
+    let line = lines.next()?;
+
+    let start_in_line = &line[..offset - line_start]
+        .rfind(|c| is_not_identifier_char(c) && c != '.')
+        .map(|ix| ix + 1)
+        .unwrap_or(0);
+    let end_in_line = &line[offset - line_start..]
+        .rfind(|c| is_not_identifier_char(c) && c != '(' && c != ')')
+        .unwrap_or(line_end - line_start);
+
+    if end_in_line > start_in_line {
+        let replace_start = snapshot.anchor_before(line_start + start_in_line);
+        let replace_end = snapshot.anchor_after(line_start + end_in_line);
+        Some(replace_start..replace_end)
+    } else {
+        None
+    }
+}

crates/inspector_ui/src/inspector.rs 🔗

@@ -0,0 +1,174 @@
+use anyhow::{Context as _, anyhow};
+use gpui::{App, DivInspectorState, Inspector, InspectorElementId, IntoElement, Window};
+use std::{cell::OnceCell, path::Path, sync::Arc};
+use ui::{Label, Tooltip, prelude::*};
+use util::{ResultExt as _, command::new_smol_command};
+use workspace::AppState;
+
+use crate::div_inspector::DivInspector;
+
+pub fn init(app_state: Arc<AppState>, cx: &mut App) {
+    cx.on_action(|_: &zed_actions::dev::ToggleInspector, cx| {
+        let Some(active_window) = cx
+            .active_window()
+            .context("no active window to toggle inspector")
+            .log_err()
+        else {
+            return;
+        };
+        // This is deferred to avoid double lease due to window already being updated.
+        cx.defer(move |cx| {
+            active_window
+                .update(cx, |_, window, cx| window.toggle_inspector(cx))
+                .log_err();
+        });
+    });
+
+    // Project used for editor buffers with LSP support
+    let project = 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,
+        cx,
+    );
+
+    let div_inspector = OnceCell::new();
+    cx.register_inspector_element(move |id, state: &DivInspectorState, window, cx| {
+        let div_inspector = div_inspector
+            .get_or_init(|| cx.new(|cx| DivInspector::new(project.clone(), window, cx)));
+        div_inspector.update(cx, |div_inspector, cx| {
+            div_inspector.update_inspected_element(&id, state.clone(), window, cx);
+            div_inspector.render(window, cx).into_any_element()
+        })
+    });
+
+    cx.set_inspector_renderer(Box::new(render_inspector));
+}
+
+fn render_inspector(
+    inspector: &mut Inspector,
+    window: &mut Window,
+    cx: &mut Context<Inspector>,
+) -> AnyElement {
+    let ui_font = theme::setup_ui_font(window, cx);
+    let colors = cx.theme().colors();
+    let inspector_id = inspector.active_element_id();
+    v_flex()
+        .size_full()
+        .bg(colors.panel_background)
+        .text_color(colors.text)
+        .font(ui_font)
+        .border_l_1()
+        .border_color(colors.border)
+        .child(
+            h_flex()
+                .p_2()
+                .border_b_1()
+                .border_color(colors.border_variant)
+                .child(
+                    IconButton::new("pick-mode", IconName::MagnifyingGlass)
+                        .tooltip(Tooltip::text("Start inspector pick mode"))
+                        .selected_icon_color(Color::Selected)
+                        .toggle_state(inspector.is_picking())
+                        .on_click(cx.listener(|inspector, _, window, _cx| {
+                            inspector.start_picking();
+                            window.refresh();
+                        })),
+                )
+                .child(
+                    h_flex()
+                        .w_full()
+                        .justify_end()
+                        .child(Label::new("GPUI Inspector").size(LabelSize::Large)),
+                ),
+        )
+        .child(
+            v_flex()
+                .id("gpui-inspector-content")
+                .overflow_y_scroll()
+                .p_2()
+                .gap_2()
+                .when_some(inspector_id, |this, inspector_id| {
+                    this.child(render_inspector_id(inspector_id, cx))
+                })
+                .children(inspector.render_inspector_states(window, cx)),
+        )
+        .into_any_element()
+}
+
+fn render_inspector_id(inspector_id: &InspectorElementId, cx: &App) -> Div {
+    let source_location = inspector_id.path.source_location;
+    // For unknown reasons, for some elements the path is absolute.
+    let source_location_string = source_location.to_string();
+    let source_location_string = source_location_string
+        .strip_prefix(env!("ZED_REPO_DIR"))
+        .and_then(|s| s.strip_prefix("/"))
+        .map(|s| s.to_string())
+        .unwrap_or(source_location_string);
+
+    v_flex()
+        .child(Label::new("Element ID").size(LabelSize::Large))
+        .child(
+            div()
+                .id("instance-id")
+                .text_ui(cx)
+                .tooltip(Tooltip::text(
+                    "Disambiguates elements from the same source location",
+                ))
+                .child(format!("Instance {}", inspector_id.instance_id)),
+        )
+        .child(
+            div()
+                .id("source-location")
+                .text_ui(cx)
+                .bg(cx.theme().colors().editor_foreground.opacity(0.025))
+                .underline()
+                .child(source_location_string)
+                .tooltip(Tooltip::text("Click to open by running zed cli"))
+                .on_click(move |_, _window, cx| {
+                    cx.background_spawn(open_zed_source_location(source_location))
+                        .detach_and_log_err(cx);
+                }),
+        )
+        .child(
+            div()
+                .id("global-id")
+                .text_ui(cx)
+                .min_h_20()
+                .tooltip(Tooltip::text(
+                    "GlobalElementId of the nearest ancestor with an ID",
+                ))
+                .child(inspector_id.path.global_id.to_string()),
+        )
+}
+
+async fn open_zed_source_location(
+    location: &'static std::panic::Location<'static>,
+) -> anyhow::Result<()> {
+    let mut path = Path::new(env!("ZED_REPO_DIR")).to_path_buf();
+    path.push(Path::new(location.file()));
+    let path_arg = format!(
+        "{}:{}:{}",
+        path.display(),
+        location.line(),
+        location.column()
+    );
+
+    let output = new_smol_command("zed")
+        .arg(&path_arg)
+        .output()
+        .await
+        .with_context(|| format!("running zed to open {path_arg} failed"))?;
+
+    if !output.status.success() {
+        Err(anyhow!(
+            "running zed to open {path_arg} failed with stderr: {}",
+            String::from_utf8_lossy(&output.stderr)
+        ))
+    } else {
+        Ok(())
+    }
+}

crates/inspector_ui/src/inspector_ui.rs 🔗

@@ -0,0 +1,24 @@
+#[cfg(debug_assertions)]
+mod div_inspector;
+#[cfg(debug_assertions)]
+mod inspector;
+
+#[cfg(debug_assertions)]
+pub use inspector::init;
+
+#[cfg(not(debug_assertions))]
+pub fn init(_app_state: std::sync::Arc<workspace::AppState>, cx: &mut gpui::App) {
+    use std::any::TypeId;
+    use workspace::notifications::NotifyResultExt as _;
+
+    cx.on_action(|_: &zed_actions::dev::ToggleInspector, cx| {
+        Err::<(), anyhow::Error>(anyhow::anyhow!(
+            "dev::ToggleInspector is only available in debug builds"
+        ))
+        .notify_app_err(cx);
+    });
+
+    command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _cx| {
+        filter.hide_action_types(&[TypeId::of::<zed_actions::dev::ToggleInspector>()]);
+    });
+}

crates/install_cli/src/install_cli.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use client::ZED_URL_SCHEME;
 use gpui::{AppContext as _, AsyncApp, Context, PromptLevel, Window, actions};
 use release_channel::ReleaseChannel;
@@ -55,11 +55,8 @@ async fn install_script(cx: &AsyncApp) -> Result<PathBuf> {
         .output()
         .await?
         .status;
-    if status.success() {
-        Ok(link_path.into())
-    } else {
-        Err(anyhow!("error running osascript"))
-    }
+    anyhow::ensure!(status.success(), "error running osascript");
+    Ok(link_path.into())
 }
 
 pub async fn register_zed_scheme(cx: &AsyncApp) -> anyhow::Result<()> {

crates/jj/Cargo.toml 🔗

@@ -0,0 +1,18 @@
+[package]
+name = "jj"
+version = "0.1.0"
+publish.workspace = true
+edition.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/jj.rs"
+
+[dependencies]
+anyhow.workspace = true
+gpui.workspace = true
+jj-lib.workspace = true
+workspace-hack.workspace = true

crates/jj/src/jj.rs 🔗

@@ -0,0 +1,5 @@
+mod jj_repository;
+mod jj_store;
+
+pub use jj_repository::*;
+pub use jj_store::*;

crates/jj/src/jj_repository.rs 🔗

@@ -0,0 +1,72 @@
+use std::path::Path;
+use std::sync::Arc;
+
+use anyhow::Result;
+use gpui::SharedString;
+use jj_lib::config::StackedConfig;
+use jj_lib::repo::StoreFactories;
+use jj_lib::settings::UserSettings;
+use jj_lib::workspace::{self, DefaultWorkspaceLoaderFactory, WorkspaceLoaderFactory};
+
+#[derive(Debug, Clone)]
+pub struct Bookmark {
+    pub ref_name: SharedString,
+}
+
+pub trait JujutsuRepository: Send + Sync {
+    fn list_bookmarks(&self) -> Vec<Bookmark>;
+}
+
+pub struct RealJujutsuRepository {
+    repository: Arc<jj_lib::repo::ReadonlyRepo>,
+}
+
+impl RealJujutsuRepository {
+    pub fn new(cwd: &Path) -> Result<Self> {
+        let workspace_loader_factory = DefaultWorkspaceLoaderFactory;
+        let workspace_loader = workspace_loader_factory.create(Self::find_workspace_dir(cwd))?;
+
+        let config = StackedConfig::with_defaults();
+        let settings = UserSettings::from_config(config)?;
+
+        let workspace = workspace_loader.load(
+            &settings,
+            &StoreFactories::default(),
+            &workspace::default_working_copy_factories(),
+        )?;
+
+        let repo_loader = workspace.repo_loader();
+        let repository = repo_loader.load_at_head()?;
+
+        Ok(Self { repository })
+    }
+
+    fn find_workspace_dir(cwd: &Path) -> &Path {
+        cwd.ancestors()
+            .find(|path| path.join(".jj").is_dir())
+            .unwrap_or(cwd)
+    }
+}
+
+impl JujutsuRepository for RealJujutsuRepository {
+    fn list_bookmarks(&self) -> Vec<Bookmark> {
+        let bookmarks = self
+            .repository
+            .view()
+            .bookmarks()
+            .map(|(ref_name, _target)| Bookmark {
+                ref_name: ref_name.as_str().to_string().into(),
+            })
+            .collect();
+
+        bookmarks
+    }
+}
+
+pub struct FakeJujutsuRepository {}
+
+impl JujutsuRepository for FakeJujutsuRepository {
+    fn list_bookmarks(&self) -> Vec<Bookmark> {
+        Vec::new()
+    }
+}

crates/jj/src/jj_store.rs 🔗

@@ -0,0 +1,41 @@
+use std::path::Path;
+use std::sync::Arc;
+
+use gpui::{App, Entity, Global, prelude::*};
+
+use crate::{JujutsuRepository, RealJujutsuRepository};
+
+/// Note: We won't ultimately be storing the jj store in a global, we're just doing this for exploration purposes.
+struct GlobalJujutsuStore(Entity<JujutsuStore>);
+
+impl Global for GlobalJujutsuStore {}
+
+pub struct JujutsuStore {
+    repository: Arc<dyn JujutsuRepository>,
+}
+
+impl JujutsuStore {
+    pub fn init_global(cx: &mut App) {
+        let Some(repository) = RealJujutsuRepository::new(&Path::new(".")).ok() else {
+            return;
+        };
+
+        let repository = Arc::new(repository);
+        let jj_store = cx.new(|cx| JujutsuStore::new(repository, cx));
+
+        cx.set_global(GlobalJujutsuStore(jj_store));
+    }
+
+    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
+        cx.try_global::<GlobalJujutsuStore>()
+            .map(|global| global.0.clone())
+    }
+
+    pub fn new(repository: Arc<dyn JujutsuRepository>, _cx: &mut Context<Self>) -> Self {
+        Self { repository }
+    }
+
+    pub fn repository(&self) -> &Arc<dyn JujutsuRepository> {
+        &self.repository
+    }
+}

crates/jj_ui/Cargo.toml 🔗

@@ -0,0 +1,25 @@
+[package]
+name = "jj_ui"
+version = "0.1.0"
+publish.workspace = true
+edition.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/jj_ui.rs"
+
+[dependencies]
+command_palette_hooks.workspace = true
+feature_flags.workspace = true
+fuzzy.workspace = true
+gpui.workspace = true
+jj.workspace = true
+picker.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace-hack.workspace = true
+workspace.workspace = true
+zed_actions.workspace = true

crates/jj_ui/src/bookmark_picker.rs 🔗

@@ -0,0 +1,198 @@
+use std::sync::Arc;
+
+use fuzzy::{StringMatchCandidate, match_strings};
+use gpui::{
+    App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window,
+    prelude::*,
+};
+use jj::{Bookmark, JujutsuStore};
+use picker::{Picker, PickerDelegate};
+use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
+use util::ResultExt as _;
+use workspace::{ModalView, Workspace};
+
+pub fn register(workspace: &mut Workspace) {
+    workspace.register_action(open);
+}
+
+fn open(
+    workspace: &mut Workspace,
+    _: &zed_actions::jj::BookmarkList,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    let Some(jj_store) = JujutsuStore::try_global(cx) else {
+        return;
+    };
+
+    workspace.toggle_modal(window, cx, |window, cx| {
+        let delegate = BookmarkPickerDelegate::new(cx.entity().downgrade(), jj_store, cx);
+        BookmarkPicker::new(delegate, window, cx)
+    });
+}
+
+pub struct BookmarkPicker {
+    picker: Entity<Picker<BookmarkPickerDelegate>>,
+}
+
+impl BookmarkPicker {
+    pub fn new(
+        delegate: BookmarkPickerDelegate,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
+        Self { picker }
+    }
+}
+
+impl ModalView for BookmarkPicker {}
+
+impl EventEmitter<DismissEvent> for BookmarkPicker {}
+
+impl Focusable for BookmarkPicker {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.picker.focus_handle(cx)
+    }
+}
+
+impl Render for BookmarkPicker {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex().w(rems(34.)).child(self.picker.clone())
+    }
+}
+
+#[derive(Debug, Clone)]
+struct BookmarkEntry {
+    bookmark: Bookmark,
+    positions: Vec<usize>,
+}
+
+pub struct BookmarkPickerDelegate {
+    picker: WeakEntity<BookmarkPicker>,
+    matches: Vec<BookmarkEntry>,
+    all_bookmarks: Vec<Bookmark>,
+    selected_index: usize,
+}
+
+impl BookmarkPickerDelegate {
+    fn new(
+        picker: WeakEntity<BookmarkPicker>,
+        jj_store: Entity<JujutsuStore>,
+        cx: &mut Context<BookmarkPicker>,
+    ) -> Self {
+        let bookmarks = jj_store.read(cx).repository().list_bookmarks();
+
+        Self {
+            picker,
+            matches: Vec::new(),
+            all_bookmarks: bookmarks,
+            selected_index: 0,
+        }
+    }
+}
+
+impl PickerDelegate for BookmarkPickerDelegate {
+    type ListItem = ListItem;
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+        "Select Bookmark…".into()
+    }
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(
+        &mut self,
+        ix: usize,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Task<()> {
+        let background = cx.background_executor().clone();
+        let all_bookmarks = self.all_bookmarks.clone();
+
+        cx.spawn_in(window, async move |this, cx| {
+            let matches = if query.is_empty() {
+                all_bookmarks
+                    .into_iter()
+                    .map(|bookmark| BookmarkEntry {
+                        bookmark,
+                        positions: Vec::new(),
+                    })
+                    .collect()
+            } else {
+                let candidates = all_bookmarks
+                    .iter()
+                    .enumerate()
+                    .map(|(ix, bookmark)| StringMatchCandidate::new(ix, &bookmark.ref_name))
+                    .collect::<Vec<_>>();
+                match_strings(
+                    &candidates,
+                    &query,
+                    false,
+                    true,
+                    100,
+                    &Default::default(),
+                    background,
+                )
+                .await
+                .into_iter()
+                .map(|mat| BookmarkEntry {
+                    bookmark: all_bookmarks[mat.candidate_id].clone(),
+                    positions: mat.positions,
+                })
+                .collect()
+            };
+
+            this.update(cx, |this, _cx| {
+                this.delegate.matches = matches;
+            })
+            .log_err();
+        })
+    }
+
+    fn confirm(&mut self, _secondary: bool, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {
+        //
+    }
+
+    fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        self.picker
+            .update(cx, |_, cx| cx.emit(DismissEvent))
+            .log_err();
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let entry = &self.matches[ix];
+
+        Some(
+            ListItem::new(ix)
+                .inset(true)
+                .spacing(ListItemSpacing::Sparse)
+                .toggle_state(selected)
+                .child(HighlightedLabel::new(
+                    entry.bookmark.ref_name.clone(),
+                    entry.positions.clone(),
+                )),
+        )
+    }
+}

crates/jj_ui/src/jj_ui.rs 🔗

@@ -0,0 +1,39 @@
+mod bookmark_picker;
+
+use command_palette_hooks::CommandPaletteFilter;
+use feature_flags::FeatureFlagAppExt as _;
+use gpui::App;
+use jj::JujutsuStore;
+use workspace::Workspace;
+
+pub fn init(cx: &mut App) {
+    JujutsuStore::init_global(cx);
+
+    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
+        bookmark_picker::register(workspace);
+    })
+    .detach();
+
+    feature_gate_jj_ui_actions(cx);
+}
+
+fn feature_gate_jj_ui_actions(cx: &mut App) {
+    const JJ_ACTION_NAMESPACE: &str = "jj";
+
+    CommandPaletteFilter::update_global(cx, |filter, _cx| {
+        filter.hide_namespace(JJ_ACTION_NAMESPACE);
+    });
+
+    cx.observe_flag::<feature_flags::JjUiFeatureFlag, _>({
+        move |is_enabled, cx| {
+            CommandPaletteFilter::update_global(cx, |filter, _cx| {
+                if is_enabled {
+                    filter.show_namespace(JJ_ACTION_NAMESPACE);
+                } else {
+                    filter.hide_namespace(JJ_ACTION_NAMESPACE);
+                }
+            });
+        }
+    })
+    .detach();
+}

crates/journal/src/journal.rs 🔗

@@ -1,7 +1,7 @@
 use anyhow::Result;
 use chrono::{Datelike, Local, NaiveTime, Timelike};
-use editor::Editor;
 use editor::scroll::Autoscroll;
+use editor::{Editor, SelectionEffects};
 use gpui::{App, AppContext as _, Context, Window, actions};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
@@ -168,9 +168,12 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap
                 if let Some(editor) = item.downcast::<Editor>().map(|editor| editor.downgrade()) {
                     editor.update_in(cx, |editor, window, cx| {
                         let len = editor.buffer().read(cx).len(cx);
-                        editor.change_selections(Some(Autoscroll::center()), window, cx, |s| {
-                            s.select_ranges([len..len])
-                        });
+                        editor.change_selections(
+                            SelectionEffects::scroll(Autoscroll::center()),
+                            window,
+                            cx,
+                            |s| s.select_ranges([len..len]),
+                        );
                         if len > 0 {
                             editor.insert("\n\n", window, cx);
                         }

crates/language/Cargo.toml 🔗

@@ -20,6 +20,7 @@ test-support = [
     "text/test-support",
     "tree-sitter-rust",
     "tree-sitter-python",
+    "tree-sitter-rust",
     "tree-sitter-typescript",
     "settings/test-support",
     "util/test-support",
@@ -28,7 +29,6 @@ test-support = [
 [dependencies]
 anyhow.workspace = true
 async-trait.workspace = true
-async-watch.workspace = true
 clock.workspace = true
 collections.workspace = true
 ec4rs.workspace = true
@@ -51,6 +51,7 @@ schemars.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
+shellexpand.workspace = true
 smallvec.workspace = true
 smol.workspace = true
 streaming-iterator.workspace = true
@@ -65,12 +66,13 @@ tree-sitter-typescript = { workspace = true, optional = true }
 tree-sitter.workspace = true
 unicase = "2.6"
 util.workspace = true
+watch.workspace = true
 workspace-hack.workspace = true
+diffy = "0.4.2"
 
 [dev-dependencies]
 collections = { workspace = true, features = ["test-support"] }
 ctor.workspace = true
-env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 indoc.workspace = true
 lsp = { workspace = true, features = ["test-support"] }
@@ -91,3 +93,4 @@ tree-sitter-rust.workspace = true
 tree-sitter-typescript.workspace = true
 unindent.workspace = true
 util = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/language/src/buffer.rs 🔗

@@ -1,12 +1,6 @@
-pub use crate::{
-    Grammar, Language, LanguageRegistry,
-    diagnostic_set::DiagnosticSet,
-    highlight_map::{HighlightId, HighlightMap},
-    proto,
-};
 use crate::{
-    LanguageScope, Outline, OutlineConfig, RunnableCapture, RunnableTag, TextObject,
-    TreeSitterOptions,
+    DebuggerTextObject, LanguageScope, Outline, OutlineConfig, RunnableCapture, RunnableTag,
+    TextObject, TreeSitterOptions,
     diagnostic_set::{DiagnosticEntry, DiagnosticGroup},
     language_settings::{LanguageSettings, language_settings},
     outline::OutlineItem,
@@ -17,8 +11,13 @@ use crate::{
     task_context::RunnableRange,
     text_diff::text_diff,
 };
-use anyhow::{Context as _, Result, anyhow};
-use async_watch as watch;
+pub use crate::{
+    Grammar, Language, LanguageRegistry,
+    diagnostic_set::DiagnosticSet,
+    highlight_map::{HighlightId, HighlightMap},
+    proto,
+};
+use anyhow::{Context as _, Result};
 pub use clock::ReplicaId;
 use clock::{AGENT_REPLICA_ID, Lamport};
 use collections::HashMap;
@@ -107,6 +106,7 @@ pub struct Buffer {
     reload_task: Option<Task<Result<()>>>,
     language: Option<Arc<Language>>,
     autoindent_requests: Vec<Arc<AutoindentRequest>>,
+    wait_for_autoindent_txs: Vec<oneshot::Sender<()>>,
     pending_autoindent: Option<Task<()>>,
     sync_parse_timeout: Duration,
     syntax_map: Mutex<SyntaxMap>,
@@ -229,8 +229,19 @@ pub struct Diagnostic {
     pub is_disk_based: bool,
     /// Whether this diagnostic marks unnecessary code.
     pub is_unnecessary: bool,
+    /// Quick separation of diagnostics groups based by their source.
+    pub source_kind: DiagnosticSourceKind,
     /// Data from language server that produced this diagnostic. Passed back to the LS when we request code actions for this diagnostic.
     pub data: Option<Value>,
+    /// Whether to underline the corresponding text range in the editor.
+    pub underline: bool,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub enum DiagnosticSourceKind {
+    Pulled,
+    Pushed,
+    Other,
 }
 
 /// An operation used to synchronize this buffer with its other replicas.
@@ -462,6 +473,7 @@ pub struct BufferChunks<'a> {
     information_depth: usize,
     hint_depth: usize,
     unnecessary_depth: usize,
+    underline: bool,
     highlights: Option<BufferChunkHighlights<'a>>,
 }
 
@@ -482,6 +494,10 @@ pub struct Chunk<'a> {
     pub is_unnecessary: bool,
     /// Whether this chunk of text was originally a tab character.
     pub is_tab: bool,
+    /// Whether this chunk of text was originally a tab character.
+    pub is_inlay: bool,
+    /// Whether to underline the corresponding text range in the editor.
+    pub underline: bool,
 }
 
 /// A set of edits to a given version of a buffer, computed asynchronously.
@@ -492,10 +508,11 @@ pub struct Diff {
     pub edits: Vec<(Range<usize>, Arc<str>)>,
 }
 
-#[derive(Clone, Copy)]
+#[derive(Debug, Clone, Copy)]
 pub(crate) struct DiagnosticEndpoint {
     offset: usize,
     is_start: bool,
+    underline: bool,
     severity: DiagnosticSeverity,
     is_unnecessary: bool,
 }
@@ -816,13 +833,11 @@ impl Buffer {
         message: proto::BufferState,
         file: Option<Arc<dyn File>>,
     ) -> Result<Self> {
-        let buffer_id = BufferId::new(message.id)
-            .with_context(|| anyhow!("Could not deserialize buffer_id"))?;
+        let buffer_id = BufferId::new(message.id).context("Could not deserialize buffer_id")?;
         let buffer = TextBuffer::new(replica_id, buffer_id, message.base_text);
         let mut this = Self::build(buffer, file, capability);
         this.text.set_line_ending(proto::deserialize_line_ending(
-            rpc::proto::LineEnding::from_i32(message.line_ending)
-                .ok_or_else(|| anyhow!("missing line_ending"))?,
+            rpc::proto::LineEnding::from_i32(message.line_ending).context("missing line_ending")?,
         ));
         this.saved_version = proto::deserialize_version(&message.saved_version);
         this.saved_mtime = message.saved_mtime.map(|time| time.into());
@@ -928,8 +943,9 @@ impl Buffer {
             reparse: None,
             non_text_state_update_count: 0,
             sync_parse_timeout: Duration::from_millis(1),
-            parse_status: async_watch::channel(ParseStatus::Idle),
+            parse_status: watch::channel(ParseStatus::Idle),
             autoindent_requests: Default::default(),
+            wait_for_autoindent_txs: Default::default(),
             pending_autoindent: Default::default(),
             language: None,
             remote_selections: Default::default(),
@@ -1369,9 +1385,30 @@ impl Buffer {
     /// Returns the [`Language`] at the given location.
     pub fn language_at<D: ToOffset>(&self, position: D) -> Option<Arc<Language>> {
         let offset = position.to_offset(self);
+        let mut is_first = true;
+        let start_anchor = self.anchor_before(offset);
+        let end_anchor = self.anchor_after(offset);
         self.syntax_map
             .lock()
             .layers_for_range(offset..offset, &self.text, false)
+            .filter(|layer| {
+                if is_first {
+                    is_first = false;
+                    return true;
+                }
+                let any_sub_ranges_contain_range = layer
+                    .included_sub_ranges
+                    .map(|sub_ranges| {
+                        sub_ranges.iter().any(|sub_range| {
+                            let is_before_start = sub_range.end.cmp(&start_anchor, self).is_lt();
+                            let is_after_end = sub_range.start.cmp(&end_anchor, self).is_gt();
+                            !is_before_start && !is_after_end
+                        })
+                    })
+                    .unwrap_or(true);
+                let result = any_sub_ranges_contain_range;
+                return result;
+            })
             .last()
             .map(|info| info.language.clone())
             .or_else(|| self.language.clone())
@@ -1414,7 +1451,7 @@ impl Buffer {
         self.syntax_map.lock().contains_unknown_injections()
     }
 
-    #[cfg(test)]
+    #[cfg(any(test, feature = "test-support"))]
     pub fn set_sync_parse_timeout(&mut self, timeout: Duration) {
         self.sync_parse_timeout = timeout;
     }
@@ -1565,6 +1602,9 @@ impl Buffer {
             }
         } else {
             self.autoindent_requests.clear();
+            for tx in self.wait_for_autoindent_txs.drain(..) {
+                tx.send(()).ok();
+            }
         }
     }
 
@@ -1746,6 +1786,9 @@ impl Buffer {
         cx: &mut Context<Self>,
     ) {
         self.autoindent_requests.clear();
+        for tx in self.wait_for_autoindent_txs.drain(..) {
+            tx.send(()).ok();
+        }
 
         let edits: Vec<_> = indent_sizes
             .into_iter()
@@ -1839,9 +1882,12 @@ impl Buffer {
     }
 
     /// Ensures that the buffer ends with a single newline character, and
-    /// no other whitespace.
+    /// no other whitespace. Skips if the buffer is empty.
     pub fn ensure_final_newline(&mut self, cx: &mut Context<Self>) {
         let len = self.len();
+        if len == 0 {
+            return;
+        }
         let mut offset = len;
         for chunk in self.as_rope().reversed_chunks_in_range(0..len) {
             let non_whitespace_len = chunk
@@ -2082,6 +2128,16 @@ impl Buffer {
         self.text.give_up_waiting();
     }
 
+    pub fn wait_for_autoindent_applied(&mut self) -> Option<oneshot::Receiver<()>> {
+        let mut rx = None;
+        if !self.autoindent_requests.is_empty() {
+            let channel = oneshot::channel();
+            self.wait_for_autoindent_txs.push(channel.0);
+            rx = Some(channel.1);
+        }
+        rx
+    }
+
     /// Stores a set of selections that should be broadcasted to all of the buffer's replicas.
     pub fn set_active_selections(
         &mut self,
@@ -2857,7 +2913,12 @@ impl BufferSnapshot {
     ) -> Option<impl Iterator<Item = Option<IndentSuggestion>> + '_> {
         let config = &self.language.as_ref()?.config;
         let prev_non_blank_row = self.prev_non_blank_row(row_range.start);
-        let significant_indentation = config.significant_indentation;
+
+        #[derive(Debug, Clone)]
+        struct StartPosition {
+            start: Point,
+            suffix: SharedString,
+        }
 
         // Find the suggested indentation ranges based on the syntax tree.
         let start = Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0);
@@ -2873,13 +2934,13 @@ impl BufferSnapshot {
             .collect::<Vec<_>>();
 
         let mut indent_ranges = Vec::<Range<Point>>::new();
+        let mut start_positions = Vec::<StartPosition>::new();
         let mut outdent_positions = Vec::<Point>::new();
         while let Some(mat) = matches.peek() {
             let mut start: Option<Point> = None;
             let mut end: Option<Point> = None;
-            let mut outdent: Option<Point> = None;
 
-            let config = &indent_configs[mat.grammar_index];
+            let config = indent_configs[mat.grammar_index];
             for capture in mat.captures {
                 if capture.index == config.indent_capture_ix {
                     start.get_or_insert(Point::from_ts_point(capture.node.start_position()));
@@ -2889,21 +2950,18 @@ impl BufferSnapshot {
                 } else if Some(capture.index) == config.end_capture_ix {
                     end = Some(Point::from_ts_point(capture.node.start_position()));
                 } else if Some(capture.index) == config.outdent_capture_ix {
-                    let point = Point::from_ts_point(capture.node.start_position());
-                    outdent.get_or_insert(point);
-                    outdent_positions.push(point);
+                    outdent_positions.push(Point::from_ts_point(capture.node.start_position()));
+                } else if let Some(suffix) = config.suffixed_start_captures.get(&capture.index) {
+                    start_positions.push(StartPosition {
+                        start: Point::from_ts_point(capture.node.start_position()),
+                        suffix: suffix.clone(),
+                    });
                 }
             }
 
             matches.advance();
-            // in case of significant indentation expand end to outdent position
-            let end = if significant_indentation {
-                outdent.or(end)
-            } else {
-                end
-            };
             if let Some((start, end)) = start.zip(end) {
-                if start.row == end.row && !significant_indentation {
+                if start.row == end.row {
                     continue;
                 }
                 let range = start..end;
@@ -2941,24 +2999,26 @@ impl BufferSnapshot {
             matches.advance();
         }
 
-        // we don't use outdent positions to truncate in case of significant indentation
-        // rather we use them to expand (handled above)
-        if !significant_indentation {
-            outdent_positions.sort();
-            for outdent_position in outdent_positions {
-                // find the innermost indent range containing this outdent_position
-                // 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()
-                {
-                    range_to_truncate.end = outdent_position;
-                }
+        outdent_positions.sort();
+        for outdent_position in outdent_positions {
+            // find the innermost indent range containing this outdent_position
+            // 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()
+            {
+                range_to_truncate.end = outdent_position;
             }
         }
 
+        start_positions.sort_by_key(|b| b.start);
+
         // 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 start_positions_iter = start_positions.iter().peekable();
+
         let mut indent_change_rows = Vec::<(u32, Ordering)>::new();
         self.for_each_line(
             Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0)
@@ -2978,6 +3038,33 @@ impl BufferSnapshot {
                 {
                     indent_change_rows.push((row + 1, Ordering::Greater));
                 }
+                while let Some(pos) = start_positions_iter.peek() {
+                    if pos.start.row < row {
+                        let pos = start_positions_iter.next().unwrap();
+                        last_seen_suffix
+                            .entry(pos.suffix.to_string())
+                            .or_default()
+                            .push(pos.start);
+                    } else {
+                        break;
+                    }
+                }
+                for rule in &config.decrease_indent_patterns {
+                    if rule.pattern.as_ref().map_or(false, |r| r.is_match(line)) {
+                        let row_start_column = self.indent_size_for_line(row).len;
+                        let basis_row = rule
+                            .valid_after
+                            .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);
+                        }
+                        break;
+                    }
+                }
             },
         );
 
@@ -2987,6 +3074,7 @@ impl BufferSnapshot {
         } else {
             row_range.start.saturating_sub(1)
         };
+
         let mut prev_row_start = Point::new(prev_row, self.indent_size_for_line(prev_row).len);
         Some(row_range.map(move |row| {
             let row_start = Point::new(row, self.indent_size_for_line(row).len);
@@ -3024,17 +3112,17 @@ impl BufferSnapshot {
                 if range.start.row == prev_row && range.end > row_start {
                     indent_from_prev_row = true;
                 }
-                if significant_indentation && self.is_line_blank(row) && range.start.row == prev_row
-                {
-                    indent_from_prev_row = true;
-                }
-                if !significant_indentation || !self.is_line_blank(row) {
-                    if range.end > prev_row_start && range.end <= row_start {
-                        outdent_to_row = outdent_to_row.min(range.start.row);
-                    }
+                if range.end > prev_row_start && range.end <= row_start {
+                    outdent_to_row = outdent_to_row.min(range.start.row);
                 }
             }
 
+            if let Some(basis_row) = regex_outdent_map.get(&row) {
+                indent_from_prev_row = false;
+                outdent_to_row = *basis_row;
+                from_regex = true;
+            }
+
             let within_error = error_ranges
                 .iter()
                 .any(|e| e.start.row < row && e.end > row_start);
@@ -3092,7 +3180,7 @@ impl BufferSnapshot {
         None
     }
 
-    fn get_highlights(&self, range: Range<usize>) -> (SyntaxMapCaptures, Vec<HighlightMap>) {
+    fn get_highlights(&self, range: Range<usize>) -> (SyntaxMapCaptures<'_>, Vec<HighlightMap>) {
         let captures = self.syntax.captures(range, &self.text, |grammar| {
             grammar.highlights_query.as_ref()
         });
@@ -3108,7 +3196,7 @@ impl BufferSnapshot {
     /// in an arbitrary way due to being stored in a [`Rope`](text::Rope). The text is also
     /// returned in chunks where each chunk has a single syntax highlighting style and
     /// diagnostic status.
-    pub fn chunks<T: ToOffset>(&self, range: Range<T>, language_aware: bool) -> BufferChunks {
+    pub fn chunks<T: ToOffset>(&self, range: Range<T>, language_aware: bool) -> BufferChunks<'_> {
         let range = range.start.to_offset(self)..range.end.to_offset(self);
 
         let mut syntax = None;
@@ -3157,12 +3245,12 @@ impl BufferSnapshot {
     }
 
     /// Iterates over every [`SyntaxLayer`] in the buffer.
-    pub fn syntax_layers(&self) -> impl Iterator<Item = SyntaxLayer> + '_ {
+    pub fn syntax_layers(&self) -> impl Iterator<Item = SyntaxLayer<'_>> + '_ {
         self.syntax
             .layers_for_range(0..self.len(), &self.text, true)
     }
 
-    pub fn syntax_layer_at<D: ToOffset>(&self, position: D) -> Option<SyntaxLayer> {
+    pub fn syntax_layer_at<D: ToOffset>(&self, position: D) -> Option<SyntaxLayer<'_>> {
         let offset = position.to_offset(self);
         self.syntax
             .layers_for_range(offset..offset, &self.text, false)
@@ -3173,7 +3261,7 @@ impl BufferSnapshot {
     pub fn smallest_syntax_layer_containing<D: ToOffset>(
         &self,
         range: Range<D>,
-    ) -> Option<SyntaxLayer> {
+    ) -> Option<SyntaxLayer<'_>> {
         let range = range.to_offset(self);
         return self
             .syntax
@@ -3279,8 +3367,8 @@ impl BufferSnapshot {
     pub fn surrounding_word<T: ToOffset>(&self, start: T) -> (Range<usize>, Option<CharKind>) {
         let mut start = start.to_offset(self);
         let mut end = start;
-        let mut next_chars = self.chars_at(start).peekable();
-        let mut prev_chars = self.reversed_chars_at(start).peekable();
+        let mut next_chars = self.chars_at(start).take(128).peekable();
+        let mut prev_chars = self.reversed_chars_at(start).take(128).peekable();
 
         let classifier = self.char_classifier_at(start);
         let word_kind = cmp::max(
@@ -3391,7 +3479,7 @@ impl BufferSnapshot {
     }
 
     /// Returns the root syntax node within the given row
-    pub fn syntax_root_ancestor(&self, position: Anchor) -> Option<tree_sitter::Node> {
+    pub fn syntax_root_ancestor(&self, position: Anchor) -> Option<tree_sitter::Node<'_>> {
         let start_offset = position.to_offset(self);
 
         let row = self.summary_for_anchor::<text::PointUtf16>(&position).row as usize;
@@ -3728,7 +3816,7 @@ impl BufferSnapshot {
         &self,
         range: Range<usize>,
         query: fn(&Grammar) -> Option<&tree_sitter::Query>,
-    ) -> SyntaxMapMatches {
+    ) -> SyntaxMapMatches<'_> {
         self.syntax.matches(range, self, query)
     }
 
@@ -3792,6 +3880,74 @@ impl BufferSnapshot {
             .filter(|pair| !pair.newline_only)
     }
 
+    pub fn debug_variables_query<T: ToOffset>(
+        &self,
+        range: Range<T>,
+    ) -> impl Iterator<Item = (Range<usize>, DebuggerTextObject)> + '_ {
+        let range = range.start.to_offset(self).saturating_sub(1)
+            ..self.len().min(range.end.to_offset(self) + 1);
+
+        let mut matches = self.syntax.matches_with_options(
+            range.clone(),
+            &self.text,
+            TreeSitterOptions::default(),
+            |grammar| grammar.debug_variables_config.as_ref().map(|c| &c.query),
+        );
+
+        let configs = matches
+            .grammars()
+            .iter()
+            .map(|grammar| grammar.debug_variables_config.as_ref())
+            .collect::<Vec<_>>();
+
+        let mut captures = Vec::<(Range<usize>, DebuggerTextObject)>::new();
+
+        iter::from_fn(move || {
+            loop {
+                while let Some(capture) = captures.pop() {
+                    if capture.0.overlaps(&range) {
+                        return Some(capture);
+                    }
+                }
+
+                let mat = matches.peek()?;
+
+                let Some(config) = configs[mat.grammar_index].as_ref() else {
+                    matches.advance();
+                    continue;
+                };
+
+                for capture in mat.captures {
+                    let Some(ix) = config
+                        .objects_by_capture_ix
+                        .binary_search_by_key(&capture.index, |e| e.0)
+                        .ok()
+                    else {
+                        continue;
+                    };
+                    let text_object = config.objects_by_capture_ix[ix].1;
+                    let byte_range = capture.node.byte_range();
+
+                    let mut found = false;
+                    for (range, existing) in captures.iter_mut() {
+                        if existing == &text_object {
+                            range.start = range.start.min(byte_range.start);
+                            range.end = range.end.max(byte_range.end);
+                            found = true;
+                            break;
+                        }
+                    }
+
+                    if !found {
+                        captures.push((byte_range, text_object));
+                    }
+                }
+
+                matches.advance();
+            }
+        })
+    }
+
     pub fn text_object_ranges<T: ToOffset>(
         &self,
         range: Range<T>,
@@ -4230,6 +4386,11 @@ impl BufferSnapshot {
         self.non_text_state_update_count
     }
 
+    /// An integer version that changes when the buffer's syntax changes.
+    pub fn syntax_update_count(&self) -> usize {
+        self.syntax.update_count()
+    }
+
     /// Returns a snapshot of underlying file.
     pub fn file(&self) -> Option<&Arc<dyn File>> {
         self.file.as_ref()
@@ -4390,6 +4551,7 @@ impl<'a> BufferChunks<'a> {
             information_depth: 0,
             hint_depth: 0,
             unnecessary_depth: 0,
+            underline: true,
             highlights,
         };
         this.initialize_diagnostic_endpoints();
@@ -4450,12 +4612,14 @@ impl<'a> BufferChunks<'a> {
                         is_start: true,
                         severity: entry.diagnostic.severity,
                         is_unnecessary: entry.diagnostic.is_unnecessary,
+                        underline: entry.diagnostic.underline,
                     });
                     diagnostic_endpoints.push(DiagnosticEndpoint {
                         offset: entry.range.end,
                         is_start: false,
                         severity: entry.diagnostic.severity,
                         is_unnecessary: entry.diagnostic.is_unnecessary,
+                        underline: entry.diagnostic.underline,
                     });
                 }
                 diagnostic_endpoints
@@ -4561,6 +4725,7 @@ impl<'a> Iterator for BufferChunks<'a> {
                 if endpoint.offset <= self.range.start {
                     self.update_diagnostic_depths(endpoint);
                     diagnostic_endpoints.next();
+                    self.underline = endpoint.underline;
                 } else {
                     next_diagnostic_endpoint = endpoint.offset;
                     break;
@@ -4592,9 +4757,10 @@ impl<'a> Iterator for BufferChunks<'a> {
             Some(Chunk {
                 text: slice,
                 syntax_highlight_id: highlight_id,
+                underline: self.underline,
                 diagnostic_severity: self.current_diagnostic_severity(),
                 is_unnecessary: self.current_code_is_unnecessary(),
-                ..Default::default()
+                ..Chunk::default()
             })
         } else {
             None
@@ -4625,6 +4791,7 @@ impl Default for Diagnostic {
     fn default() -> Self {
         Self {
             source: Default::default(),
+            source_kind: DiagnosticSourceKind::Other,
             code: None,
             code_description: None,
             severity: DiagnosticSeverity::ERROR,
@@ -4634,6 +4801,7 @@ impl Default for Diagnostic {
             is_primary: false,
             is_disk_based: false,
             is_unnecessary: false,
+            underline: true,
             data: None,
         }
     }

crates/language/src/buffer_tests.rs 🔗

@@ -39,9 +39,7 @@ pub static TRAILING_WHITESPACE_REGEX: LazyLock<regex::Regex> = LazyLock::new(||
 #[cfg(test)]
 #[ctor::ctor]
 fn init_logger() {
-    if std::env::var("RUST_LOG").is_ok() {
-        env_logger::init();
-    }
+    zlog::init_test();
 }
 
 #[gpui::test]
@@ -85,6 +83,17 @@ fn test_select_language(cx: &mut App) {
         },
         Some(tree_sitter_rust::LANGUAGE.into()),
     )));
+    registry.add(Arc::new(Language::new(
+        LanguageConfig {
+            name: "Rust with longer extension".into(),
+            matcher: LanguageMatcher {
+                path_suffixes: vec!["longer.rs".to_string()],
+                ..Default::default()
+            },
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::LANGUAGE.into()),
+    )));
     registry.add(Arc::new(Language::new(
         LanguageConfig {
             name: LanguageName::new("Make"),
@@ -111,6 +120,14 @@ fn test_select_language(cx: &mut App) {
         Some("Make".into())
     );
 
+    // matching longer, compound extension, part of which could also match another lang
+    assert_eq!(
+        registry
+            .language_for_file(&file("src/lib.longer.rs"), None, cx)
+            .map(|l| l.name()),
+        Some("Rust with longer extension".into())
+    );
+
     // matching filename
     assert_eq!(
         registry
@@ -183,7 +200,11 @@ async fn test_language_for_file_with_custom_file_types(cx: &mut TestAppContext)
         init_settings(cx, |settings| {
             settings.file_types.extend([
                 ("TypeScript".into(), vec!["js".into()]),
-                ("C++".into(), vec!["c".into()]),
+                (
+                    "JavaScript".into(),
+                    vec!["*longer.ts".into(), "ecmascript".into()],
+                ),
+                ("C++".into(), vec!["c".into(), "*.dev".into()]),
                 (
                     "Dockerfile".into(),
                     vec!["Dockerfile".into(), "Dockerfile.*".into()],
@@ -206,7 +227,7 @@ async fn test_language_for_file_with_custom_file_types(cx: &mut TestAppContext)
         LanguageConfig {
             name: "TypeScript".into(),
             matcher: LanguageMatcher {
-                path_suffixes: vec!["js".to_string()],
+                path_suffixes: vec!["ts".to_string(), "ts.ecmascript".to_string()],
                 ..Default::default()
             },
             ..Default::default()
@@ -239,6 +260,21 @@ async fn test_language_for_file_with_custom_file_types(cx: &mut TestAppContext)
         languages.add(Arc::new(Language::new(config, None)));
     }
 
+    // matches system-provided lang extension
+    let language = cx
+        .read(|cx| languages.language_for_file(&file("foo.ts"), None, cx))
+        .unwrap();
+    assert_eq!(language.name(), "TypeScript".into());
+    let language = cx
+        .read(|cx| languages.language_for_file(&file("foo.ts.ecmascript"), None, cx))
+        .unwrap();
+    assert_eq!(language.name(), "TypeScript".into());
+    let language = cx
+        .read(|cx| languages.language_for_file(&file("foo.cpp"), None, cx))
+        .unwrap();
+    assert_eq!(language.name(), "C++".into());
+
+    // user configured lang extension, same length as system-provided
     let language = cx
         .read(|cx| languages.language_for_file(&file("foo.js"), None, cx))
         .unwrap();
@@ -247,6 +283,25 @@ async fn test_language_for_file_with_custom_file_types(cx: &mut TestAppContext)
         .read(|cx| languages.language_for_file(&file("foo.c"), None, cx))
         .unwrap();
     assert_eq!(language.name(), "C++".into());
+
+    // user configured lang extension, longer than system-provided
+    let language = cx
+        .read(|cx| languages.language_for_file(&file("foo.longer.ts"), None, cx))
+        .unwrap();
+    assert_eq!(language.name(), "JavaScript".into());
+
+    // user configured lang extension, shorter than system-provided
+    let language = cx
+        .read(|cx| languages.language_for_file(&file("foo.ecmascript"), None, cx))
+        .unwrap();
+    assert_eq!(language.name(), "JavaScript".into());
+
+    // user configured glob matches
+    let language = cx
+        .read(|cx| languages.language_for_file(&file("c-plus-plus.dev"), None, cx))
+        .unwrap();
+    assert_eq!(language.name(), "C++".into());
+    // should match Dockerfile.* => Dockerfile, not *.dev => C++
     let language = cx
         .read(|cx| languages.language_for_file(&file("Dockerfile.dev"), None, cx))
         .unwrap();
@@ -2218,6 +2273,7 @@ fn test_language_scope_at_with_javascript(cx: &mut App) {
             LanguageConfig {
                 name: "JavaScript".into(),
                 line_comments: vec!["// ".into()],
+                block_comment: Some(("/*".into(), "*/".into())),
                 brackets: BracketPairConfig {
                     pairs: vec![
                         BracketPair {
@@ -2281,6 +2337,10 @@ fn test_language_scope_at_with_javascript(cx: &mut App) {
 
         let config = snapshot.language_scope_at(0).unwrap();
         assert_eq!(config.line_comment_prefixes(), &[Arc::from("// ")]);
+        assert_eq!(
+            config.block_comment_delimiters(),
+            Some((&"/*".into(), &"*/".into()))
+        );
         // Both bracket pairs are enabled
         assert_eq!(
             config.brackets().map(|e| e.1).collect::<Vec<_>>(),
@@ -2299,6 +2359,10 @@ fn test_language_scope_at_with_javascript(cx: &mut App) {
             .language_scope_at(text.find("b\"").unwrap())
             .unwrap();
         assert_eq!(string_config.line_comment_prefixes(), &[Arc::from("// ")]);
+        assert_eq!(
+            string_config.block_comment_delimiters(),
+            Some((&"/*".into(), &"*/".into()))
+        );
         // Second bracket pair is disabled
         assert_eq!(
             string_config.brackets().map(|e| e.1).collect::<Vec<_>>(),
@@ -2326,6 +2390,10 @@ fn test_language_scope_at_with_javascript(cx: &mut App) {
             .language_scope_at(text.find(" d=").unwrap() + 1)
             .unwrap();
         assert_eq!(tag_config.line_comment_prefixes(), &[Arc::from("// ")]);
+        assert_eq!(
+            tag_config.block_comment_delimiters(),
+            Some((&"/*".into(), &"*/".into()))
+        );
         assert_eq!(
             tag_config.brackets().map(|e| e.1).collect::<Vec<_>>(),
             &[true, true]
@@ -2339,6 +2407,10 @@ fn test_language_scope_at_with_javascript(cx: &mut App) {
             expression_in_element_config.line_comment_prefixes(),
             &[Arc::from("// ")]
         );
+        assert_eq!(
+            expression_in_element_config.block_comment_delimiters(),
+            Some((&"/*".into(), &"*/".into()))
+        );
         assert_eq!(
             expression_in_element_config
                 .brackets()
@@ -3629,6 +3701,7 @@ fn get_tree_sexp(buffer: &Entity<Buffer>, cx: &mut gpui::TestAppContext) -> Stri
 }
 
 // Assert that the enclosing bracket ranges around the selection match the pairs indicated by the marked text in `range_markers`
+#[track_caller]
 fn assert_bracket_pairs(
     selection_text: &'static str,
     bracket_pair_texts: Vec<&'static str>,

crates/language/src/language.rs 🔗

@@ -24,7 +24,7 @@ pub mod buffer_tests;
 
 pub use crate::language_settings::EditPredictionsMode;
 use crate::language_settings::SoftWrap;
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use async_trait::async_trait;
 use collections::{HashMap, HashSet, IndexSet};
 use fs::Fs;
@@ -32,9 +32,11 @@ use futures::Future;
 use gpui::{App, AsyncApp, Entity, SharedString, Task};
 pub use highlight_map::HighlightMap;
 use http_client::HttpClient;
-pub use language_registry::{LanguageName, LoadedLanguage};
+pub use language_registry::{
+    LanguageName, LanguageServerStatusUpdate, LoadedLanguage, ServerHealth,
+};
 use lsp::{CodeActionKind, InitializeParams, LanguageServerBinary, LanguageServerBinaryOptions};
-pub use manifest::{ManifestName, ManifestProvider, ManifestQuery};
+pub use manifest::{ManifestDelegate, ManifestName, ManifestProvider, ManifestQuery};
 use parking_lot::Mutex;
 use regex::Regex;
 use schemars::{
@@ -64,8 +66,10 @@ use std::{
 use std::{num::NonZeroU32, sync::OnceLock};
 use syntax_map::{QueryCursorHandle, SyntaxSnapshot};
 use task::RunnableTag;
-pub use task_context::{ContextProvider, RunnableRange};
-pub use text_diff::{DiffOptions, line_diff, text_diff, text_diff_with_options, unified_diff};
+pub use task_context::{ContextLocation, ContextProvider, RunnableRange};
+pub use text_diff::{
+    DiffOptions, apply_diff_patch, line_diff, text_diff, text_diff_with_options, unified_diff,
+};
 use theme::SyntaxTheme;
 pub use toolchain::{LanguageToolchainStore, Toolchain, ToolchainList, ToolchainLister};
 use tree_sitter::{self, Query, QueryCursor, WasmStore, wasmtime};
@@ -243,6 +247,10 @@ impl CachedLspAdapter {
         self.adapter.retain_old_diagnostic(previous_diagnostic, cx)
     }
 
+    pub fn underline_diagnostic(&self, diagnostic: &lsp::Diagnostic) -> bool {
+        self.adapter.underline_diagnostic(diagnostic)
+    }
+
     pub fn diagnostic_message_to_markdown(&self, message: &str) -> Option<String> {
         self.adapter.diagnostic_message_to_markdown(message)
     }
@@ -317,7 +325,6 @@ pub trait LspAdapterDelegate: Send + Sync {
     fn http_client(&self) -> Arc<dyn HttpClient>;
     fn worktree_id(&self) -> WorktreeId;
     fn worktree_root_path(&self) -> &Path;
-    fn exists(&self, path: &Path, is_dir: Option<bool>) -> bool;
     fn update_status(&self, language: LanguageServerName, status: BinaryStatus);
     fn registered_lsp_adapters(&self) -> Vec<Arc<dyn LspAdapter>>;
     async fn language_server_download_dir(&self, name: &LanguageServerName) -> Option<Arc<Path>>;
@@ -368,9 +375,7 @@ pub trait LspAdapter: 'static + Send + Sync {
                 }
             }
 
-            if !binary_options.allow_binary_download {
-                return Err(anyhow!("downloading language servers disabled"));
-            }
+            anyhow::ensure!(binary_options.allow_binary_download, "downloading language servers disabled");
 
             if let Some(cached_binary) = cached_binary.as_ref() {
                 return Ok(cached_binary.clone());
@@ -470,6 +475,16 @@ pub trait LspAdapter: 'static + Send + Sync {
         false
     }
 
+    /// Whether to underline a given diagnostic or not, when rendering in the editor.
+    ///
+    /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnosticTag
+    /// states that
+    /// > Clients are allowed to render diagnostics with this tag faded out instead of having an error squiggle.
+    /// for the unnecessary diagnostics, so do not underline them.
+    fn underline_diagnostic(&self, _diagnostic: &lsp::Diagnostic) -> bool {
+        true
+    }
+
     /// Post-processes completions provided by the language server.
     async fn process_completions(&self, _: &mut [lsp::CompletionItem]) {}
 
@@ -681,10 +696,6 @@ pub struct LanguageConfig {
     #[serde(default)]
     #[schemars(schema_with = "bracket_pair_config_json_schema")]
     pub brackets: BracketPairConfig,
-    /// If set to true, indicates the language uses significant whitespace/indentation
-    /// for syntax structure (like Python) rather than brackets/braces for code blocks.
-    #[serde(default)]
-    pub significant_indentation: bool,
     /// If set to true, auto indentation uses last non empty line to determine
     /// the indentation level for a new line.
     #[serde(default = "auto_indent_using_last_non_empty_line_default")]
@@ -702,6 +713,12 @@ pub struct LanguageConfig {
     #[serde(default, deserialize_with = "deserialize_regex")]
     #[schemars(schema_with = "regex_json_schema")]
     pub decrease_indent_pattern: Option<Regex>,
+    /// A list of rules for decreasing indentation. Each rule pairs a regex with a set of valid
+    /// "block-starting" tokens. When a line matches a pattern, its indentation is aligned with
+    /// the most recent line that began with a corresponding token. This enables context-aware
+    /// outdenting, like aligning an `else` with its `if`.
+    #[serde(default)]
+    pub decrease_indent_patterns: Vec<DecreaseIndentConfig>,
     /// A list of characters that trigger the automatic insertion of a closing
     /// bracket when they immediately precede the point where an opening
     /// bracket is inserted.
@@ -755,6 +772,19 @@ pub struct LanguageConfig {
     /// A list of preferred debuggers for this language.
     #[serde(default)]
     pub debuggers: IndexSet<SharedString>,
+    /// Whether to treat documentation comment of this language differently by
+    /// auto adding prefix on new line, adjusting the indenting , etc.
+    #[serde(default)]
+    pub documentation: Option<DocumentationConfig>,
+}
+
+#[derive(Clone, Debug, Deserialize, Default, JsonSchema)]
+pub struct DecreaseIndentConfig {
+    #[serde(default, deserialize_with = "deserialize_regex")]
+    #[schemars(schema_with = "regex_json_schema")]
+    pub pattern: Option<Regex>,
+    #[serde(default)]
+    pub valid_after: Vec<String>,
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
@@ -805,6 +835,19 @@ pub struct JsxTagAutoCloseConfig {
     pub erroneous_close_tag_name_node_name: Option<String>,
 }
 
+/// The configuration for documentation block for this language.
+#[derive(Clone, Deserialize, JsonSchema)]
+pub struct DocumentationConfig {
+    /// A start tag of documentation block.
+    pub start: Arc<str>,
+    /// A end tag of documentation block.
+    pub end: Arc<str>,
+    /// A character to add as a prefix when a new line is added to a documentation block.
+    pub prefix: Arc<str>,
+    /// A indent to add for prefix and end line upon new line.
+    pub tab_size: NonZeroU32,
+}
+
 /// Represents a language for the given range. Some languages (e.g. HTML)
 /// interleave several languages together, thus a single buffer might actually contain
 /// several nested scopes.
@@ -867,6 +910,7 @@ impl Default for LanguageConfig {
             auto_indent_on_paste: None,
             increase_indent_pattern: Default::default(),
             decrease_indent_pattern: Default::default(),
+            decrease_indent_patterns: Default::default(),
             autoclose_before: Default::default(),
             line_comments: Default::default(),
             block_comment: Default::default(),
@@ -882,7 +926,7 @@ impl Default for LanguageConfig {
             jsx_tag_auto_close: None,
             completion_query_characters: Default::default(),
             debuggers: Default::default(),
-            significant_indentation: Default::default(),
+            documentation: None,
         }
     }
 }
@@ -1049,6 +1093,7 @@ pub struct Grammar {
     pub embedding_config: Option<EmbeddingConfig>,
     pub(crate) injection_config: Option<InjectionConfig>,
     pub(crate) override_config: Option<OverrideConfig>,
+    pub(crate) debug_variables_config: Option<DebugVariablesConfig>,
     pub(crate) highlight_map: Mutex<HighlightMap>,
 }
 
@@ -1058,6 +1103,7 @@ struct IndentConfig {
     start_capture_ix: Option<u32>,
     end_capture_ix: Option<u32>,
     outdent_capture_ix: Option<u32>,
+    suffixed_start_captures: HashMap<u32, SharedString>,
 }
 
 pub struct OutlineConfig {
@@ -1071,6 +1117,22 @@ pub struct OutlineConfig {
     pub annotation_capture_ix: Option<u32>,
 }
 
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum DebuggerTextObject {
+    Variable,
+    Scope,
+}
+
+impl DebuggerTextObject {
+    pub fn from_capture_name(name: &str) -> Option<DebuggerTextObject> {
+        match name {
+            "debug-variable" => Some(DebuggerTextObject::Variable),
+            "debug-scope" => Some(DebuggerTextObject::Scope),
+            _ => None,
+        }
+    }
+}
+
 #[derive(Debug, Clone, Copy, PartialEq)]
 pub enum TextObject {
     InsideFunction,
@@ -1173,6 +1235,11 @@ struct BracketsPatternConfig {
     newline_only: bool,
 }
 
+pub struct DebugVariablesConfig {
+    pub query: Query,
+    pub objects_by_capture_ix: Vec<(u32, DebuggerTextObject)>,
+}
+
 impl Language {
     pub fn new(config: LanguageConfig, ts_language: Option<tree_sitter::Language>) -> Self {
         Self::new_with_id(LanguageId::new(), config, ts_language)
@@ -1204,6 +1271,7 @@ impl Language {
                     redactions_config: None,
                     runnable_config: None,
                     error_query: Query::new(&ts_language, "(ERROR) @error").ok(),
+                    debug_variables_config: None,
                     ts_language,
                     highlight_map: Default::default(),
                 })
@@ -1274,21 +1342,22 @@ impl Language {
                 .with_text_object_query(query.as_ref())
                 .context("Error loading textobject query")?;
         }
+        if let Some(query) = queries.debugger {
+            self = self
+                .with_debug_variables_query(query.as_ref())
+                .context("Error loading debug variables query")?;
+        }
         Ok(self)
     }
 
     pub fn with_highlights_query(mut self, source: &str) -> Result<Self> {
-        let grammar = self
-            .grammar_mut()
-            .ok_or_else(|| anyhow!("cannot mutate grammar"))?;
+        let grammar = self.grammar_mut().context("cannot mutate grammar")?;
         grammar.highlights_query = Some(Query::new(&grammar.ts_language, source)?);
         Ok(self)
     }
 
     pub fn with_runnable_query(mut self, source: &str) -> Result<Self> {
-        let grammar = self
-            .grammar_mut()
-            .ok_or_else(|| anyhow!("cannot mutate grammar"))?;
+        let grammar = self.grammar_mut().context("cannot mutate grammar")?;
 
         let query = Query::new(&grammar.ts_language, source)?;
         let mut extra_captures = Vec::with_capacity(query.capture_names().len());
@@ -1311,9 +1380,7 @@ impl Language {
     }
 
     pub fn with_outline_query(mut self, source: &str) -> Result<Self> {
-        let grammar = self
-            .grammar_mut()
-            .ok_or_else(|| anyhow!("cannot mutate grammar"))?;
+        let grammar = self.grammar_mut().context("cannot mutate grammar")?;
         let query = Query::new(&grammar.ts_language, source)?;
         let mut item_capture_ix = None;
         let mut name_capture_ix = None;
@@ -1350,9 +1417,7 @@ impl Language {
     }
 
     pub fn with_text_object_query(mut self, source: &str) -> Result<Self> {
-        let grammar = self
-            .grammar_mut()
-            .ok_or_else(|| anyhow!("cannot mutate grammar"))?;
+        let grammar = self.grammar_mut().context("cannot mutate grammar")?;
         let query = Query::new(&grammar.ts_language, source)?;
 
         let mut text_objects_by_capture_ix = Vec::new();
@@ -1370,9 +1435,7 @@ impl Language {
     }
 
     pub fn with_embedding_query(mut self, source: &str) -> Result<Self> {
-        let grammar = self
-            .grammar_mut()
-            .ok_or_else(|| anyhow!("cannot mutate grammar"))?;
+        let grammar = self.grammar_mut().context("cannot mutate grammar")?;
         let query = Query::new(&grammar.ts_language, source)?;
         let mut item_capture_ix = None;
         let mut name_capture_ix = None;
@@ -1402,10 +1465,26 @@ impl Language {
         Ok(self)
     }
 
+    pub fn with_debug_variables_query(mut self, source: &str) -> Result<Self> {
+        let grammar = self.grammar_mut().context("cannot mutate grammar")?;
+        let query = Query::new(&grammar.ts_language, source)?;
+
+        let mut objects_by_capture_ix = Vec::new();
+        for (ix, name) in query.capture_names().iter().enumerate() {
+            if let Some(text_object) = DebuggerTextObject::from_capture_name(name) {
+                objects_by_capture_ix.push((ix as u32, text_object));
+            }
+        }
+
+        grammar.debug_variables_config = Some(DebugVariablesConfig {
+            query,
+            objects_by_capture_ix,
+        });
+        Ok(self)
+    }
+
     pub fn with_brackets_query(mut self, source: &str) -> Result<Self> {
-        let grammar = self
-            .grammar_mut()
-            .ok_or_else(|| anyhow!("cannot mutate grammar"))?;
+        let grammar = self.grammar_mut().context("cannot mutate grammar")?;
         let query = Query::new(&grammar.ts_language, source)?;
         let mut open_capture_ix = None;
         let mut close_capture_ix = None;
@@ -1440,9 +1519,7 @@ impl Language {
     }
 
     pub fn with_indents_query(mut self, source: &str) -> Result<Self> {
-        let grammar = self
-            .grammar_mut()
-            .ok_or_else(|| anyhow!("cannot mutate grammar"))?;
+        let grammar = self.grammar_mut().context("cannot mutate grammar")?;
         let query = Query::new(&grammar.ts_language, source)?;
         let mut indent_capture_ix = None;
         let mut start_capture_ix = None;
@@ -1457,6 +1534,14 @@ impl Language {
                 ("outdent", &mut outdent_capture_ix),
             ],
         );
+
+        let mut suffixed_start_captures = HashMap::default();
+        for (ix, name) in query.capture_names().iter().enumerate() {
+            if let Some(suffix) = name.strip_prefix("start.") {
+                suffixed_start_captures.insert(ix as u32, suffix.to_owned().into());
+            }
+        }
+
         if let Some(indent_capture_ix) = indent_capture_ix {
             grammar.indents_config = Some(IndentConfig {
                 query,
@@ -1464,15 +1549,14 @@ impl Language {
                 start_capture_ix,
                 end_capture_ix,
                 outdent_capture_ix,
+                suffixed_start_captures,
             });
         }
         Ok(self)
     }
 
     pub fn with_injection_query(mut self, source: &str) -> Result<Self> {
-        let grammar = self
-            .grammar_mut()
-            .ok_or_else(|| anyhow!("cannot mutate grammar"))?;
+        let grammar = self.grammar_mut().context("cannot mutate grammar")?;
         let query = Query::new(&grammar.ts_language, source)?;
         let mut language_capture_ix = None;
         let mut injection_language_capture_ix = None;
@@ -1490,18 +1574,14 @@ impl Language {
         language_capture_ix = match (language_capture_ix, injection_language_capture_ix) {
             (None, Some(ix)) => Some(ix),
             (Some(_), Some(_)) => {
-                return Err(anyhow!(
-                    "both language and injection.language captures are present"
-                ));
+                anyhow::bail!("both language and injection.language captures are present");
             }
             _ => language_capture_ix,
         };
         content_capture_ix = match (content_capture_ix, injection_content_capture_ix) {
             (None, Some(ix)) => Some(ix),
             (Some(_), Some(_)) => {
-                return Err(anyhow!(
-                    "both content and injection.content captures are present"
-                ));
+                anyhow::bail!("both content and injection.content captures are present")
             }
             _ => content_capture_ix,
         };
@@ -1535,10 +1615,7 @@ impl Language {
 
     pub fn with_override_query(mut self, source: &str) -> anyhow::Result<Self> {
         let query = {
-            let grammar = self
-                .grammar
-                .as_ref()
-                .ok_or_else(|| anyhow!("no grammar for language"))?;
+            let grammar = self.grammar.as_ref().context("no grammar for language")?;
             Query::new(&grammar.ts_language, source)?
         };
 
@@ -1589,10 +1666,10 @@ impl Language {
                 .values()
                 .any(|entry| entry.name == *referenced_name)
             {
-                Err(anyhow!(
+                anyhow::bail!(
                     "language {:?} has overrides in config not in query: {referenced_name:?}",
                     self.config.name
-                ))?;
+                );
             }
         }
 
@@ -1615,9 +1692,7 @@ impl Language {
 
         self.config.brackets.disabled_scopes_by_bracket_ix.clear();
 
-        let grammar = self
-            .grammar_mut()
-            .ok_or_else(|| anyhow!("cannot mutate grammar"))?;
+        let grammar = self.grammar_mut().context("cannot mutate grammar")?;
         grammar.override_config = Some(OverrideConfig {
             query,
             values: override_configs_by_id,
@@ -1626,9 +1701,7 @@ impl Language {
     }
 
     pub fn with_redaction_query(mut self, source: &str) -> anyhow::Result<Self> {
-        let grammar = self
-            .grammar_mut()
-            .ok_or_else(|| anyhow!("cannot mutate grammar"))?;
+        let grammar = self.grammar_mut().context("cannot mutate grammar")?;
 
         let query = Query::new(&grammar.ts_language, source)?;
         let mut redaction_capture_ix = None;
@@ -1802,6 +1875,14 @@ impl LanguageScope {
             .unwrap_or(false)
     }
 
+    /// Returns config to documentation block for this language.
+    ///
+    /// Used for documentation styles that require a leading character on each line,
+    /// such as the asterisk in JSDoc, Javadoc, etc.
+    pub fn documentation(&self) -> Option<&DocumentationConfig> {
+        self.language.config.documentation.as_ref()
+    }
+
     /// Returns a list of bracket pairs for a given language with an additional
     /// piece of information about whether the particular bracket pair is currently active for a given language.
     pub fn brackets(&self) -> impl Iterator<Item = (&BracketPair, bool)> {
@@ -1833,9 +1914,9 @@ impl LanguageScope {
     pub fn language_allowed(&self, name: &LanguageServerName) -> bool {
         let config = &self.language.config;
         let opt_in_servers = &config.scope_opt_in_language_servers;
-        if opt_in_servers.iter().any(|o| *o == *name) {
+        if opt_in_servers.contains(name) {
             if let Some(over) = self.config_override() {
-                over.opt_into_language_servers.iter().any(|o| *o == *name)
+                over.opt_into_language_servers.contains(name)
             } else {
                 false
             }
@@ -1916,6 +1997,10 @@ impl Grammar {
             .capture_index_for_name(name)?;
         Some(self.highlight_map.lock().get(capture_id))
     }
+
+    pub fn debug_variables_config(&self) -> Option<&DebugVariablesConfig> {
+        self.debug_variables_config.as_ref()
+    }
 }
 
 impl CodeLabel {
@@ -1968,25 +2053,27 @@ impl CodeLabel {
         } else {
             label.clone()
         };
+        let filter_range = item
+            .filter_text
+            .as_deref()
+            .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
+            .unwrap_or(0..label_length);
         Self {
             text,
             runs,
-            filter_range: 0..label_length,
+            filter_range,
         }
     }
 
     pub fn plain(text: String, filter_text: Option<&str>) -> Self {
-        let mut result = Self {
+        let filter_range = filter_text
+            .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
+            .unwrap_or(0..text.len());
+        Self {
             runs: Vec::new(),
-            filter_range: 0..text.len(),
+            filter_range,
             text,
-        };
-        if let Some(filter_text) = filter_text {
-            if let Some(ix) = result.text.find(filter_text) {
-                result.filter_range = ix..ix + filter_text.len();
-            }
         }
-        result
     }
 
     pub fn push_str(&mut self, text: &str, highlight: Option<HighlightId>) {
@@ -2164,18 +2251,16 @@ pub fn point_from_lsp(point: lsp::Position) -> Unclipped<PointUtf16> {
 }
 
 pub fn range_to_lsp(range: Range<PointUtf16>) -> Result<lsp::Range> {
-    if range.start > range.end {
-        Err(anyhow!(
-            "Inverted range provided to an LSP request: {:?}-{:?}",
-            range.start,
-            range.end
-        ))
-    } else {
-        Ok(lsp::Range {
-            start: point_to_lsp(range.start),
-            end: point_to_lsp(range.end),
-        })
-    }
+    anyhow::ensure!(
+        range.start <= range.end,
+        "Inverted range provided to an LSP request: {:?}-{:?}",
+        range.start,
+        range.end
+    );
+    Ok(lsp::Range {
+        start: point_to_lsp(range.start),
+        end: point_to_lsp(range.end),
+    })
 }
 
 pub fn range_from_lsp(range: lsp::Range) -> Range<Unclipped<PointUtf16>> {

crates/language/src/language_registry.rs 🔗

@@ -16,8 +16,6 @@ use futures::{
 };
 use globset::GlobSet;
 use gpui::{App, BackgroundExecutor, SharedString};
-use itertools::FoldWhile::{Continue, Done};
-use itertools::Itertools;
 use lsp::LanguageServerId;
 use parking_lot::{Mutex, RwLock};
 use postage::watch;
@@ -41,7 +39,7 @@ use util::{ResultExt, maybe, post_inc};
 #[derive(
     Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
 )]
-pub struct LanguageName(SharedString);
+pub struct LanguageName(pub SharedString);
 
 impl LanguageName {
     pub fn new(s: &str) -> Self {
@@ -68,6 +66,12 @@ impl From<LanguageName> for SharedString {
     }
 }
 
+impl From<SharedString> for LanguageName {
+    fn from(value: SharedString) -> Self {
+        LanguageName(value)
+    }
+}
+
 impl AsRef<str> for LanguageName {
     fn as_ref(&self) -> &str {
         self.0.as_ref()
@@ -103,7 +107,7 @@ pub struct LanguageRegistry {
     state: RwLock<LanguageRegistryState>,
     language_server_download_dir: Option<Arc<Path>>,
     executor: BackgroundExecutor,
-    lsp_binary_status_tx: BinaryStatusSender,
+    lsp_binary_status_tx: ServerStatusSender,
 }
 
 struct LanguageRegistryState {
@@ -134,11 +138,28 @@ pub struct FakeLanguageServerEntry {
     pub _server: Option<lsp::FakeLanguageServer>,
 }
 
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum LanguageServerStatusUpdate {
+    Binary(BinaryStatus),
+    Health(ServerHealth, Option<SharedString>),
+}
+
+#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone, Copy)]
+#[serde(rename_all = "camelCase")]
+pub enum ServerHealth {
+    Ok,
+    Warning,
+    Error,
+}
+
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum BinaryStatus {
     None,
     CheckingForUpdate,
     Downloading,
+    Starting,
+    Stopping,
+    Stopped,
     Failed { error: String },
 }
 
@@ -167,18 +188,12 @@ impl AvailableLanguage {
     }
 }
 
-#[derive(Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Copy, Clone, Default)]
 enum LanguageMatchPrecedence {
     #[default]
     Undetermined,
-    PathOrContent,
-    UserConfigured,
-}
-
-impl LanguageMatchPrecedence {
-    fn best_possible_match(&self) -> bool {
-        *self == LanguageMatchPrecedence::UserConfigured
-    }
+    PathOrContent(usize),
+    UserConfigured(usize),
 }
 
 enum AvailableGrammar {
@@ -214,7 +229,7 @@ pub const QUERY_FILENAME_PREFIXES: &[(
     ("overrides", |q| &mut q.overrides),
     ("redactions", |q| &mut q.redactions),
     ("runnables", |q| &mut q.runnables),
-    ("debug_variables", |q| &mut q.debug_variables),
+    ("debugger", |q| &mut q.debugger),
     ("textobjects", |q| &mut q.text_objects),
 ];
 
@@ -231,12 +246,12 @@ pub struct LanguageQueries {
     pub redactions: Option<Cow<'static, str>>,
     pub runnables: Option<Cow<'static, str>>,
     pub text_objects: Option<Cow<'static, str>>,
-    pub debug_variables: Option<Cow<'static, str>>,
+    pub debugger: Option<Cow<'static, str>>,
 }
 
 #[derive(Clone, Default)]
-struct BinaryStatusSender {
-    txs: Arc<Mutex<Vec<mpsc::UnboundedSender<(SharedString, BinaryStatus)>>>>,
+struct ServerStatusSender {
+    txs: Arc<Mutex<Vec<mpsc::UnboundedSender<(LanguageServerName, BinaryStatus)>>>>,
 }
 
 pub struct LoadedLanguage {
@@ -620,26 +635,57 @@ impl LanguageRegistry {
     ) -> impl Future<Output = Result<Arc<Language>>> + use<> {
         let name = UniCase::new(name);
         let rx = self.get_or_load_language(|language_name, _, current_best_match| {
-            (current_best_match < LanguageMatchPrecedence::PathOrContent
-                && UniCase::new(&language_name.0) == name)
-                .then_some(LanguageMatchPrecedence::PathOrContent)
+            match current_best_match {
+                LanguageMatchPrecedence::Undetermined if UniCase::new(&language_name.0) == name => {
+                    Some(LanguageMatchPrecedence::PathOrContent(name.len()))
+                }
+                LanguageMatchPrecedence::Undetermined
+                | LanguageMatchPrecedence::UserConfigured(_)
+                | LanguageMatchPrecedence::PathOrContent(_) => None,
+            }
         });
         async move { rx.await? }
     }
 
+    pub fn language_name_for_extension(self: &Arc<Self>, extension: &str) -> Option<LanguageName> {
+        self.state.try_read().and_then(|state| {
+            state
+                .available_languages
+                .iter()
+                .find(|language| {
+                    language
+                        .matcher()
+                        .path_suffixes
+                        .iter()
+                        .any(|suffix| *suffix == extension)
+                })
+                .map(|language| language.name.clone())
+        })
+    }
+
     pub fn language_for_name_or_extension(
         self: &Arc<Self>,
         string: &str,
     ) -> impl Future<Output = Result<Arc<Language>>> {
         let string = UniCase::new(string);
         let rx = self.get_or_load_language(|name, config, current_best_match| {
-            (current_best_match < LanguageMatchPrecedence::PathOrContent
-                && (UniCase::new(&name.0) == string
+            let name_matches = || {
+                UniCase::new(&name.0) == string
                     || config
                         .path_suffixes
                         .iter()
-                        .any(|suffix| UniCase::new(suffix) == string)))
-            .then_some(LanguageMatchPrecedence::PathOrContent)
+                        .any(|suffix| UniCase::new(suffix) == string)
+            };
+
+            match current_best_match {
+                LanguageMatchPrecedence::Undetermined => {
+                    name_matches().then_some(LanguageMatchPrecedence::PathOrContent(string.len()))
+                }
+                LanguageMatchPrecedence::PathOrContent(len) => (string.len() > len
+                    && name_matches())
+                .then_some(LanguageMatchPrecedence::PathOrContent(string.len())),
+                LanguageMatchPrecedence::UserConfigured(_) => None,
+            }
         });
         async move { rx.await? }
     }
@@ -695,10 +741,9 @@ impl LanguageRegistry {
         // and no other extension which is not the desired behavior here,
         // as we want `.zshrc` to result in extension being `Some("zshrc")`
         let extension = filename.and_then(|filename| filename.split('.').next_back());
-        let path_suffixes = [extension, filename, path.to_str()];
-        let path_suffixes_candidates = path_suffixes
+        let path_suffixes = [extension, filename, path.to_str()]
             .iter()
-            .filter_map(|suffix| suffix.map(globset::Candidate::new))
+            .filter_map(|suffix| suffix.map(|suffix| (suffix, globset::Candidate::new(suffix))))
             .collect::<SmallVec<[_; 3]>>();
         let content = LazyCell::new(|| {
             content.map(|content| {
@@ -709,20 +754,37 @@ impl LanguageRegistry {
         });
         self.find_matching_language(move |language_name, config, current_best_match| {
             let path_matches_default_suffix = || {
-                config
-                    .path_suffixes
-                    .iter()
-                    .any(|suffix| path_suffixes.contains(&Some(suffix.as_str())))
+                let len =
+                    config
+                        .path_suffixes
+                        .iter()
+                        .fold(0, |acc: usize, path_suffix: &String| {
+                            let ext = ".".to_string() + path_suffix;
+
+                            let matched_suffix_len = path_suffixes
+                                .iter()
+                                .find(|(suffix, _)| suffix.ends_with(&ext) || suffix == path_suffix)
+                                .map(|(suffix, _)| suffix.len());
+
+                            match matched_suffix_len {
+                                Some(len) => acc.max(len),
+                                None => acc,
+                            }
+                        });
+                (len > 0).then_some(len)
             };
+
             let path_matches_custom_suffix = || {
                 user_file_types
                     .and_then(|types| types.get(language_name.as_ref()))
-                    .map_or(false, |custom_suffixes| {
-                        path_suffixes_candidates
+                    .map_or(None, |custom_suffixes| {
+                        path_suffixes
                             .iter()
-                            .any(|suffix| custom_suffixes.is_match_candidate(suffix))
+                            .find(|(_, candidate)| custom_suffixes.is_match_candidate(candidate))
+                            .map(|(suffix, _)| suffix.len())
                     })
             };
+
             let content_matches = || {
                 config.first_line_pattern.as_ref().map_or(false, |pattern| {
                     content
@@ -734,17 +796,29 @@ impl LanguageRegistry {
             // Only return a match for the given file if we have a better match than
             // the current one.
             match current_best_match {
-                LanguageMatchPrecedence::PathOrContent | LanguageMatchPrecedence::Undetermined
-                    if path_matches_custom_suffix() =>
-                {
-                    Some(LanguageMatchPrecedence::UserConfigured)
+                LanguageMatchPrecedence::PathOrContent(current_len) => {
+                    if let Some(len) = path_matches_custom_suffix() {
+                        // >= because user config should win tie with system ext len
+                        (len >= current_len).then_some(LanguageMatchPrecedence::UserConfigured(len))
+                    } else if let Some(len) = path_matches_default_suffix() {
+                        // >= because user config should win tie with system ext len
+                        (len >= current_len).then_some(LanguageMatchPrecedence::PathOrContent(len))
+                    } else {
+                        None
+                    }
                 }
-                LanguageMatchPrecedence::Undetermined
-                    if path_matches_default_suffix() || content_matches() =>
-                {
-                    Some(LanguageMatchPrecedence::PathOrContent)
+                LanguageMatchPrecedence::Undetermined => {
+                    if let Some(len) = path_matches_custom_suffix() {
+                        Some(LanguageMatchPrecedence::UserConfigured(len))
+                    } else if let Some(len) = path_matches_default_suffix() {
+                        Some(LanguageMatchPrecedence::PathOrContent(len))
+                    } else if content_matches() {
+                        Some(LanguageMatchPrecedence::PathOrContent(1))
+                    } else {
+                        None
+                    }
                 }
-                _ => None,
+                LanguageMatchPrecedence::UserConfigured(_) => None,
             }
         })
     }
@@ -762,28 +836,61 @@ impl LanguageRegistry {
             .available_languages
             .iter()
             .rev()
-            .fold_while(None, |best_language_match, language| {
+            .fold(None, |best_language_match, language| {
                 let current_match_type = best_language_match
                     .as_ref()
                     .map_or(LanguageMatchPrecedence::default(), |(_, score)| *score);
                 let language_score =
                     callback(&language.name, &language.matcher, current_match_type);
-                debug_assert!(
-                    language_score.is_none_or(|new_score| new_score > current_match_type),
-                    "Matching callback should only return a better match than the current one"
-                );
-
-                match language_score {
-                    Some(new_score) if new_score.best_possible_match() => {
-                        Done(Some((language.clone(), new_score)))
+
+                match (language_score, current_match_type) {
+                    // no current best, so our candidate is better
+                    (
+                        Some(
+                            LanguageMatchPrecedence::PathOrContent(_)
+                            | LanguageMatchPrecedence::UserConfigured(_),
+                        ),
+                        LanguageMatchPrecedence::Undetermined,
+                    ) => language_score.map(|new_score| (language.clone(), new_score)),
+
+                    // our candidate is better only if the name is longer
+                    (
+                        Some(LanguageMatchPrecedence::PathOrContent(new_len)),
+                        LanguageMatchPrecedence::PathOrContent(current_len),
+                    )
+                    | (
+                        Some(LanguageMatchPrecedence::UserConfigured(new_len)),
+                        LanguageMatchPrecedence::UserConfigured(current_len),
+                    )
+                    | (
+                        Some(LanguageMatchPrecedence::PathOrContent(new_len)),
+                        LanguageMatchPrecedence::UserConfigured(current_len),
+                    ) => {
+                        if new_len > current_len {
+                            language_score.map(|new_score| (language.clone(), new_score))
+                        } else {
+                            best_language_match
+                        }
                     }
-                    Some(new_score) if current_match_type < new_score => {
-                        Continue(Some((language.clone(), new_score)))
+
+                    // our candidate is better if the name is longer or equal to
+                    (
+                        Some(LanguageMatchPrecedence::UserConfigured(new_len)),
+                        LanguageMatchPrecedence::PathOrContent(current_len),
+                    ) => {
+                        if new_len >= current_len {
+                            language_score.map(|new_score| (language.clone(), new_score))
+                        } else {
+                            best_language_match
+                        }
+                    }
+
+                    // no candidate, use current best
+                    (None, _) | (Some(LanguageMatchPrecedence::Undetermined), _) => {
+                        best_language_match
                     }
-                    _ => Continue(best_language_match),
                 }
             })
-            .into_inner()
             .map(|(available_language, _)| available_language);
         drop(state);
         available_language
@@ -851,15 +958,13 @@ impl LanguageRegistry {
                                 }
                             }
                             Err(e) => {
-                                log::error!("failed to load language {name}:\n{:?}", e);
+                                log::error!("failed to load language {name}:\n{e:?}");
                                 let mut state = this.state.write();
                                 state.mark_language_loaded(id);
                                 if let Some(mut txs) = state.loading_languages.remove(&id) {
                                     for tx in txs.drain(..) {
                                         let _ = tx.send(Err(anyhow!(
-                                            "failed to load language {}: {}",
-                                            name,
-                                            e
+                                            "failed to load language {name}: {e}",
                                         )));
                                     }
                                 }
@@ -912,6 +1017,7 @@ impl LanguageRegistry {
                     txs.push(tx);
                 }
                 AvailableGrammar::Unloaded(wasm_path) => {
+                    log::trace!("start loading grammar {name:?}");
                     let this = self.clone();
                     let wasm_path = wasm_path.clone();
                     *grammar = AvailableGrammar::Loading(wasm_path.clone(), vec![tx]);
@@ -922,7 +1028,7 @@ impl LanguageRegistry {
                                 let grammar_name = wasm_path
                                     .file_stem()
                                     .and_then(OsStr::to_str)
-                                    .ok_or_else(|| anyhow!("invalid grammar filename"))?;
+                                    .context("invalid grammar filename")?;
                                 anyhow::Ok(with_parser(|parser| {
                                     let mut store = parser.take_wasm_store().unwrap();
                                     let grammar = store.load_language(grammar_name, &wasm_bytes);
@@ -937,6 +1043,7 @@ impl LanguageRegistry {
                                 Err(error) => AvailableGrammar::LoadFailed(error.clone()),
                             };
 
+                            log::trace!("finish loading grammar {name:?}");
                             let old_value = this.state.write().grammars.insert(name, value);
                             if let Some(AvailableGrammar::Loading(_, txs)) = old_value {
                                 for tx in txs {
@@ -948,7 +1055,7 @@ impl LanguageRegistry {
                 }
             }
         } else {
-            tx.send(Err(Arc::new(anyhow!("no such grammar {}", name))))
+            tx.send(Err(Arc::new(anyhow!("no such grammar {name}"))))
                 .ok();
         }
 
@@ -981,8 +1088,8 @@ impl LanguageRegistry {
         self.state.read().all_lsp_adapters.get(name).cloned()
     }
 
-    pub fn update_lsp_status(&self, server_name: LanguageServerName, status: BinaryStatus) {
-        self.lsp_binary_status_tx.send(server_name.0, status);
+    pub fn update_lsp_binary_status(&self, server_name: LanguageServerName, status: BinaryStatus) {
+        self.lsp_binary_status_tx.send(server_name, status);
     }
 
     pub fn next_language_server_id(&self) -> LanguageServerId {
@@ -1037,7 +1144,7 @@ impl LanguageRegistry {
 
     pub fn language_server_binary_statuses(
         &self,
-    ) -> mpsc::UnboundedReceiver<(SharedString, BinaryStatus)> {
+    ) -> mpsc::UnboundedReceiver<(LanguageServerName, BinaryStatus)> {
         self.lsp_binary_status_tx.subscribe()
     }
 
@@ -1151,14 +1258,14 @@ impl LanguageRegistryState {
     }
 }
 
-impl BinaryStatusSender {
-    fn subscribe(&self) -> mpsc::UnboundedReceiver<(SharedString, BinaryStatus)> {
+impl ServerStatusSender {
+    fn subscribe(&self) -> mpsc::UnboundedReceiver<(LanguageServerName, BinaryStatus)> {
         let (tx, rx) = mpsc::unbounded();
         self.txs.lock().push(tx);
         rx
     }
 
-    fn send(&self, name: SharedString, status: BinaryStatus) {
+    fn send(&self, name: LanguageServerName, status: BinaryStatus) {
         let mut txs = self.txs.lock();
         txs.retain(|tx| tx.unbounded_send((name.clone(), status.clone())).is_ok());
     }

crates/language/src/language_settings.rs 🔗

@@ -23,6 +23,7 @@ use serde_json::Value;
 use settings::{
     Settings, SettingsLocation, SettingsSources, SettingsStore, add_references_to_properties,
 };
+use shellexpand;
 use std::{borrow::Cow, num::NonZeroU32, path::Path, sync::Arc};
 use util::serde::default_true;
 
@@ -287,6 +288,8 @@ pub struct CopilotSettings {
     pub proxy: Option<String>,
     /// Disable certificate verification for proxy (not recommended).
     pub proxy_no_verify: Option<bool>,
+    /// Enterprise URI for Copilot.
+    pub enterprise_uri: Option<String>,
 }
 
 /// The settings for all languages.
@@ -381,6 +384,7 @@ fn default_lsp_fetch_timeout_ms() -> u64 {
 
 /// The settings for a particular language.
 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[schemars(deny_unknown_fields)]
 pub struct LanguageSettingsContent {
     /// How many columns a tab should occupy.
     ///
@@ -605,6 +609,11 @@ pub struct CopilotSettingsContent {
     /// Default: false
     #[serde(default)]
     pub proxy_no_verify: Option<bool>,
+    /// Enterprise URI for Copilot.
+    ///
+    /// Default: none
+    #[serde(default)]
+    pub enterprise_uri: Option<String>,
 }
 
 /// The settings for enabling/disabling features.
@@ -763,6 +772,8 @@ pub enum ShowWhitespaceSetting {
     /// - It is adjacent to an edge (start or end)
     /// - It is adjacent to a whitespace (left or right)
     Boundary,
+    /// Draw whitespaces only after non-whitespace characters.
+    Trailing,
 }
 
 /// Controls which formatter should be used when formatting code.
@@ -979,8 +990,8 @@ pub struct InlayHintSettings {
     pub enabled: bool,
     /// Global switch to toggle inline values on and off.
     ///
-    /// Default: false
-    #[serde(default)]
+    /// Default: true
+    #[serde(default = "default_true")]
     pub show_value_hints: bool,
     /// Whether type hints should be shown.
     ///
@@ -1044,6 +1055,15 @@ pub struct LanguageTaskConfig {
     pub variables: HashMap<String, String>,
     #[serde(default = "default_true")]
     pub enabled: bool,
+    /// Use LSP tasks over Zed language extension ones.
+    /// If no LSP tasks are returned due to error/timeout or regular execution,
+    /// Zed language extension tasks will be used instead.
+    ///
+    /// Other Zed tasks will still be shown:
+    /// * Zed task from either of the task config file
+    /// * Zed task from history (e.g. one-off task was spawned before)
+    #[serde(default = "default_true")]
+    pub prefer_lsp: bool,
 }
 
 impl InlayHintSettings {
@@ -1215,10 +1235,10 @@ impl settings::Settings for AllLanguageSettings {
         let mut copilot_settings = default_value
             .edit_predictions
             .as_ref()
-            .map(|settings| settings.copilot.clone())
-            .map(|copilot| CopilotSettings {
-                proxy: copilot.proxy,
-                proxy_no_verify: copilot.proxy_no_verify,
+            .map(|settings| CopilotSettings {
+                proxy: settings.copilot.proxy.clone(),
+                proxy_no_verify: settings.copilot.proxy_no_verify,
+                enterprise_uri: settings.copilot.enterprise_uri.clone(),
             })
             .unwrap_or_default();
 
@@ -1274,6 +1294,14 @@ impl settings::Settings for AllLanguageSettings {
                 copilot_settings.proxy_no_verify = Some(proxy_no_verify);
             }
 
+            if let Some(enterprise_uri) = user_settings
+                .edit_predictions
+                .as_ref()
+                .and_then(|settings| settings.copilot.enterprise_uri.clone())
+            {
+                copilot_settings.enterprise_uri = Some(enterprise_uri);
+            }
+
             // A user's global settings override the default global settings and
             // all default language-specific settings.
             merge_settings(&mut defaults, &user_settings.defaults);
@@ -1321,9 +1349,10 @@ impl settings::Settings for AllLanguageSettings {
                 disabled_globs: completion_globs
                     .iter()
                     .filter_map(|g| {
+                        let expanded_g = shellexpand::tilde(g).into_owned();
                         Some(DisabledGlob {
-                            matcher: globset::Glob::new(g).ok()?.compile_matcher(),
-                            is_absolute: Path::new(g).is_absolute(),
+                            matcher: globset::Glob::new(&expanded_g).ok()?.compile_matcher(),
+                            is_absolute: Path::new(&expanded_g).is_absolute(),
                         })
                     })
                     .collect(),
@@ -1440,7 +1469,8 @@ impl settings::Settings for AllLanguageSettings {
         vscode.bool_setting("editor.inlineSuggest.enabled", &mut d.show_edit_predictions);
         vscode.enum_setting("editor.renderWhitespace", &mut d.show_whitespaces, |s| {
             Some(match s {
-                "boundary" | "trailing" => ShowWhitespaceSetting::Boundary,
+                "boundary" => ShowWhitespaceSetting::Boundary,
+                "trailing" => ShowWhitespaceSetting::Trailing,
                 "selection" => ShowWhitespaceSetting::Selection,
                 "all" => ShowWhitespaceSetting::All,
                 _ => ShowWhitespaceSetting::None,
@@ -1492,8 +1522,27 @@ impl settings::Settings for AllLanguageSettings {
                 associations.entry(v.into()).or_default().push(k.clone());
             }
         }
+
         // TODO: do we want to merge imported globs per filetype? for now we'll just replace
         current.file_types.extend(associations);
+
+        // cursor global ignore list applies to cursor-tab, so transfer it to edit_predictions.disabled_globs
+        if let Some(disabled_globs) = vscode
+            .read_value("cursor.general.globalCursorIgnoreList")
+            .and_then(|v| v.as_array())
+        {
+            current
+                .edit_predictions
+                .get_or_insert_default()
+                .disabled_globs
+                .get_or_insert_default()
+                .extend(
+                    disabled_globs
+                        .iter()
+                        .filter_map(|glob| glob.as_str())
+                        .map(|s| s.to_string()),
+                );
+        }
     }
 }
 
@@ -1683,10 +1732,12 @@ mod tests {
                         };
                         #[cfg(windows)]
                         let glob_str = glob_str.as_str();
-
+                        let expanded_glob_str = shellexpand::tilde(glob_str).into_owned();
                         DisabledGlob {
-                            matcher: globset::Glob::new(glob_str).unwrap().compile_matcher(),
-                            is_absolute: Path::new(glob_str).is_absolute(),
+                            matcher: globset::Glob::new(&expanded_glob_str)
+                                .unwrap()
+                                .compile_matcher(),
+                            is_absolute: Path::new(&expanded_glob_str).is_absolute(),
                         }
                     })
                     .collect(),
@@ -1782,10 +1833,16 @@ mod tests {
         let dot_env_file = make_test_file(&[".env"]);
         let settings = build_settings(&[".env"]);
         assert!(!settings.enabled_for_file(&dot_env_file, &cx));
+
+        // Test tilde expansion
+        let home = shellexpand::tilde("~").into_owned().to_string();
+        let home_file = make_test_file(&[&home, "test.rs"]);
+        let settings = build_settings(&["~/test.rs"]);
+        assert!(!settings.enabled_for_file(&home_file, &cx));
     }
 
     #[test]
-    pub fn test_resolve_language_servers() {
+    fn test_resolve_language_servers() {
         fn language_server_names(names: &[&str]) -> Vec<LanguageServerName> {
             names
                 .iter()

crates/language/src/manifest.rs 🔗

@@ -1,8 +1,7 @@
 use std::{borrow::Borrow, path::Path, sync::Arc};
 
 use gpui::SharedString;
-
-use crate::LspAdapterDelegate;
+use settings::WorktreeId;
 
 #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
 pub struct ManifestName(SharedString);
@@ -39,10 +38,15 @@ pub struct ManifestQuery {
     /// Path to the file, relative to worktree root.
     pub path: Arc<Path>,
     pub depth: usize,
-    pub delegate: Arc<dyn LspAdapterDelegate>,
+    pub delegate: Arc<dyn ManifestDelegate>,
 }
 
 pub trait ManifestProvider {
     fn name(&self) -> ManifestName;
     fn search(&self, query: ManifestQuery) -> Option<Arc<Path>>;
 }
+
+pub trait ManifestDelegate: Send + Sync {
+    fn worktree_id(&self) -> WorktreeId;
+    fn exists(&self, path: &Path, is_dir: Option<bool>) -> bool;
+}

crates/language/src/proto.rs 🔗

@@ -1,7 +1,7 @@
 //! Handles conversions of `language` items to and from the [`rpc`] protocol.
 
-use crate::{CursorShape, Diagnostic, diagnostic_set::DiagnosticEntry};
-use anyhow::{Context as _, Result, anyhow};
+use crate::{CursorShape, Diagnostic, DiagnosticSourceKind, diagnostic_set::DiagnosticEntry};
+use anyhow::{Context as _, Result};
 use clock::ReplicaId;
 use lsp::{DiagnosticSeverity, LanguageServerId};
 use rpc::proto;
@@ -11,6 +11,8 @@ use text::*;
 
 pub use proto::{BufferState, File, Operation};
 
+use super::{point_from_lsp, point_to_lsp};
+
 /// Deserializes a `[text::LineEnding]` from the RPC representation.
 pub fn deserialize_line_ending(message: proto::LineEnding) -> text::LineEnding {
     match message {
@@ -200,6 +202,11 @@ pub fn serialize_diagnostics<'a>(
         .into_iter()
         .map(|entry| proto::Diagnostic {
             source: entry.diagnostic.source.clone(),
+            source_kind: match entry.diagnostic.source_kind {
+                DiagnosticSourceKind::Pulled => proto::diagnostic::SourceKind::Pulled,
+                DiagnosticSourceKind::Pushed => proto::diagnostic::SourceKind::Pushed,
+                DiagnosticSourceKind::Other => proto::diagnostic::SourceKind::Other,
+            } as i32,
             start: Some(serialize_anchor(&entry.range.start)),
             end: Some(serialize_anchor(&entry.range.end)),
             message: entry.diagnostic.message.clone(),
@@ -213,6 +220,7 @@ pub fn serialize_diagnostics<'a>(
             } as i32,
             group_id: entry.diagnostic.group_id as u64,
             is_primary: entry.diagnostic.is_primary,
+            underline: entry.diagnostic.underline,
             code: entry.diagnostic.code.as_ref().map(|s| s.to_string()),
             code_description: entry
                 .diagnostic
@@ -259,10 +267,7 @@ pub fn deserialize_anchor_range(range: proto::AnchorRange) -> Result<Range<Ancho
 /// Deserializes an [`crate::Operation`] from the RPC representation.
 pub fn deserialize_operation(message: proto::Operation) -> Result<crate::Operation> {
     Ok(
-        match message
-            .variant
-            .ok_or_else(|| anyhow!("missing operation variant"))?
-        {
+        match message.variant.context("missing operation variant")? {
             proto::operation::Variant::Edit(edit) => {
                 crate::Operation::Buffer(text::Operation::Edit(deserialize_edit_operation(edit)))
             }
@@ -312,7 +317,7 @@ pub fn deserialize_operation(message: proto::Operation) -> Result<crate::Operati
                     line_mode: message.line_mode,
                     cursor_shape: deserialize_cursor_shape(
                         proto::CursorShape::from_i32(message.cursor_shape)
-                            .ok_or_else(|| anyhow!("Missing cursor shape"))?,
+                            .context("Missing cursor shape")?,
                     ),
                 }
             }
@@ -432,6 +437,14 @@ pub fn deserialize_diagnostics(
                     is_primary: diagnostic.is_primary,
                     is_disk_based: diagnostic.is_disk_based,
                     is_unnecessary: diagnostic.is_unnecessary,
+                    underline: diagnostic.underline,
+                    source_kind: match proto::diagnostic::SourceKind::from_i32(
+                        diagnostic.source_kind,
+                    )? {
+                        proto::diagnostic::SourceKind::Pulled => DiagnosticSourceKind::Pulled,
+                        proto::diagnostic::SourceKind::Pushed => DiagnosticSourceKind::Pushed,
+                        proto::diagnostic::SourceKind::Other => DiagnosticSourceKind::Other,
+                    },
                     data,
                 },
             })
@@ -510,11 +523,7 @@ pub fn serialize_transaction(transaction: &Transaction) -> proto::Transaction {
 /// Deserializes a [`Transaction`] from the RPC representation.
 pub fn deserialize_transaction(transaction: proto::Transaction) -> Result<Transaction> {
     Ok(Transaction {
-        id: deserialize_timestamp(
-            transaction
-                .id
-                .ok_or_else(|| anyhow!("missing transaction id"))?,
-        ),
+        id: deserialize_timestamp(transaction.id.context("missing transaction id")?),
         edit_ids: transaction
             .edit_ids
             .into_iter()
@@ -575,3 +584,33 @@ pub fn serialize_version(version: &clock::Global) -> Vec<proto::VectorClockEntry
         })
         .collect()
 }
+
+pub fn serialize_lsp_edit(edit: lsp::TextEdit) -> proto::TextEdit {
+    let start = point_from_lsp(edit.range.start).0;
+    let end = point_from_lsp(edit.range.end).0;
+    proto::TextEdit {
+        new_text: edit.new_text,
+        lsp_range_start: Some(proto::PointUtf16 {
+            row: start.row,
+            column: start.column,
+        }),
+        lsp_range_end: Some(proto::PointUtf16 {
+            row: end.row,
+            column: end.column,
+        }),
+    }
+}
+
+pub fn deserialize_lsp_edit(edit: proto::TextEdit) -> Option<lsp::TextEdit> {
+    let start = edit.lsp_range_start?;
+    let start = PointUtf16::new(start.row, start.column);
+    let end = edit.lsp_range_end?;
+    let end = PointUtf16::new(end.row, end.column);
+    Some(lsp::TextEdit {
+        range: lsp::Range {
+            start: point_to_lsp(start),
+            end: point_to_lsp(end),
+        },
+        new_text: edit.new_text,
+    })
+}

crates/language/src/syntax_map.rs 🔗

@@ -4,8 +4,10 @@ mod syntax_map_tests;
 use crate::{
     Grammar, InjectionConfig, Language, LanguageId, LanguageRegistry, QUERY_CURSORS, with_parser,
 };
+use anyhow::Context as _;
 use collections::HashMap;
 use futures::FutureExt;
+use gpui::SharedString;
 use std::{
     borrow::Cow,
     cmp::{self, Ordering, Reverse},
@@ -30,6 +32,7 @@ pub struct SyntaxSnapshot {
     parsed_version: clock::Global,
     interpolated_version: clock::Global,
     language_registry_version: usize,
+    update_count: usize,
 }
 
 #[derive(Default)]
@@ -93,6 +96,7 @@ enum SyntaxLayerContent {
     Parsed {
         tree: tree_sitter::Tree,
         language: Arc<Language>,
+        included_sub_ranges: Option<Vec<Range<Anchor>>>,
     },
     Pending {
         language_name: Arc<str>,
@@ -121,6 +125,7 @@ impl SyntaxLayerContent {
 pub struct SyntaxLayer<'a> {
     /// The language for this layer.
     pub language: &'a Arc<Language>,
+    pub included_sub_ranges: Option<&'a [Range<Anchor>]>,
     pub(crate) depth: usize,
     tree: &'a Tree,
     pub(crate) offset: (usize, tree_sitter::Point),
@@ -180,6 +185,13 @@ enum ParseStepLanguage {
 }
 
 impl ParseStepLanguage {
+    fn name(&self) -> SharedString {
+        match self {
+            ParseStepLanguage::Loaded { language } => language.name().0,
+            ParseStepLanguage::Pending { name } => name.into(),
+        }
+    }
+
     fn id(&self) -> Option<LanguageId> {
         match self {
             ParseStepLanguage::Loaded { language } => Some(language.id),
@@ -246,7 +258,9 @@ impl SyntaxMap {
     }
 
     pub fn clear(&mut self, text: &BufferSnapshot) {
+        let update_count = self.snapshot.update_count + 1;
         self.snapshot = SyntaxSnapshot::new(text);
+        self.snapshot.update_count = update_count;
     }
 }
 
@@ -257,6 +271,7 @@ impl SyntaxSnapshot {
             parsed_version: clock::Global::default(),
             interpolated_version: clock::Global::default(),
             language_registry_version: 0,
+            update_count: 0,
         }
     }
 
@@ -264,6 +279,10 @@ impl SyntaxSnapshot {
         self.layers.is_empty()
     }
 
+    pub fn update_count(&self) -> usize {
+        self.update_count
+    }
+
     pub fn interpolate(&mut self, text: &BufferSnapshot) {
         let edits = text
             .anchored_edits_since::<(usize, Point)>(&self.interpolated_version)
@@ -412,7 +431,9 @@ impl SyntaxSnapshot {
                         .and_then(|language| language.ok())
                         .is_some()
                     {
-                        resolved_injection_ranges.push(layer.range.to_offset(text));
+                        let range = layer.range.to_offset(text);
+                        log::trace!("reparse range {range:?} for language {language_name:?}");
+                        resolved_injection_ranges.push(range);
                     }
 
                     cursor.next(text);
@@ -430,6 +451,8 @@ impl SyntaxSnapshot {
                 self.language_registry_version = registry.version();
             }
         }
+
+        self.update_count += 1;
     }
 
     fn reparse_with_ranges(
@@ -439,7 +462,10 @@ impl SyntaxSnapshot {
         invalidated_ranges: Vec<Range<usize>>,
         registry: Option<&Arc<LanguageRegistry>>,
     ) {
-        log::trace!("reparse. invalidated ranges:{:?}", invalidated_ranges);
+        log::trace!(
+            "reparse. invalidated ranges:{:?}",
+            LogOffsetRanges(&invalidated_ranges, text),
+        );
 
         let max_depth = self.layers.summary().max_depth;
         let mut cursor = self.layers.cursor::<SyntaxLayerSummary>(text);
@@ -467,6 +493,13 @@ impl SyntaxSnapshot {
         loop {
             let step = queue.pop();
             let position = if let Some(step) = &step {
+                log::trace!(
+                    "parse step depth:{}, range:{:?}, language:{} ({:?})",
+                    step.depth,
+                    LogAnchorRange(&step.range, text),
+                    step.language.name(),
+                    step.language.id(),
+                );
                 SyntaxLayerPosition {
                     depth: step.depth,
                     range: step.range.clone(),
@@ -565,13 +598,13 @@ impl SyntaxSnapshot {
                             .to_ts_point();
                     }
 
-                    if let Some((SyntaxLayerContent::Parsed { tree: old_tree, .. }, layer_start)) =
-                        old_layer.map(|layer| (&layer.content, layer.range.start))
+                    if let Some((SyntaxLayerContent::Parsed { tree: old_tree, .. }, layer_range)) =
+                        old_layer.map(|layer| (&layer.content, layer.range.clone()))
                     {
                         log::trace!(
-                            "existing layer. language:{}, start:{:?}, ranges:{:?}",
+                            "existing layer. language:{}, range:{:?}, included_ranges:{:?}",
                             language.name(),
-                            LogPoint(layer_start.to_point(text)),
+                            LogAnchorRange(&layer_range, text),
                             LogIncludedRanges(&old_tree.included_ranges())
                         );
 
@@ -610,7 +643,7 @@ impl SyntaxSnapshot {
                         }
 
                         log::trace!(
-                            "update layer. language:{}, start:{:?}, included_ranges:{:?}",
+                            "update layer. language:{}, range:{:?}, included_ranges:{:?}",
                             language.name(),
                             LogAnchorRange(&step.range, text),
                             LogIncludedRanges(&included_ranges),
@@ -620,7 +653,7 @@ impl SyntaxSnapshot {
                             grammar,
                             text.as_rope(),
                             step_start_byte,
-                            included_ranges,
+                            &included_ranges,
                             Some(old_tree.clone()),
                         );
                         match result {
@@ -673,7 +706,7 @@ impl SyntaxSnapshot {
                             grammar,
                             text.as_rope(),
                             step_start_byte,
-                            included_ranges,
+                            &included_ranges,
                             None,
                         );
                         match result {
@@ -716,7 +749,21 @@ impl SyntaxSnapshot {
                         );
                     }
 
-                    SyntaxLayerContent::Parsed { tree, language }
+                    let included_sub_ranges: Option<Vec<Range<Anchor>>> =
+                        (included_ranges.len() > 1).then_some(
+                            included_ranges
+                                .into_iter()
+                                .map(|r| {
+                                    text.anchor_before(r.start_byte + step_start_byte)
+                                        ..text.anchor_after(r.end_byte + step_start_byte)
+                                })
+                                .collect(),
+                        );
+                    SyntaxLayerContent::Parsed {
+                        tree,
+                        language,
+                        included_sub_ranges,
+                    }
                 }
                 ParseStepLanguage::Pending { name } => SyntaxLayerContent::Pending {
                     language_name: name,
@@ -744,28 +791,36 @@ impl SyntaxSnapshot {
     #[cfg(debug_assertions)]
     fn check_invariants(&self, text: &BufferSnapshot) {
         let mut max_depth = 0;
-        let mut prev_range: Option<Range<Anchor>> = None;
+        let mut prev_layer: Option<(Range<Anchor>, Option<LanguageId>)> = None;
         for layer in self.layers.iter() {
             match Ord::cmp(&layer.depth, &max_depth) {
                 Ordering::Less => {
                     panic!("layers out of order")
                 }
                 Ordering::Equal => {
-                    if let Some(prev_range) = prev_range {
+                    if let Some((prev_range, prev_language_id)) = prev_layer {
                         match layer.range.start.cmp(&prev_range.start, text) {
                             Ordering::Less => panic!("layers out of order"),
-                            Ordering::Equal => {
-                                assert!(layer.range.end.cmp(&prev_range.end, text).is_ge())
-                            }
+                            Ordering::Equal => match layer.range.end.cmp(&prev_range.end, text) {
+                                Ordering::Less => panic!("layers out of order"),
+                                Ordering::Equal => {
+                                    if layer.content.language_id() < prev_language_id {
+                                        panic!("layers out of order")
+                                    }
+                                }
+                                Ordering::Greater => {}
+                            },
                             Ordering::Greater => {}
                         }
                     }
+                    prev_layer = Some((layer.range.clone(), layer.content.language_id()));
+                }
+                Ordering::Greater => {
+                    prev_layer = None;
                 }
-                Ordering::Greater => {}
             }
 
             max_depth = layer.depth;
-            prev_range = Some(layer.range.clone());
         }
     }
 
@@ -782,6 +837,7 @@ impl SyntaxSnapshot {
             [SyntaxLayer {
                 language,
                 tree,
+                included_sub_ranges: None,
                 depth: 0,
                 offset: (0, tree_sitter::Point::new(0, 0)),
             }]
@@ -866,13 +922,19 @@ impl SyntaxSnapshot {
         iter::from_fn(move || {
             while let Some(layer) = cursor.item() {
                 let mut info = None;
-                if let SyntaxLayerContent::Parsed { tree, language } = &layer.content {
+                if let SyntaxLayerContent::Parsed {
+                    tree,
+                    language,
+                    included_sub_ranges,
+                } = &layer.content
+                {
                     let layer_start_offset = layer.range.start.to_offset(buffer);
                     let layer_start_point = layer.range.start.to_point(buffer).to_ts_point();
                     if include_hidden || !language.config.hidden {
                         info = Some(SyntaxLayer {
                             tree,
                             language,
+                            included_sub_ranges: included_sub_ranges.as_deref(),
                             depth: layer.depth,
                             offset: (layer_start_offset, layer_start_point),
                         });
@@ -1102,7 +1164,7 @@ impl<'a> SyntaxMapMatches<'a> {
         &self.grammars
     }
 
-    pub fn peek(&self) -> Option<SyntaxMapMatch> {
+    pub fn peek(&self) -> Option<SyntaxMapMatch<'_>> {
         let layer = self.layers.first()?;
 
         if !layer.has_next {
@@ -1230,7 +1292,7 @@ fn parse_text(
     grammar: &Grammar,
     text: &Rope,
     start_byte: usize,
-    ranges: Vec<tree_sitter::Range>,
+    ranges: &[tree_sitter::Range],
     old_tree: Option<Tree>,
 ) -> anyhow::Result<Tree> {
     with_parser(|parser| {
@@ -1246,7 +1308,7 @@ fn parse_text(
                 old_tree.as_ref(),
                 None,
             )
-            .ok_or_else(|| anyhow::anyhow!("failed to parse"))
+            .context("failed to parse")
     })
 }
 
@@ -1526,7 +1588,7 @@ fn insert_newlines_between_ranges(
 
 impl OwnedSyntaxLayer {
     /// Returns the root syntax node for this layer.
-    pub fn node(&self) -> Node {
+    pub fn node(&self) -> Node<'_> {
         self.tree
             .root_node_with_offset(self.offset.0, self.offset.1)
     }
@@ -1618,7 +1680,7 @@ impl Ord for ParseStep {
         Ord::cmp(&other.depth, &self.depth)
             .then_with(|| Ord::cmp(&range_b.start, &range_a.start))
             .then_with(|| Ord::cmp(&range_a.end, &range_b.end))
-            .then_with(|| self.language.id().cmp(&other.language.id()))
+            .then_with(|| other.language.id().cmp(&self.language.id()))
     }
 }
 
@@ -1864,6 +1926,7 @@ impl ToTreeSitterPoint for Point {
 struct LogIncludedRanges<'a>(&'a [tree_sitter::Range]);
 struct LogPoint(Point);
 struct LogAnchorRange<'a>(&'a Range<Anchor>, &'a text::BufferSnapshot);
+struct LogOffsetRanges<'a>(&'a [Range<usize>], &'a text::BufferSnapshot);
 struct LogChangedRegions<'a>(&'a ChangeRegionSet, &'a text::BufferSnapshot);
 
 impl fmt::Debug for LogIncludedRanges<'_> {
@@ -1885,6 +1948,16 @@ impl fmt::Debug for LogAnchorRange<'_> {
     }
 }
 
+impl fmt::Debug for LogOffsetRanges<'_> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_list()
+            .entries(self.0.iter().map(|range| {
+                LogPoint(range.start.to_point(self.1))..LogPoint(range.end.to_point(self.1))
+            }))
+            .finish()
+    }
+}
+
 impl fmt::Debug for LogChangedRegions<'_> {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         f.debug_list()

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

@@ -788,15 +788,99 @@ fn test_empty_combined_injections_inside_injections(cx: &mut App) {
             "(template...",
             // Markdown inline content
             "(inline)",
-            // HTML within the ERB
-            "(document (text))",
             // The ruby syntax tree should be empty, since there are
             // no interpolations in the ERB template.
             "(program)",
+            // HTML within the ERB
+            "(document (text))",
         ],
     );
 }
 
+#[gpui::test]
+fn test_syntax_map_languages_loading_with_erb(cx: &mut App) {
+    let text = r#"
+        <body>
+            <% if @one %>
+                <div class=one>
+            <% else %>
+                <div class=two>
+            <% end %>
+            </div>
+        </body>
+    "#
+    .unindent();
+
+    let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
+    let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), text);
+
+    let mut syntax_map = SyntaxMap::new(&buffer);
+    syntax_map.set_language_registry(registry.clone());
+
+    let language = Arc::new(erb_lang());
+
+    log::info!("parsing");
+    registry.add(language.clone());
+    syntax_map.reparse(language.clone(), &buffer);
+
+    log::info!("loading html");
+    registry.add(Arc::new(html_lang()));
+    syntax_map.reparse(language.clone(), &buffer);
+
+    log::info!("loading ruby");
+    registry.add(Arc::new(ruby_lang()));
+    syntax_map.reparse(language.clone(), &buffer);
+
+    assert_capture_ranges(
+        &syntax_map,
+        &buffer,
+        &["tag", "ivar"],
+        "
+            <«body»>
+                <% if «@one» %>
+                    <«div» class=one>
+                <% else %>
+                    <«div» class=two>
+                <% end %>
+                </«div»>
+            </«body»>
+        ",
+    );
+
+    let text = r#"
+        <body>
+            <% if @one«_hundred» %>
+                <div class=one>
+            <% else %>
+                <div class=two>
+            <% end %>
+            </div>
+        </body>
+    "#
+    .unindent();
+
+    log::info!("editing");
+    buffer.edit_via_marked_text(&text);
+    syntax_map.interpolate(&buffer);
+    syntax_map.reparse(language.clone(), &buffer);
+
+    assert_capture_ranges(
+        &syntax_map,
+        &buffer,
+        &["tag", "ivar"],
+        "
+            <«body»>
+                <% if «@one_hundred» %>
+                    <«div» class=one>
+                <% else %>
+                    <«div» class=two>
+                <% end %>
+                </«div»>
+            </«body»>
+        ",
+    );
+}
+
 #[gpui::test(iterations = 50)]
 fn test_random_syntax_map_edits_rust_macros(rng: StdRng, cx: &mut App) {
     let text = r#"
@@ -1076,7 +1160,7 @@ fn test_edit_sequence(language_name: &str, steps: &[&str], cx: &mut App) -> (Buf
         .now_or_never()
         .unwrap()
         .unwrap();
-    let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), Default::default());
+    let mut buffer = Buffer::new(0, BufferId::new(1).unwrap(), "");
 
     let mut mutated_syntax_map = SyntaxMap::new(&buffer);
     mutated_syntax_map.set_language_registry(registry.clone());
@@ -1317,6 +1401,7 @@ fn assert_layers_for_range(
     }
 }
 
+#[track_caller]
 fn assert_capture_ranges(
     syntax_map: &SyntaxMap,
     buffer: &BufferSnapshot,

crates/language/src/task_context.rs 🔗

@@ -1,9 +1,10 @@
-use std::{ops::Range, sync::Arc};
+use std::{ops::Range, path::PathBuf, sync::Arc};
 
-use crate::{LanguageToolchainStore, Location, Runnable};
+use crate::{File, LanguageToolchainStore, Location, Runnable};
 
 use anyhow::Result;
 use collections::HashMap;
+use fs::Fs;
 use gpui::{App, Task};
 use lsp::LanguageServerName;
 use task::{TaskTemplates, TaskVariables};
@@ -26,21 +27,23 @@ pub trait ContextProvider: Send + Sync {
     fn build_context(
         &self,
         _variables: &TaskVariables,
-        _location: &Location,
+        _location: ContextLocation<'_>,
         _project_env: Option<HashMap<String, String>>,
         _toolchains: Arc<dyn LanguageToolchainStore>,
         _cx: &mut App,
     ) -> Task<Result<TaskVariables>> {
+        let _ = _location;
         Task::ready(Ok(TaskVariables::default()))
     }
 
     /// Provides all tasks, associated with the current language.
     fn associated_tasks(
         &self,
-        _: Option<Arc<dyn crate::File>>,
-        _cx: &App,
-    ) -> Option<TaskTemplates> {
-        None
+        _: Arc<dyn Fs>,
+        _: Option<Arc<dyn File>>,
+        _: &App,
+    ) -> Task<Option<TaskTemplates>> {
+        Task::ready(None)
     }
 
     /// A language server name, that can return tasks using LSP (ext) for this language.
@@ -48,3 +51,10 @@ pub trait ContextProvider: Send + Sync {
         None
     }
 }
+
+/// Metadata about the place in the project we gather the context for.
+pub struct ContextLocation<'a> {
+    pub fs: Option<Arc<dyn Fs>>,
+    pub worktree_root: Option<PathBuf>,
+    pub file_location: &'a Location,
+}

crates/language/src/text_diff.rs 🔗

@@ -1,4 +1,5 @@
 use crate::{CharClassifier, CharKind, LanguageScope};
+use anyhow::{Context, anyhow};
 use imara_diff::{
     Algorithm, UnifiedDiffBuilder, diff,
     intern::{InternedInput, Token},
@@ -119,6 +120,12 @@ pub fn text_diff_with_options(
     edits
 }
 
+pub fn apply_diff_patch(base_text: &str, patch: &str) -> Result<String, anyhow::Error> {
+    let patch = diffy::Patch::from_str(patch).context("Failed to parse patch")?;
+    let result = diffy::apply(base_text, &patch);
+    result.map_err(|err| anyhow!(err))
+}
+
 fn should_perform_word_diff_within_hunk(
     old_row_range: &Range<u32>,
     old_byte_range: &Range<usize>,
@@ -270,4 +277,12 @@ mod tests {
             ]
         );
     }
+
+    #[test]
+    fn test_apply_diff_patch() {
+        let old_text = "one two\nthree four five\nsix seven eight nine\nten\n";
+        let new_text = "one two\nthree FOUR five\nsix SEVEN eight nine\nten\nELEVEN\n";
+        let patch = unified_diff(old_text, new_text);
+        assert_eq!(apply_diff_patch(old_text, &patch).unwrap(), new_text);
+    }
 }

crates/language/src/toolchain.rs 🔗

@@ -14,7 +14,7 @@ use collections::HashMap;
 use gpui::{AsyncApp, SharedString};
 use settings::WorktreeId;
 
-use crate::LanguageName;
+use crate::{LanguageName, ManifestName};
 
 /// Represents a single toolchain.
 #[derive(Clone, Debug)]
@@ -44,14 +44,17 @@ pub trait ToolchainLister: Send + Sync {
     async fn list(
         &self,
         worktree_root: PathBuf,
+        subroot_relative_path: Option<Arc<Path>>,
         project_env: Option<HashMap<String, String>>,
     ) -> ToolchainList;
     // Returns a term which we should use in UI to refer to a toolchain.
     fn term(&self) -> SharedString;
+    /// Returns the name of the manifest file for this toolchain.
+    fn manifest_name(&self) -> ManifestName;
 }
 
 #[async_trait(?Send)]
-pub trait LanguageToolchainStore {
+pub trait LanguageToolchainStore: Send + Sync + 'static {
     async fn active_toolchain(
         self: Arc<Self>,
         worktree_id: WorktreeId,

crates/language_extension/src/extension_lsp_adapter.rs 🔗

@@ -18,7 +18,7 @@ use language::{
 use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerName};
 use serde::Serialize;
 use serde_json::Value;
-use util::{ResultExt, maybe};
+use util::{ResultExt, fs::make_file_executable, maybe};
 
 use crate::LanguageServerRegistryProxy;
 
@@ -83,7 +83,7 @@ impl ExtensionLanguageServerProxy for LanguageServerRegistryProxy {
         status: BinaryStatus,
     ) {
         self.language_registry
-            .update_lsp_status(language_server_id, status);
+            .update_lsp_binary_status(language_server_id, status);
     }
 }
 
@@ -144,14 +144,9 @@ impl LspAdapter for ExtensionLspAdapter {
             if ["toml", "zig"].contains(&self.extension.manifest().id.as_ref())
                 && path.starts_with(&self.extension.work_dir())
             {
-                #[cfg(not(windows))]
-                {
-                    use std::fs::{self, Permissions};
-                    use std::os::unix::fs::PermissionsExt;
-
-                    fs::set_permissions(&path, Permissions::from_mode(0o755))
-                        .context("failed to set file permissions")?;
-                }
+                make_file_executable(&path)
+                    .await
+                    .context("failed to set file permissions")?;
             }
 
             Ok(LanguageServerBinary {

crates/language_model/Cargo.toml 🔗

@@ -22,19 +22,17 @@ base64.workspace = true
 client.workspace = true
 collections.workspace = true
 futures.workspace = true
-google_ai = { workspace = true, features = ["schemars"] }
 gpui.workspace = true
 http_client.workspace = true
 icons.workspace = true
 image.workspace = true
-open_ai = { workspace = true, features = ["schemars"] }
+log.workspace = true
 parking_lot.workspace = true
 proto.workspace = true
 schemars.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 smol.workspace = true
-strum.workspace = true
 telemetry_events.workspace = true
 thiserror.workspace = true
 util.workspace = true

crates/language_model/src/fake_provider.rs 🔗

@@ -107,14 +107,18 @@ impl FakeLanguageModel {
         self.current_completion_txs.lock().len()
     }
 
-    pub fn stream_completion_response(&self, request: &LanguageModelRequest, chunk: String) {
+    pub fn stream_completion_response(
+        &self,
+        request: &LanguageModelRequest,
+        chunk: impl Into<String>,
+    ) {
         let current_completion_txs = self.current_completion_txs.lock();
         let tx = current_completion_txs
             .iter()
             .find(|(req, _)| req == request)
             .map(|(_, tx)| tx)
             .unwrap();
-        tx.unbounded_send(chunk).unwrap();
+        tx.unbounded_send(chunk.into()).unwrap();
     }
 
     pub fn end_completion_stream(&self, request: &LanguageModelRequest) {
@@ -123,7 +127,7 @@ impl FakeLanguageModel {
             .retain(|(req, _)| req != request);
     }
 
-    pub fn stream_last_completion_response(&self, chunk: String) {
+    pub fn stream_last_completion_response(&self, chunk: impl Into<String>) {
         self.stream_completion_response(self.pending_completions().last().unwrap(), chunk);
     }
 
@@ -157,15 +161,19 @@ impl LanguageModel for FakeLanguageModel {
         false
     }
 
+    fn supports_images(&self) -> bool {
+        false
+    }
+
     fn telemetry_id(&self) -> String {
         "fake".to_string()
     }
 
-    fn max_token_count(&self) -> usize {
+    fn max_token_count(&self) -> u64 {
         1000000
     }
 
-    fn count_tokens(&self, _: LanguageModelRequest, _: &App) -> BoxFuture<'static, Result<usize>> {
+    fn count_tokens(&self, _: LanguageModelRequest, _: &App) -> BoxFuture<'static, Result<u64>> {
         futures::future::ready(Ok(0)).boxed()
     }
 
@@ -177,6 +185,7 @@ impl LanguageModel for FakeLanguageModel {
         'static,
         Result<
             BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
+            LanguageModelCompletionError,
         >,
     > {
         let (tx, rx) = mpsc::unbounded();

crates/language_model/src/language_model.rs 🔗

@@ -8,27 +8,24 @@ mod telemetry;
 #[cfg(any(test, feature = "test-support"))]
 pub mod fake_provider;
 
-use anyhow::{Result, anyhow};
+use anthropic::{AnthropicError, parse_prompt_too_long};
+use anyhow::Result;
 use client::Client;
 use futures::FutureExt;
 use futures::{StreamExt, future::BoxFuture, stream::BoxStream};
 use gpui::{AnyElement, AnyView, App, AsyncApp, SharedString, Task, Window};
-use http_client::http::{HeaderMap, HeaderValue};
+use http_client::http;
 use icons::IconName;
 use parking_lot::Mutex;
-use proto::Plan;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize, de::DeserializeOwned};
-use std::fmt;
 use std::ops::{Add, Sub};
-use std::str::FromStr as _;
 use std::sync::Arc;
+use std::time::Duration;
+use std::{fmt, io};
 use thiserror::Error;
 use util::serde::is_default;
-use zed_llm_client::{
-    CompletionRequestStatus, MODEL_REQUESTS_USAGE_AMOUNT_HEADER_NAME,
-    MODEL_REQUESTS_USAGE_LIMIT_HEADER_NAME, UsageLimit,
-};
+use zed_llm_client::CompletionRequestStatus;
 
 pub use crate::model::*;
 pub use crate::rate_limiter::*;
@@ -39,6 +36,10 @@ pub use crate::telemetry::*;
 
 pub const ZED_CLOUD_PROVIDER_ID: &str = "zed.dev";
 
+/// If we get a rate limit error that doesn't tell us when we can retry,
+/// default to waiting this long before retrying.
+const DEFAULT_RATE_LIMIT_RETRY_AFTER: Duration = Duration::from_secs(4);
+
 pub fn init(client: Arc<Client>, cx: &mut App) {
     init_settings(cx);
     RefreshLlmTokenListener::register(client.clone(), cx);
@@ -48,21 +49,12 @@ pub fn init_settings(cx: &mut App) {
     registry::init(cx);
 }
 
-/// The availability of a [`LanguageModel`].
-#[derive(Debug, PartialEq, Eq, Clone, Copy)]
-pub enum LanguageModelAvailability {
-    /// The language model is available to the general public.
-    Public,
-    /// The language model is available to users on the indicated plan.
-    RequiresPlan(Plan),
-}
-
 /// Configuration for caching language model messages.
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub struct LanguageModelCacheConfiguration {
     pub max_cache_anchors: usize,
     pub should_speculate: bool,
-    pub min_total_token: usize,
+    pub min_total_token: u64,
 }
 
 /// A completion event from a language model.
@@ -75,6 +67,9 @@ pub enum LanguageModelCompletionEvent {
         text: String,
         signature: Option<String>,
     },
+    RedactedThinking {
+        data: String,
+    },
     ToolUse(LanguageModelToolUse),
     StartMessage {
         message_id: String,
@@ -84,6 +79,8 @@ pub enum LanguageModelCompletionEvent {
 
 #[derive(Error, Debug)]
 pub enum LanguageModelCompletionError {
+    #[error("rate limit exceeded, retry after {retry_after:?}")]
+    RateLimitExceeded { retry_after: Duration },
     #[error("received bad input JSON")]
     BadInputJson {
         id: LanguageModelToolUseId,
@@ -91,8 +88,78 @@ pub enum LanguageModelCompletionError {
         raw_input: Arc<str>,
         json_parse_error: String,
     },
+    #[error("language model provider's API is overloaded")]
+    Overloaded,
     #[error(transparent)]
     Other(#[from] anyhow::Error),
+    #[error("invalid request format to language model provider's API")]
+    BadRequestFormat,
+    #[error("authentication error with language model provider's API")]
+    AuthenticationError,
+    #[error("permission error with language model provider's API")]
+    PermissionError,
+    #[error("language model provider API endpoint not found")]
+    ApiEndpointNotFound,
+    #[error("prompt too large for context window")]
+    PromptTooLarge { tokens: Option<u64> },
+    #[error("internal server error in language model provider's API")]
+    ApiInternalServerError,
+    #[error("I/O error reading response from language model provider's API: {0:?}")]
+    ApiReadResponseError(io::Error),
+    #[error("HTTP response error from language model provider's API: status {status} - {body:?}")]
+    HttpResponseError { status: u16, body: String },
+    #[error("error serializing request to language model provider API: {0}")]
+    SerializeRequest(serde_json::Error),
+    #[error("error building request body to language model provider API: {0}")]
+    BuildRequestBody(http::Error),
+    #[error("error sending HTTP request to language model provider API: {0}")]
+    HttpSend(anyhow::Error),
+    #[error("error deserializing language model provider API response: {0}")]
+    DeserializeResponse(serde_json::Error),
+    #[error("unexpected language model provider API response format: {0}")]
+    UnknownResponseFormat(String),
+}
+
+impl From<AnthropicError> for LanguageModelCompletionError {
+    fn from(error: AnthropicError) -> Self {
+        match error {
+            AnthropicError::SerializeRequest(error) => Self::SerializeRequest(error),
+            AnthropicError::BuildRequestBody(error) => Self::BuildRequestBody(error),
+            AnthropicError::HttpSend(error) => Self::HttpSend(error),
+            AnthropicError::DeserializeResponse(error) => Self::DeserializeResponse(error),
+            AnthropicError::ReadResponse(error) => Self::ApiReadResponseError(error),
+            AnthropicError::HttpResponseError { status, body } => {
+                Self::HttpResponseError { status, body }
+            }
+            AnthropicError::RateLimit { retry_after } => Self::RateLimitExceeded { retry_after },
+            AnthropicError::ApiError(api_error) => api_error.into(),
+            AnthropicError::UnexpectedResponseFormat(error) => Self::UnknownResponseFormat(error),
+        }
+    }
+}
+
+impl From<anthropic::ApiError> for LanguageModelCompletionError {
+    fn from(error: anthropic::ApiError) -> Self {
+        use anthropic::ApiErrorCode::*;
+
+        match error.code() {
+            Some(code) => match code {
+                InvalidRequestError => LanguageModelCompletionError::BadRequestFormat,
+                AuthenticationError => LanguageModelCompletionError::AuthenticationError,
+                PermissionError => LanguageModelCompletionError::PermissionError,
+                NotFoundError => LanguageModelCompletionError::ApiEndpointNotFound,
+                RequestTooLarge => LanguageModelCompletionError::PromptTooLarge {
+                    tokens: parse_prompt_too_long(&error.message),
+                },
+                RateLimitError => LanguageModelCompletionError::RateLimitExceeded {
+                    retry_after: DEFAULT_RATE_LIMIT_RETRY_AFTER,
+                },
+                ApiError => LanguageModelCompletionError::ApiInternalServerError,
+                OverloadedError => LanguageModelCompletionError::Overloaded,
+            },
+            None => LanguageModelCompletionError::Other(error.into()),
+        }
+    }
 }
 
 /// Indicates the format used to define the input schema for a language model tool.
@@ -110,44 +177,23 @@ pub enum StopReason {
     EndTurn,
     MaxTokens,
     ToolUse,
-}
-
-#[derive(Debug, Clone, Copy)]
-pub struct RequestUsage {
-    pub limit: UsageLimit,
-    pub amount: i32,
-}
-
-impl RequestUsage {
-    pub fn from_headers(headers: &HeaderMap<HeaderValue>) -> Result<Self> {
-        let limit = headers
-            .get(MODEL_REQUESTS_USAGE_LIMIT_HEADER_NAME)
-            .ok_or_else(|| anyhow!("missing {MODEL_REQUESTS_USAGE_LIMIT_HEADER_NAME:?} header"))?;
-        let limit = UsageLimit::from_str(limit.to_str()?)?;
-
-        let amount = headers
-            .get(MODEL_REQUESTS_USAGE_AMOUNT_HEADER_NAME)
-            .ok_or_else(|| anyhow!("missing {MODEL_REQUESTS_USAGE_AMOUNT_HEADER_NAME:?} header"))?;
-        let amount = amount.to_str()?.parse::<i32>()?;
-
-        Ok(Self { limit, amount })
-    }
+    Refusal,
 }
 
 #[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize, Default)]
 pub struct TokenUsage {
     #[serde(default, skip_serializing_if = "is_default")]
-    pub input_tokens: u32,
+    pub input_tokens: u64,
     #[serde(default, skip_serializing_if = "is_default")]
-    pub output_tokens: u32,
+    pub output_tokens: u64,
     #[serde(default, skip_serializing_if = "is_default")]
-    pub cache_creation_input_tokens: u32,
+    pub cache_creation_input_tokens: u64,
     #[serde(default, skip_serializing_if = "is_default")]
-    pub cache_read_input_tokens: u32,
+    pub cache_read_input_tokens: u64,
 }
 
 impl TokenUsage {
-    pub fn total_tokens(&self) -> u32 {
+    pub fn total_tokens(&self) -> u64 {
         self.input_tokens
             + self.output_tokens
             + self.cache_read_input_tokens
@@ -238,10 +284,8 @@ pub trait LanguageModel: Send + Sync {
         None
     }
 
-    /// Returns the availability of this language model.
-    fn availability(&self) -> LanguageModelAvailability {
-        LanguageModelAvailability::Public
-    }
+    /// Whether this model supports images
+    fn supports_images(&self) -> bool;
 
     /// Whether this model supports tools.
     fn supports_tools(&self) -> bool;
@@ -249,23 +293,8 @@ pub trait LanguageModel: Send + Sync {
     /// Whether this model supports choosing which tool to use.
     fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool;
 
-    /// Returns whether this model supports "max mode";
-    fn supports_max_mode(&self) -> bool {
-        if self.provider_id().0 != ZED_CLOUD_PROVIDER_ID {
-            return false;
-        }
-
-        const MAX_MODE_CAPABLE_MODELS: &[CloudModel] = &[
-            CloudModel::Anthropic(anthropic::Model::Claude3_7Sonnet),
-            CloudModel::Anthropic(anthropic::Model::Claude3_7SonnetThinking),
-        ];
-
-        for model in MAX_MODE_CAPABLE_MODELS {
-            if self.id().0 == model.id() {
-                return true;
-            }
-        }
-
+    /// Returns whether this model supports "burn mode";
+    fn supports_burn_mode(&self) -> bool {
         false
     }
 
@@ -273,8 +302,8 @@ pub trait LanguageModel: Send + Sync {
         LanguageModelToolSchemaFormat::JsonSchema
     }
 
-    fn max_token_count(&self) -> usize;
-    fn max_output_tokens(&self) -> Option<u32> {
+    fn max_token_count(&self) -> u64;
+    fn max_output_tokens(&self) -> Option<u64> {
         None
     }
 
@@ -282,7 +311,7 @@ pub trait LanguageModel: Send + Sync {
         &self,
         request: LanguageModelRequest,
         cx: &App,
-    ) -> BoxFuture<'static, Result<usize>>;
+    ) -> BoxFuture<'static, Result<u64>>;
 
     fn stream_completion(
         &self,
@@ -292,6 +321,7 @@ pub trait LanguageModel: Send + Sync {
         'static,
         Result<
             BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
+            LanguageModelCompletionError,
         >,
     >;
 
@@ -299,7 +329,7 @@ pub trait LanguageModel: Send + Sync {
         &self,
         request: LanguageModelRequest,
         cx: &AsyncApp,
-    ) -> BoxFuture<'static, Result<LanguageModelTextStream>> {
+    ) -> BoxFuture<'static, Result<LanguageModelTextStream, LanguageModelCompletionError>> {
         let future = self.stream_completion(request, cx);
 
         async move {
@@ -332,6 +362,7 @@ pub trait LanguageModel: Send + Sync {
                                 Ok(LanguageModelCompletionEvent::StartMessage { .. }) => None,
                                 Ok(LanguageModelCompletionEvent::Text(text)) => Some(Ok(text)),
                                 Ok(LanguageModelCompletionEvent::Thinking { .. }) => None,
+                                Ok(LanguageModelCompletionEvent::RedactedThinking { .. }) => None,
                                 Ok(LanguageModelCompletionEvent::Stop(_)) => None,
                                 Ok(LanguageModelCompletionEvent::ToolUse(_)) => None,
                                 Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => {
@@ -367,7 +398,34 @@ pub trait LanguageModel: Send + Sync {
 #[derive(Debug, Error)]
 pub enum LanguageModelKnownError {
     #[error("Context window limit exceeded ({tokens})")]
-    ContextWindowLimitExceeded { tokens: usize },
+    ContextWindowLimitExceeded { tokens: u64 },
+    #[error("Language model provider's API is currently overloaded")]
+    Overloaded,
+    #[error("Language model provider's API encountered an internal server error")]
+    ApiInternalServerError,
+    #[error("I/O error while reading response from language model provider's API: {0:?}")]
+    ReadResponseError(io::Error),
+    #[error("Error deserializing response from language model provider's API: {0:?}")]
+    DeserializeResponse(serde_json::Error),
+    #[error("Language model provider's API returned a response in an unknown format")]
+    UnknownResponseFormat(String),
+    #[error("Rate limit exceeded for language model provider's API; retry in {retry_after:?}")]
+    RateLimitExceeded { retry_after: Duration },
+}
+
+impl LanguageModelKnownError {
+    /// Attempts to map an HTTP response status code to a known error type.
+    /// Returns None if the status code doesn't map to a specific known error.
+    pub fn from_http_response(status: u16, _body: &str) -> Option<Self> {
+        match status {
+            429 => Some(Self::RateLimitExceeded {
+                retry_after: DEFAULT_RATE_LIMIT_RETRY_AFTER,
+            }),
+            503 => Some(Self::Overloaded),
+            500..=599 => Some(Self::ApiInternalServerError),
+            _ => None,
+        }
+    }
 }
 
 pub trait LanguageModelTool: 'static + DeserializeOwned + JsonSchema {
@@ -396,7 +454,6 @@ pub trait LanguageModelProvider: 'static {
     fn recommended_models(&self, _cx: &App) -> Vec<Arc<dyn LanguageModel>> {
         Vec::new()
     }
-    fn load_model(&self, _model: Arc<dyn LanguageModel>, _cx: &App) {}
     fn is_authenticated(&self, cx: &App) -> bool;
     fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>>;
     fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView;

crates/language_model/src/model/cloud_model.rs 🔗

@@ -7,120 +7,9 @@ use gpui::{
     App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, ReadGlobal as _,
 };
 use proto::{Plan, TypedEnvelope};
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
 use smol::lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard};
-use strum::EnumIter;
 use thiserror::Error;
 
-use crate::{LanguageModelAvailability, LanguageModelToolSchemaFormat};
-
-#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
-#[serde(tag = "provider", rename_all = "lowercase")]
-pub enum CloudModel {
-    Anthropic(anthropic::Model),
-    OpenAi(open_ai::Model),
-    Google(google_ai::Model),
-}
-
-#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, EnumIter)]
-pub enum ZedModel {
-    #[serde(rename = "Qwen/Qwen2-7B-Instruct")]
-    Qwen2_7bInstruct,
-}
-
-impl Default for CloudModel {
-    fn default() -> Self {
-        Self::Anthropic(anthropic::Model::default())
-    }
-}
-
-impl CloudModel {
-    pub fn id(&self) -> &str {
-        match self {
-            Self::Anthropic(model) => model.id(),
-            Self::OpenAi(model) => model.id(),
-            Self::Google(model) => model.id(),
-        }
-    }
-
-    pub fn display_name(&self) -> &str {
-        match self {
-            Self::Anthropic(model) => model.display_name(),
-            Self::OpenAi(model) => model.display_name(),
-            Self::Google(model) => model.display_name(),
-        }
-    }
-
-    pub fn max_token_count(&self) -> usize {
-        match self {
-            Self::Anthropic(model) => model.max_token_count(),
-            Self::OpenAi(model) => model.max_token_count(),
-            Self::Google(model) => model.max_token_count(),
-        }
-    }
-
-    /// Returns the availability of this model.
-    pub fn availability(&self) -> LanguageModelAvailability {
-        match self {
-            Self::Anthropic(model) => match model {
-                anthropic::Model::Claude3_5Sonnet
-                | anthropic::Model::Claude3_7Sonnet
-                | anthropic::Model::Claude3_7SonnetThinking => {
-                    LanguageModelAvailability::RequiresPlan(Plan::Free)
-                }
-                anthropic::Model::Claude3Opus
-                | anthropic::Model::Claude3Sonnet
-                | anthropic::Model::Claude3Haiku
-                | anthropic::Model::Claude3_5Haiku
-                | anthropic::Model::Custom { .. } => {
-                    LanguageModelAvailability::RequiresPlan(Plan::ZedPro)
-                }
-            },
-            Self::OpenAi(model) => match model {
-                open_ai::Model::ThreePointFiveTurbo
-                | open_ai::Model::Four
-                | open_ai::Model::FourTurbo
-                | open_ai::Model::FourOmni
-                | open_ai::Model::FourOmniMini
-                | open_ai::Model::FourPointOne
-                | open_ai::Model::FourPointOneMini
-                | open_ai::Model::FourPointOneNano
-                | open_ai::Model::O1Mini
-                | open_ai::Model::O1Preview
-                | open_ai::Model::O1
-                | open_ai::Model::O3Mini
-                | open_ai::Model::O3
-                | open_ai::Model::O4Mini
-                | open_ai::Model::Custom { .. } => {
-                    LanguageModelAvailability::RequiresPlan(Plan::ZedPro)
-                }
-            },
-            Self::Google(model) => match model {
-                google_ai::Model::Gemini15Pro
-                | google_ai::Model::Gemini15Flash
-                | google_ai::Model::Gemini20Pro
-                | google_ai::Model::Gemini20Flash
-                | google_ai::Model::Gemini20FlashThinking
-                | google_ai::Model::Gemini20FlashLite
-                | google_ai::Model::Gemini25ProExp0325
-                | google_ai::Model::Gemini25ProPreview0325
-                | google_ai::Model::Gemini25FlashPreview0417
-                | google_ai::Model::Custom { .. } => {
-                    LanguageModelAvailability::RequiresPlan(Plan::ZedPro)
-                }
-            },
-        }
-    }
-
-    pub fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
-        match self {
-            Self::Anthropic(_) | Self::OpenAi(_) => LanguageModelToolSchemaFormat::JsonSchema,
-            Self::Google(_) => LanguageModelToolSchemaFormat::JsonSchemaSubset,
-        }
-    }
-}
-
 #[derive(Error, Debug)]
 pub struct PaymentRequiredError;
 
@@ -133,18 +22,6 @@ impl fmt::Display for PaymentRequiredError {
     }
 }
 
-#[derive(Error, Debug)]
-pub struct MaxMonthlySpendReachedError;
-
-impl fmt::Display for MaxMonthlySpendReachedError {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        write!(
-            f,
-            "Maximum spending limit reached for this month. For more usage, increase your spending limit."
-        )
-    }
-}
-
 #[derive(Error, Debug)]
 pub struct ModelRequestLimitReachedError {
     pub plan: Plan,

crates/language_model/src/rate_limiter.rs 🔗

@@ -1,4 +1,3 @@
-use anyhow::Result;
 use futures::Stream;
 use smol::lock::{Semaphore, SemaphoreGuardArc};
 use std::{
@@ -8,6 +7,8 @@ use std::{
     task::{Context, Poll},
 };
 
+use crate::LanguageModelCompletionError;
+
 #[derive(Clone)]
 pub struct RateLimiter {
     semaphore: Arc<Semaphore>,
@@ -36,9 +37,12 @@ impl RateLimiter {
         }
     }
 
-    pub fn run<'a, Fut, T>(&self, future: Fut) -> impl 'a + Future<Output = Result<T>>
+    pub fn run<'a, Fut, T>(
+        &self,
+        future: Fut,
+    ) -> impl 'a + Future<Output = Result<T, LanguageModelCompletionError>>
     where
-        Fut: 'a + Future<Output = Result<T>>,
+        Fut: 'a + Future<Output = Result<T, LanguageModelCompletionError>>,
     {
         let guard = self.semaphore.acquire_arc();
         async move {
@@ -52,9 +56,12 @@ impl RateLimiter {
     pub fn stream<'a, Fut, T>(
         &self,
         future: Fut,
-    ) -> impl 'a + Future<Output = Result<impl Stream<Item = T::Item> + use<Fut, T>>>
+    ) -> impl 'a
+    + Future<
+        Output = Result<impl Stream<Item = T::Item> + use<Fut, T>, LanguageModelCompletionError>,
+    >
     where
-        Fut: 'a + Future<Output = Result<T>>,
+        Fut: 'a + Future<Output = Result<T, LanguageModelCompletionError>>,
         T: Stream,
     {
         let guard = self.semaphore.acquire_arc();

crates/language_model/src/registry.rs 🔗

@@ -4,7 +4,8 @@ use crate::{
 };
 use collections::BTreeMap;
 use gpui::{App, Context, Entity, EventEmitter, Global, prelude::*};
-use std::sync::Arc;
+use std::{str::FromStr, sync::Arc};
+use thiserror::Error;
 use util::maybe;
 
 pub fn init(cx: &mut App) {
@@ -16,6 +17,34 @@ struct GlobalLanguageModelRegistry(Entity<LanguageModelRegistry>);
 
 impl Global for GlobalLanguageModelRegistry {}
 
+#[derive(Error)]
+pub enum ConfigurationError {
+    #[error("Configure at least one LLM provider to start using the panel.")]
+    NoProvider,
+    #[error("LLM Provider is not configured or does not support the configured model.")]
+    ModelNotFound,
+    #[error("{} LLM provider is not configured.", .0.name().0)]
+    ProviderNotAuthenticated(Arc<dyn LanguageModelProvider>),
+    #[error("Using the {} LLM provider requires accepting the Terms of Service.",
+    .0.name().0)]
+    ProviderPendingTermsAcceptance(Arc<dyn LanguageModelProvider>),
+}
+
+impl std::fmt::Debug for ConfigurationError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::NoProvider => write!(f, "NoProvider"),
+            Self::ModelNotFound => write!(f, "ModelNotFound"),
+            Self::ProviderNotAuthenticated(provider) => {
+                write!(f, "ProviderNotAuthenticated({})", provider.id())
+            }
+            Self::ProviderPendingTermsAcceptance(provider) => {
+                write!(f, "ProviderPendingTermsAcceptance({})", provider.id())
+            }
+        }
+    }
+}
+
 #[derive(Default)]
 pub struct LanguageModelRegistry {
     default_model: Option<ConfiguredModel>,
@@ -27,11 +56,36 @@ pub struct LanguageModelRegistry {
     inline_alternatives: Vec<Arc<dyn LanguageModel>>,
 }
 
+#[derive(Debug)]
 pub struct SelectedModel {
     pub provider: LanguageModelProviderId,
     pub model: LanguageModelId,
 }
 
+impl FromStr for SelectedModel {
+    type Err = String;
+
+    /// Parse string identifiers like `provider_id/model_id` into a `SelectedModel`
+    fn from_str(id: &str) -> Result<SelectedModel, Self::Err> {
+        let parts: Vec<&str> = id.split('/').collect();
+        let [provider_id, model_id] = parts.as_slice() else {
+            return Err(format!(
+                "Invalid model identifier format: `{}`. Expected `provider_id/model_id`",
+                id
+            ));
+        };
+
+        if provider_id.is_empty() || model_id.is_empty() {
+            return Err(format!("Provider and model ids can't be empty: `{}`", id));
+        }
+
+        Ok(SelectedModel {
+            provider: LanguageModelProviderId(provider_id.to_string().into()),
+            model: LanguageModelId(model_id.to_string().into()),
+        })
+    }
+}
+
 #[derive(Clone)]
 pub struct ConfiguredModel {
     pub provider: Arc<dyn LanguageModelProvider>,
@@ -127,6 +181,36 @@ impl LanguageModelRegistry {
         providers
     }
 
+    pub fn configuration_error(
+        &self,
+        model: Option<ConfiguredModel>,
+        cx: &App,
+    ) -> Option<ConfigurationError> {
+        let Some(model) = model else {
+            if !self.has_authenticated_provider(cx) {
+                return Some(ConfigurationError::NoProvider);
+            }
+            return Some(ConfigurationError::ModelNotFound);
+        };
+
+        if !model.provider.is_authenticated(cx) {
+            return Some(ConfigurationError::ProviderNotAuthenticated(model.provider));
+        }
+
+        if model.provider.must_accept_terms(cx) {
+            return Some(ConfigurationError::ProviderPendingTermsAcceptance(
+                model.provider,
+            ));
+        }
+
+        None
+    }
+
+    /// Check that we have at least one provider that is authenticated.
+    fn has_authenticated_provider(&self, cx: &App) -> bool {
+        self.providers.values().any(|p| p.is_authenticated(cx))
+    }
+
     pub fn available_models<'a>(
         &'a self,
         cx: &'a App,
@@ -286,6 +370,7 @@ impl LanguageModelRegistry {
 
         self.commit_message_model
             .clone()
+            .or_else(|| self.default_fast_model.clone())
             .or_else(|| self.default_model.clone())
     }
 

crates/language_model/src/request.rs 🔗

@@ -12,13 +12,58 @@ use gpui::{
 use image::codecs::png::PngEncoder;
 use serde::{Deserialize, Serialize};
 use util::ResultExt;
-use zed_llm_client::CompletionMode;
+use zed_llm_client::{CompletionIntent, CompletionMode};
 
 #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
 pub struct LanguageModelImage {
     /// A base64-encoded PNG image.
     pub source: SharedString,
-    size: Size<DevicePixels>,
+    pub size: Size<DevicePixels>,
+}
+
+impl LanguageModelImage {
+    pub fn len(&self) -> usize {
+        self.source.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.source.is_empty()
+    }
+
+    // Parse Self from a JSON object with case-insensitive field names
+    pub fn from_json(obj: &serde_json::Map<String, serde_json::Value>) -> Option<Self> {
+        let mut source = None;
+        let mut size_obj = None;
+
+        // Find source and size fields (case-insensitive)
+        for (k, v) in obj.iter() {
+            match k.to_lowercase().as_str() {
+                "source" => source = v.as_str(),
+                "size" => size_obj = v.as_object(),
+                _ => {}
+            }
+        }
+
+        let source = source?;
+        let size_obj = size_obj?;
+
+        let mut width = None;
+        let mut height = None;
+
+        // Find width and height in size object (case-insensitive)
+        for (k, v) in size_obj.iter() {
+            match k.to_lowercase().as_str() {
+                "width" => width = v.as_i64().map(|w| w as i32),
+                "height" => height = v.as_i64().map(|h| h as i32),
+                _ => {}
+            }
+        }
+
+        Some(Self {
+            size: size(DevicePixels(width?), DevicePixels(height?)),
+            source: SharedString::from(source.to_string()),
+        })
+    }
 }
 
 impl std::fmt::Debug for LanguageModelImage {
@@ -104,6 +149,10 @@ impl LanguageModelImage {
         // so this method is more of a rough guess.
         (width * height) / 750
     }
+
+    pub fn to_base64_url(&self) -> String {
+        format!("data:image/png;base64,{}", self.source)
+    }
 }
 
 fn encode_as_base64(data: Arc<Image>, image: image::DynamicImage) -> Result<Vec<u8>> {
@@ -130,10 +179,123 @@ pub struct LanguageModelToolResult {
     pub tool_use_id: LanguageModelToolUseId,
     pub tool_name: Arc<str>,
     pub is_error: bool,
-    pub content: Arc<str>,
+    pub content: LanguageModelToolResultContent,
     pub output: Option<serde_json::Value>,
 }
 
+#[derive(Debug, Clone, Serialize, Eq, PartialEq, Hash)]
+pub enum LanguageModelToolResultContent {
+    Text(Arc<str>),
+    Image(LanguageModelImage),
+}
+
+impl<'de> Deserialize<'de> for LanguageModelToolResultContent {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        use serde::de::Error;
+
+        let value = serde_json::Value::deserialize(deserializer)?;
+
+        // Models can provide these responses in several styles. Try each in order.
+
+        // 1. Try as plain string
+        if let Ok(text) = serde_json::from_value::<String>(value.clone()) {
+            return Ok(Self::Text(Arc::from(text)));
+        }
+
+        // 2. Try as object
+        if let Some(obj) = value.as_object() {
+            // get a JSON field case-insensitively
+            fn get_field<'a>(
+                obj: &'a serde_json::Map<String, serde_json::Value>,
+                field: &str,
+            ) -> Option<&'a serde_json::Value> {
+                obj.iter()
+                    .find(|(k, _)| k.to_lowercase() == field.to_lowercase())
+                    .map(|(_, v)| v)
+            }
+
+            // Accept wrapped text format: { "type": "text", "text": "..." }
+            if let (Some(type_value), Some(text_value)) =
+                (get_field(&obj, "type"), get_field(&obj, "text"))
+            {
+                if let Some(type_str) = type_value.as_str() {
+                    if type_str.to_lowercase() == "text" {
+                        if let Some(text) = text_value.as_str() {
+                            return Ok(Self::Text(Arc::from(text)));
+                        }
+                    }
+                }
+            }
+
+            // Check for wrapped Text variant: { "text": "..." }
+            if let Some((_key, value)) = obj.iter().find(|(k, _)| k.to_lowercase() == "text") {
+                if obj.len() == 1 {
+                    // Only one field, and it's "text" (case-insensitive)
+                    if let Some(text) = value.as_str() {
+                        return Ok(Self::Text(Arc::from(text)));
+                    }
+                }
+            }
+
+            // Check for wrapped Image variant: { "image": { "source": "...", "size": ... } }
+            if let Some((_key, value)) = obj.iter().find(|(k, _)| k.to_lowercase() == "image") {
+                if obj.len() == 1 {
+                    // Only one field, and it's "image" (case-insensitive)
+                    // Try to parse the nested image object
+                    if let Some(image_obj) = value.as_object() {
+                        if let Some(image) = LanguageModelImage::from_json(image_obj) {
+                            return Ok(Self::Image(image));
+                        }
+                    }
+                }
+            }
+
+            // Try as direct Image (object with "source" and "size" fields)
+            if let Some(image) = LanguageModelImage::from_json(&obj) {
+                return Ok(Self::Image(image));
+            }
+        }
+
+        // If none of the variants match, return an error with the problematic JSON
+        Err(D::Error::custom(format!(
+            "data did not match any variant of LanguageModelToolResultContent. Expected either a string, \
+             an object with 'type': 'text', a wrapped variant like {{\"Text\": \"...\"}}, or an image object. Got: {}",
+            serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string())
+        )))
+    }
+}
+
+impl LanguageModelToolResultContent {
+    pub fn to_str(&self) -> Option<&str> {
+        match self {
+            Self::Text(text) => Some(&text),
+            Self::Image(_) => None,
+        }
+    }
+
+    pub fn is_empty(&self) -> bool {
+        match self {
+            Self::Text(text) => text.chars().all(|c| c.is_whitespace()),
+            Self::Image(_) => false,
+        }
+    }
+}
+
+impl From<&str> for LanguageModelToolResultContent {
+    fn from(value: &str) -> Self {
+        Self::Text(Arc::from(value))
+    }
+}
+
+impl From<String> for LanguageModelToolResultContent {
+    fn from(value: String) -> Self {
+        Self::Text(Arc::from(value))
+    }
+}
+
 #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
 pub enum MessageContent {
     Text(String),
@@ -141,12 +303,35 @@ pub enum MessageContent {
         text: String,
         signature: Option<String>,
     },
-    RedactedThinking(Vec<u8>),
+    RedactedThinking(String),
     Image(LanguageModelImage),
     ToolUse(LanguageModelToolUse),
     ToolResult(LanguageModelToolResult),
 }
 
+impl MessageContent {
+    pub fn to_str(&self) -> Option<&str> {
+        match self {
+            MessageContent::Text(text) => Some(text.as_str()),
+            MessageContent::Thinking { text, .. } => Some(text.as_str()),
+            MessageContent::RedactedThinking(_) => None,
+            MessageContent::ToolResult(tool_result) => tool_result.content.to_str(),
+            MessageContent::ToolUse(_) | MessageContent::Image(_) => None,
+        }
+    }
+
+    pub fn is_empty(&self) -> bool {
+        match self {
+            MessageContent::Text(text) => text.chars().all(|c| c.is_whitespace()),
+            MessageContent::Thinking { text, .. } => text.chars().all(|c| c.is_whitespace()),
+            MessageContent::ToolResult(tool_result) => tool_result.content.is_empty(),
+            MessageContent::RedactedThinking(_)
+            | MessageContent::ToolUse(_)
+            | MessageContent::Image(_) => false,
+        }
+    }
+}
+
 impl From<String> for MessageContent {
     fn from(value: String) -> Self {
         MessageContent::Text(value)
@@ -169,13 +354,7 @@ pub struct LanguageModelRequestMessage {
 impl LanguageModelRequestMessage {
     pub fn string_contents(&self) -> String {
         let mut buffer = String::new();
-        for string in self.content.iter().filter_map(|content| match content {
-            MessageContent::Text(text) => Some(text.as_str()),
-            MessageContent::Thinking { text, .. } => Some(text.as_str()),
-            MessageContent::RedactedThinking(_) => None,
-            MessageContent::ToolResult(tool_result) => Some(tool_result.content.as_ref()),
-            MessageContent::ToolUse(_) | MessageContent::Image(_) => None,
-        }) {
+        for string in self.content.iter().filter_map(|content| content.to_str()) {
             buffer.push_str(string);
         }
 
@@ -183,16 +362,7 @@ impl LanguageModelRequestMessage {
     }
 
     pub fn contents_empty(&self) -> bool {
-        self.content.iter().all(|content| match content {
-            MessageContent::Text(text) => text.chars().all(|c| c.is_whitespace()),
-            MessageContent::Thinking { text, .. } => text.chars().all(|c| c.is_whitespace()),
-            MessageContent::ToolResult(tool_result) => {
-                tool_result.content.chars().all(|c| c.is_whitespace())
-            }
-            MessageContent::RedactedThinking(_)
-            | MessageContent::ToolUse(_)
-            | MessageContent::Image(_) => false,
-        })
+        self.content.iter().all(|content| content.is_empty())
     }
 }
 
@@ -214,6 +384,7 @@ pub enum LanguageModelToolChoice {
 pub struct LanguageModelRequest {
     pub thread_id: Option<String>,
     pub prompt_id: Option<String>,
+    pub intent: Option<CompletionIntent>,
     pub mode: Option<CompletionMode>,
     pub messages: Vec<LanguageModelRequestMessage>,
     pub tools: Vec<LanguageModelRequestTool>,
@@ -227,3 +398,168 @@ pub struct LanguageModelResponseMessage {
     pub role: Option<Role>,
     pub content: Option<String>,
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_language_model_tool_result_content_deserialization() {
+        let json = r#""This is plain text""#;
+        let result: LanguageModelToolResultContent = serde_json::from_str(json).unwrap();
+        assert_eq!(
+            result,
+            LanguageModelToolResultContent::Text("This is plain text".into())
+        );
+
+        let json = r#"{"type": "text", "text": "This is wrapped text"}"#;
+        let result: LanguageModelToolResultContent = serde_json::from_str(json).unwrap();
+        assert_eq!(
+            result,
+            LanguageModelToolResultContent::Text("This is wrapped text".into())
+        );
+
+        let json = r#"{"Type": "TEXT", "TEXT": "Case insensitive"}"#;
+        let result: LanguageModelToolResultContent = serde_json::from_str(json).unwrap();
+        assert_eq!(
+            result,
+            LanguageModelToolResultContent::Text("Case insensitive".into())
+        );
+
+        let json = r#"{"Text": "Wrapped variant"}"#;
+        let result: LanguageModelToolResultContent = serde_json::from_str(json).unwrap();
+        assert_eq!(
+            result,
+            LanguageModelToolResultContent::Text("Wrapped variant".into())
+        );
+
+        let json = r#"{"text": "Lowercase wrapped"}"#;
+        let result: LanguageModelToolResultContent = serde_json::from_str(json).unwrap();
+        assert_eq!(
+            result,
+            LanguageModelToolResultContent::Text("Lowercase wrapped".into())
+        );
+
+        // Test image deserialization
+        let json = r#"{
+            "source": "base64encodedimagedata",
+            "size": {
+                "width": 100,
+                "height": 200
+            }
+        }"#;
+        let result: LanguageModelToolResultContent = serde_json::from_str(json).unwrap();
+        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);
+            }
+            _ => panic!("Expected Image variant"),
+        }
+
+        // Test wrapped Image variant
+        let json = r#"{
+            "Image": {
+                "source": "wrappedimagedata",
+                "size": {
+                    "width": 50,
+                    "height": 75
+                }
+            }
+        }"#;
+        let result: LanguageModelToolResultContent = serde_json::from_str(json).unwrap();
+        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);
+            }
+            _ => panic!("Expected Image variant"),
+        }
+
+        // Test wrapped Image variant with case insensitive
+        let json = r#"{
+            "image": {
+                "Source": "caseinsensitive",
+                "SIZE": {
+                    "width": 30,
+                    "height": 40
+                }
+            }
+        }"#;
+        let result: LanguageModelToolResultContent = serde_json::from_str(json).unwrap();
+        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);
+            }
+            _ => panic!("Expected Image variant"),
+        }
+
+        // Test that wrapped text with wrong type fails
+        let json = r#"{"type": "blahblah", "text": "This should fail"}"#;
+        let result: Result<LanguageModelToolResultContent, _> = serde_json::from_str(json);
+        assert!(result.is_err());
+
+        // Test that malformed JSON fails
+        let json = r#"{"invalid": "structure"}"#;
+        let result: Result<LanguageModelToolResultContent, _> = serde_json::from_str(json);
+        assert!(result.is_err());
+
+        // Test edge cases
+        let json = r#""""#; // Empty string
+        let result: LanguageModelToolResultContent = serde_json::from_str(json).unwrap();
+        assert_eq!(result, LanguageModelToolResultContent::Text("".into()));
+
+        // Test with extra fields in wrapped text (should be ignored)
+        let json = r#"{"type": "text", "text": "Hello", "extra": "field"}"#;
+        let result: LanguageModelToolResultContent = serde_json::from_str(json).unwrap();
+        assert_eq!(result, LanguageModelToolResultContent::Text("Hello".into()));
+
+        // Test direct image with case-insensitive fields
+        let json = r#"{
+            "SOURCE": "directimage",
+            "Size": {
+                "width": 200,
+                "height": 300
+            }
+        }"#;
+        let result: LanguageModelToolResultContent = serde_json::from_str(json).unwrap();
+        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);
+            }
+            _ => panic!("Expected Image variant"),
+        }
+
+        // Test that multiple fields prevent wrapped variant interpretation
+        let json = r#"{"Text": "not wrapped", "extra": "field"}"#;
+        let result: Result<LanguageModelToolResultContent, _> = serde_json::from_str(json);
+        assert!(result.is_err());
+
+        // Test wrapped text with uppercase TEXT variant
+        let json = r#"{"TEXT": "Uppercase variant"}"#;
+        let result: LanguageModelToolResultContent = serde_json::from_str(json).unwrap();
+        assert_eq!(
+            result,
+            LanguageModelToolResultContent::Text("Uppercase variant".into())
+        );
+
+        // Test that numbers and other JSON values fail gracefully
+        let json = r#"123"#;
+        let result: Result<LanguageModelToolResultContent, _> = serde_json::from_str(json);
+        assert!(result.is_err());
+
+        let json = r#"null"#;
+        let result: Result<LanguageModelToolResultContent, _> = serde_json::from_str(json);
+        assert!(result.is_err());
+
+        let json = r#"[1, 2, 3]"#;
+        let result: Result<LanguageModelToolResultContent, _> = serde_json::from_str(json);
+        assert!(result.is_err());
+    }
+}

crates/language_model/src/telemetry.rs 🔗

@@ -1,5 +1,5 @@
-use anthropic::{ANTHROPIC_API_URL, AnthropicError};
-use anyhow::{Context as _, Result, anyhow};
+use anthropic::ANTHROPIC_API_URL;
+use anyhow::{Context as _, anyhow};
 use client::telemetry::Telemetry;
 use gpui::BackgroundExecutor;
 use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
@@ -20,13 +20,17 @@ pub fn report_assistant_event(
     if let Some(telemetry) = telemetry.as_ref() {
         telemetry.report_assistant_event(event.clone());
         if telemetry.metrics_enabled() && event.model_provider == ANTHROPIC_PROVIDER_ID {
-            executor
-                .spawn(async move {
-                    report_anthropic_event(event, client, model_api_key)
-                        .await
-                        .log_err();
-                })
-                .detach();
+            if let Some(api_key) = model_api_key {
+                executor
+                    .spawn(async move {
+                        report_anthropic_event(event, client, api_key)
+                            .await
+                            .log_err();
+                    })
+                    .detach();
+            } else {
+                log::error!("Cannot send Anthropic telemetry because API key is missing");
+            }
         }
     }
 }
@@ -34,17 +38,8 @@ pub fn report_assistant_event(
 async fn report_anthropic_event(
     event: AssistantEventData,
     client: Arc<dyn HttpClient>,
-    model_api_key: Option<String>,
-) -> Result<(), AnthropicError> {
-    let api_key = match model_api_key {
-        Some(key) => key,
-        None => {
-            return Err(AnthropicError::Other(anyhow!(
-                "Anthropic API key is not set"
-            )));
-        }
-    };
-
+    api_key: String,
+) -> anyhow::Result<()> {
     let uri = format!("{ANTHROPIC_API_URL}/v1/log/zed");
     let request_builder = HttpRequest::builder()
         .method(Method::POST)
@@ -72,19 +67,19 @@ async fn report_anthropic_event(
 
     let request = request_builder
         .body(AsyncBody::from(serialized_event.to_string()))
-        .context("failed to construct request body")?;
+        .context("Failed to construct Anthropic telemetry HTTP request body")?;
 
     let response = client
         .send(request)
         .await
-        .context("failed to send request to Anthropic")?;
+        .context("Failed to send telemetry HTTP request to Anthropic")?;
 
     if response.status().is_success() {
-        return Ok(());
+        Ok(())
+    } else {
+        Err(anyhow!(
+            "Anthropic telemetry logging failed with HTTP status: {}",
+            response.status()
+        ))
     }
-
-    return Err(AnthropicError::Other(anyhow!(
-        "Failed to log: {}",
-        response.status(),
-    )));
 }

crates/language_model_selector/Cargo.toml 🔗

@@ -1,36 +0,0 @@
-[package]
-name = "language_model_selector"
-version = "0.1.0"
-edition.workspace = true
-publish.workspace = true
-license = "GPL-3.0-or-later"
-
-[lints]
-workspace = true
-
-[lib]
-path = "src/language_model_selector.rs"
-
-[features]
-test-support = [
-    "gpui/test-support",
-]
-
-[dependencies]
-collections.workspace = true
-feature_flags.workspace = true
-futures.workspace = true
-fuzzy.workspace = true
-gpui.workspace = true
-language_model.workspace = true
-log.workspace = true
-ordered-float.workspace = true
-picker.workspace = true
-proto.workspace = true
-ui.workspace = true
-workspace-hack.workspace = true
-zed_actions.workspace = true
-
-[dev-dependencies]
-gpui = { workspace = true, "features" = ["test-support"] }
-language_model = { workspace = true, "features" = ["test-support"] }

crates/language_models/Cargo.toml 🔗

@@ -15,16 +15,17 @@ path = "src/language_models.rs"
 anthropic = { workspace = true, features = ["schemars"] }
 anyhow.workspace = true
 aws-config = { workspace = true, features = ["behavior-version-latest"] }
-aws-credential-types = { workspace = true, features = ["hardcoded-credentials"] }
+aws-credential-types = { workspace = true, features = [
+    "hardcoded-credentials",
+] }
 aws_http_client.workspace = true
 bedrock.workspace = true
 client.workspace = true
 collections.workspace = true
 credentials_provider.workspace = true
-copilot = { workspace = true, features = ["schemars"] }
+copilot.workspace = true
 deepseek = { workspace = true, features = ["schemars"] }
 editor.workspace = true
-feature_flags.workspace = true
 fs.workspace = true
 futures.workspace = true
 google_ai = { workspace = true, features = ["schemars"] }
@@ -38,8 +39,9 @@ menu.workspace = true
 mistral = { workspace = true, features = ["schemars"] }
 ollama = { workspace = true, features = ["schemars"] }
 open_ai = { workspace = true, features = ["schemars"] }
+open_router = { workspace = true, features = ["schemars"] }
+vercel = { workspace = true, features = ["schemars"] }
 partial-json-fixer.workspace = true
-project.workspace = true
 proto.workspace = true
 release_channel.workspace = true
 schemars.workspace = true
@@ -53,9 +55,11 @@ thiserror.workspace = true
 tiktoken-rs.workspace = true
 tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
 ui.workspace = true
+ui_input.workspace = true
 util.workspace = true
 workspace-hack.workspace = true
 zed_llm_client.workspace = true
+language.workspace = true
 
 [dev-dependencies]
 editor = { workspace = true, features = ["test-support"] }

crates/language_models/src/language_models.rs 🔗

@@ -1,7 +1,6 @@
 use std::sync::Arc;
 
 use client::{Client, UserStore};
-use fs::Fs;
 use gpui::{App, Context, Entity};
 use language_model::LanguageModelRegistry;
 use provider::deepseek::DeepSeekLanguageModelProvider;
@@ -19,10 +18,12 @@ use crate::provider::lmstudio::LmStudioLanguageModelProvider;
 use crate::provider::mistral::MistralLanguageModelProvider;
 use crate::provider::ollama::OllamaLanguageModelProvider;
 use crate::provider::open_ai::OpenAiLanguageModelProvider;
+use crate::provider::open_router::OpenRouterLanguageModelProvider;
+use crate::provider::vercel::VercelLanguageModelProvider;
 pub use crate::settings::*;
 
-pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, fs: Arc<dyn Fs>, cx: &mut App) {
-    crate::settings::init(fs, cx);
+pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, cx: &mut App) {
+    crate::settings::init(cx);
     let registry = LanguageModelRegistry::global(cx);
     registry.update(cx, |registry, cx| {
         register_language_model_providers(registry, user_store, client, cx);
@@ -72,5 +73,13 @@ fn register_language_model_providers(
         BedrockLanguageModelProvider::new(client.http_client(), cx),
         cx,
     );
+    registry.register_provider(
+        OpenRouterLanguageModelProvider::new(client.http_client(), cx),
+        cx,
+    );
+    registry.register_provider(
+        VercelLanguageModelProvider::new(client.http_client(), cx),
+        cx,
+    );
     registry.register_provider(CopilotChatLanguageModelProvider::new(cx), cx);
 }

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

@@ -1,6 +1,9 @@
 use crate::AllLanguageModelSettings;
 use crate::ui::InstructionListItem;
-use anthropic::{AnthropicError, AnthropicModelMode, ContentDelta, Event, ResponseContent, Usage};
+use anthropic::{
+    AnthropicError, AnthropicModelMode, ContentDelta, Event, ResponseContent, ToolResultContent,
+    ToolResultPart, Usage,
+};
 use anyhow::{Context as _, Result, anyhow};
 use collections::{BTreeMap, HashMap};
 use credentials_provider::CredentialsProvider;
@@ -13,9 +16,9 @@ use gpui::{
 use http_client::HttpClient;
 use language_model::{
     AuthenticateError, LanguageModel, LanguageModelCacheConfiguration,
-    LanguageModelCompletionError, LanguageModelId, LanguageModelKnownError, LanguageModelName,
-    LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
-    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, MessageContent,
+    LanguageModelCompletionError, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+    LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, MessageContent,
     RateLimiter, Role,
 };
 use language_model::{LanguageModelCompletionEvent, LanguageModelToolUse, StopReason};
@@ -38,7 +41,6 @@ pub struct AnthropicSettings {
     pub api_url: String,
     /// Extend Zed's list of Anthropic models.
     pub available_models: Vec<AvailableModel>,
-    pub needs_setting_migration: bool,
 }
 
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
@@ -48,12 +50,12 @@ pub struct AvailableModel {
     /// The model's name in Zed's UI, such as in the model selector dropdown menu in the assistant panel.
     pub display_name: Option<String>,
     /// The model's context window size.
-    pub max_tokens: usize,
+    pub max_tokens: u64,
     /// A model `name` to substitute when calling tools, in case the primary model doesn't support tool calling.
     pub tool_override: Option<String>,
     /// Configuration of Anthropic's caching API.
     pub cache_configuration: Option<LanguageModelCacheConfiguration>,
-    pub max_output_tokens: Option<u32>,
+    pub max_output_tokens: Option<u64>,
     pub default_temperature: Option<f32>,
     #[serde(default)]
     pub extra_beta_headers: Vec<String>,
@@ -237,8 +239,8 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider {
 
     fn recommended_models(&self, _cx: &App) -> Vec<Arc<dyn LanguageModel>> {
         [
-            anthropic::Model::Claude3_7Sonnet,
-            anthropic::Model::Claude3_7SonnetThinking,
+            anthropic::Model::ClaudeSonnet4,
+            anthropic::Model::ClaudeSonnet4Thinking,
         ]
         .into_iter()
         .map(|model| self.create_language_model(model))
@@ -318,7 +320,7 @@ pub struct AnthropicModel {
 pub fn count_anthropic_tokens(
     request: LanguageModelRequest,
     cx: &App,
-) -> BoxFuture<'static, Result<usize>> {
+) -> BoxFuture<'static, Result<u64>> {
     cx.background_spawn(async move {
         let messages = request.messages;
         let mut tokens_from_images = 0;
@@ -346,9 +348,14 @@ pub fn count_anthropic_tokens(
                     MessageContent::ToolUse(_tool_use) => {
                         // TODO: Estimate token usage from tool uses.
                     }
-                    MessageContent::ToolResult(tool_result) => {
-                        string_contents.push_str(&tool_result.content);
-                    }
+                    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();
+                        }
+                    },
                 }
             }
 
@@ -369,7 +376,7 @@ pub fn count_anthropic_tokens(
         // 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)
+            .map(|tokens| (tokens + tokens_from_images) as u64)
     })
     .boxed()
 }
@@ -379,22 +386,27 @@ impl AnthropicModel {
         &self,
         request: anthropic::Request,
         cx: &AsyncApp,
-    ) -> BoxFuture<'static, Result<BoxStream<'static, Result<anthropic::Event, AnthropicError>>>>
-    {
+    ) -> BoxFuture<
+        'static,
+        Result<
+            BoxStream<'static, Result<anthropic::Event, AnthropicError>>,
+            LanguageModelCompletionError,
+        >,
+    > {
         let http_client = self.http_client.clone();
 
         let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| {
             let settings = &AllLanguageModelSettings::get_global(cx).anthropic;
             (state.api_key.clone(), settings.api_url.clone())
         }) else {
-            return futures::future::ready(Err(anyhow!("App state dropped"))).boxed();
+            return futures::future::ready(Err(anyhow!("App state dropped").into())).boxed();
         };
 
         async move {
-            let api_key = api_key.ok_or_else(|| anyhow!("Missing Anthropic API Key"))?;
+            let api_key = api_key.context("Missing Anthropic API Key")?;
             let request =
                 anthropic::stream_completion(http_client.as_ref(), &api_url, &api_key, request);
-            request.await.context("failed to stream completion")
+            request.await.map_err(Into::into)
         }
         .boxed()
     }
@@ -421,6 +433,10 @@ impl LanguageModel for AnthropicModel {
         true
     }
 
+    fn supports_images(&self) -> bool {
+        true
+    }
+
     fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
         match choice {
             LanguageModelToolChoice::Auto
@@ -437,11 +453,11 @@ impl LanguageModel for AnthropicModel {
         self.state.read(cx).api_key.clone()
     }
 
-    fn max_token_count(&self) -> usize {
+    fn max_token_count(&self) -> u64 {
         self.model.max_token_count()
     }
 
-    fn max_output_tokens(&self) -> Option<u32> {
+    fn max_output_tokens(&self) -> Option<u64> {
         Some(self.model.max_output_tokens())
     }
 
@@ -449,7 +465,7 @@ impl LanguageModel for AnthropicModel {
         &self,
         request: LanguageModelRequest,
         cx: &App,
-    ) -> BoxFuture<'static, Result<usize>> {
+    ) -> BoxFuture<'static, Result<u64>> {
         count_anthropic_tokens(request, cx)
     }
 
@@ -461,6 +477,7 @@ impl LanguageModel for AnthropicModel {
         'static,
         Result<
             BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
+            LanguageModelCompletionError,
         >,
     > {
         let request = into_anthropic(
@@ -472,12 +489,7 @@ impl LanguageModel for AnthropicModel {
         );
         let request = self.stream_completion(request, cx);
         let future = self.request_limiter.stream(async move {
-            let response = request
-                .await
-                .map_err(|err| match err.downcast::<AnthropicError>() {
-                    Ok(anthropic_err) => anthropic_err_to_anyhow(anthropic_err),
-                    Err(err) => anyhow!(err),
-                })?;
+            let response = request.await?;
             Ok(AnthropicEventMapper::new().map_stream(response))
         });
         async move { Ok(future.await?.boxed()) }.boxed()
@@ -498,7 +510,7 @@ pub fn into_anthropic(
     request: LanguageModelRequest,
     model: String,
     default_temperature: f32,
-    max_output_tokens: u32,
+    max_output_tokens: u64,
     mode: AnthropicModelMode,
 ) -> anthropic::Request {
     let mut new_messages: Vec<anthropic::Message> = Vec::new();
@@ -511,14 +523,7 @@ pub fn into_anthropic(
 
         match message.role {
             Role::User | Role::Assistant => {
-                let cache_control = if message.cache {
-                    Some(anthropic::CacheControl {
-                        cache_type: anthropic::CacheControlType::Ephemeral,
-                    })
-                } else {
-                    None
-                };
-                let anthropic_message_content: Vec<anthropic::RequestContent> = message
+                let mut anthropic_message_content: Vec<anthropic::RequestContent> = message
                     .content
                     .into_iter()
                     .filter_map(|content| match content {
@@ -526,7 +531,7 @@ pub fn into_anthropic(
                             if !text.is_empty() {
                                 Some(anthropic::RequestContent::Text {
                                     text,
-                                    cache_control,
+                                    cache_control: None,
                                 })
                             } else {
                                 None
@@ -540,7 +545,7 @@ pub fn into_anthropic(
                                 Some(anthropic::RequestContent::Thinking {
                                     thinking,
                                     signature: signature.unwrap_or_default(),
-                                    cache_control,
+                                    cache_control: None,
                                 })
                             } else {
                                 None
@@ -548,9 +553,7 @@ pub fn into_anthropic(
                         }
                         MessageContent::RedactedThinking(data) => {
                             if !data.is_empty() {
-                                Some(anthropic::RequestContent::RedactedThinking {
-                                    data: String::from_utf8(data).ok()?,
-                                })
+                                Some(anthropic::RequestContent::RedactedThinking { data })
                             } else {
                                 None
                             }
@@ -561,22 +564,35 @@ pub fn into_anthropic(
                                 media_type: "image/png".to_string(),
                                 data: image.source.to_string(),
                             },
-                            cache_control,
+                            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,
+                                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: tool_result.content.to_string(),
-                                cache_control,
+                                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,
                             })
                         }
                     })
@@ -592,6 +608,29 @@ pub fn into_anthropic(
                         continue;
                     }
                 }
+
+                // Mark the last segment of the message as cached
+                if message.cache {
+                    let cache_control_value = Some(anthropic::CacheControl {
+                        cache_type: anthropic::CacheControlType::Ephemeral,
+                    });
+                    for message_content in anthropic_message_content.iter_mut().rev() {
+                        match message_content {
+                            anthropic::RequestContent::RedactedThinking { .. } => {
+                                // Caching is not possible, fallback to next message
+                            }
+                            anthropic::RequestContent::Text { cache_control, .. }
+                            | anthropic::RequestContent::Thinking { cache_control, .. }
+                            | anthropic::RequestContent::Image { cache_control, .. }
+                            | anthropic::RequestContent::ToolUse { cache_control, .. }
+                            | anthropic::RequestContent::ToolResult { cache_control, .. } => {
+                                *cache_control = cache_control_value;
+                                break;
+                            }
+                        }
+                    }
+                }
+
                 new_messages.push(anthropic::Message {
                     role: anthropic_role,
                     content: anthropic_message_content,
@@ -665,7 +704,7 @@ impl AnthropicEventMapper {
         events.flat_map(move |event| {
             futures::stream::iter(match event {
                 Ok(event) => self.map_event(event),
-                Err(error) => vec![Err(LanguageModelCompletionError::Other(anyhow!(error)))],
+                Err(error) => vec![Err(error.into())],
             })
         })
     }
@@ -688,10 +727,8 @@ impl AnthropicEventMapper {
                         signature: None,
                     })]
                 }
-                ResponseContent::RedactedThinking { .. } => {
-                    // Redacted thinking is encrypted and not accessible to the user, see:
-                    // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#suggestions-for-handling-redacted-thinking-in-production
-                    Vec::new()
+                ResponseContent::RedactedThinking { data } => {
+                    vec![Ok(LanguageModelCompletionEvent::RedactedThinking { data })]
                 }
                 ResponseContent::ToolUse { id, name, .. } => {
                     self.tool_uses_by_index.insert(
@@ -795,6 +832,7 @@ impl AnthropicEventMapper {
                         "end_turn" => StopReason::EndTurn,
                         "max_tokens" => StopReason::MaxTokens,
                         "tool_use" => StopReason::ToolUse,
+                        "refusal" => StopReason::Refusal,
                         _ => {
                             log::error!("Unexpected anthropic stop_reason: {stop_reason}");
                             StopReason::EndTurn
@@ -809,9 +847,7 @@ impl AnthropicEventMapper {
                 vec![Ok(LanguageModelCompletionEvent::Stop(self.stop_reason))]
             }
             Event::Error { error } => {
-                vec![Err(LanguageModelCompletionError::Other(anyhow!(
-                    AnthropicError::ApiError(error)
-                )))]
+                vec![Err(error.into())]
             }
             _ => Vec::new(),
         }
@@ -824,16 +860,6 @@ struct RawToolUse {
     input_json: String,
 }
 
-pub fn anthropic_err_to_anyhow(err: AnthropicError) -> anyhow::Error {
-    if let AnthropicError::ApiError(api_err) = &err {
-        if let Some(tokens) = api_err.match_window_exceeded() {
-            return anyhow!(LanguageModelKnownError::ContextWindowLimitExceeded { tokens });
-        }
-    }
-
-    anyhow!(err)
-}
-
 /// Updates usage data by preferring counts from `new`.
 fn update_usage(usage: &mut Usage, new: &Usage) {
     if let Some(input_tokens) = new.input_tokens {
@@ -1042,3 +1068,75 @@ impl Render for ConfigurationView {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use anthropic::AnthropicModelMode;
+    use language_model::{LanguageModelRequestMessage, MessageContent};
+
+    #[test]
+    fn test_cache_control_only_on_last_segment() {
+        let request = LanguageModelRequest {
+            messages: vec![LanguageModelRequestMessage {
+                role: Role::User,
+                content: vec![
+                    MessageContent::Text("Some prompt".to_string()),
+                    MessageContent::Image(language_model::LanguageModelImage::empty()),
+                    MessageContent::Image(language_model::LanguageModelImage::empty()),
+                    MessageContent::Image(language_model::LanguageModelImage::empty()),
+                    MessageContent::Image(language_model::LanguageModelImage::empty()),
+                ],
+                cache: true,
+            }],
+            thread_id: None,
+            prompt_id: None,
+            intent: None,
+            mode: None,
+            stop: vec![],
+            temperature: None,
+            tools: vec![],
+            tool_choice: None,
+        };
+
+        let anthropic_request = into_anthropic(
+            request,
+            "claude-3-5-sonnet".to_string(),
+            0.7,
+            4096,
+            AnthropicModelMode::Default,
+        );
+
+        assert_eq!(anthropic_request.messages.len(), 1);
+
+        let message = &anthropic_request.messages[0];
+        assert_eq!(message.content.len(), 5);
+
+        assert!(matches!(
+            message.content[0],
+            anthropic::RequestContent::Text {
+                cache_control: None,
+                ..
+            }
+        ));
+        for i in 1..3 {
+            assert!(matches!(
+                message.content[i],
+                anthropic::RequestContent::Image {
+                    cache_control: None,
+                    ..
+                }
+            ));
+        }
+
+        assert!(matches!(
+            message.content[4],
+            anthropic::RequestContent::Image {
+                cache_control: Some(anthropic::CacheControl {
+                    cache_type: anthropic::CacheControlType::Ephemeral,
+                }),
+                ..
+            }
+        ));
+    }
+}

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

@@ -11,8 +11,8 @@ use aws_http_client::AwsHttpClient;
 use bedrock::bedrock_client::Client as BedrockClient;
 use bedrock::bedrock_client::config::timeout::TimeoutConfig;
 use bedrock::bedrock_client::types::{
-    ContentBlockDelta, ContentBlockStart, ConverseStreamOutput, ReasoningContentBlockDelta,
-    StopReason,
+    CachePointBlock, CachePointType, ContentBlockDelta, ContentBlockStart, ConverseStreamOutput,
+    ReasoningContentBlockDelta, StopReason,
 };
 use bedrock::{
     BedrockAnyToolChoice, BedrockAutoToolChoice, BedrockBlob, BedrockError, BedrockInnerContent,
@@ -36,7 +36,8 @@ use language_model::{
     LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName,
     LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
     LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
-    LanguageModelToolUse, MessageContent, RateLimiter, Role, TokenUsage,
+    LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role,
+    TokenUsage,
 };
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
@@ -47,7 +48,7 @@ use strum::{EnumIter, IntoEnumIterator, IntoStaticStr};
 use theme::ThemeSettings;
 use tokio::runtime::Handle;
 use ui::{Icon, IconName, List, Tooltip, prelude::*};
-use util::{ResultExt, default};
+use util::ResultExt;
 
 use crate::AllLanguageModelSettings;
 
@@ -87,9 +88,9 @@ pub enum BedrockAuthMethod {
 pub struct AvailableModel {
     pub name: String,
     pub display_name: Option<String>,
-    pub max_tokens: usize,
+    pub max_tokens: u64,
     pub cache_configuration: Option<LanguageModelCacheConfiguration>,
-    pub max_output_tokens: Option<u32>,
+    pub max_output_tokens: Option<u64>,
     pub default_temperature: Option<f32>,
     pub mode: Option<ModelMode>,
 }
@@ -228,6 +229,17 @@ impl State {
             Ok(())
         })
     }
+
+    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"))
+    }
 }
 
 pub struct BedrockLanguageModelProvider {
@@ -288,8 +300,9 @@ impl LanguageModelProvider for BedrockLanguageModelProvider {
         Some(self.create_language_model(bedrock::Model::default()))
     }
 
-    fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
-        Some(self.create_language_model(bedrock::Model::default_fast()))
+    fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
+        let region = self.state.read(cx).get_region();
+        Some(self.create_language_model(bedrock::Model::default_fast(region.as_str())))
     }
 
     fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
@@ -316,6 +329,12 @@ impl LanguageModelProvider for BedrockLanguageModelProvider {
                     max_tokens: model.max_tokens,
                     max_output_tokens: model.max_output_tokens,
                     default_temperature: model.default_temperature,
+                    cache_configuration: model.cache_configuration.as_ref().map(|config| {
+                        bedrock::BedrockModelCacheConfiguration {
+                            max_cache_anchors: config.max_cache_anchors,
+                            min_total_token: config.min_total_token,
+                        }
+                    }),
                 },
             );
         }
@@ -364,10 +383,10 @@ struct BedrockModel {
 }
 
 impl BedrockModel {
-    fn get_or_init_client(&self, cx: &AsyncApp) -> Result<&BedrockClient, anyhow::Error> {
+    fn get_or_init_client(&self, cx: &AsyncApp) -> anyhow::Result<&BedrockClient> {
         self.client
             .get_or_try_init_blocking(|| {
-                let Ok((auth_method, credentials, endpoint, region, settings)) =
+                let (auth_method, credentials, endpoint, region, settings) =
                     cx.read_entity(&self.state, |state, _cx| {
                         let auth_method = state
                             .settings
@@ -376,11 +395,7 @@ impl BedrockModel {
 
                         let endpoint = state.settings.as_ref().and_then(|s| s.endpoint.clone());
 
-                        let region = state
-                            .settings
-                            .as_ref()
-                            .and_then(|s| s.region.clone())
-                            .unwrap_or(String::from("us-east-1"));
+                        let region = state.get_region();
 
                         (
                             auth_method,
@@ -389,10 +404,7 @@ impl BedrockModel {
                             region,
                             state.settings.clone(),
                         )
-                    })
-                else {
-                    return Err(anyhow!("App state dropped"));
-                };
+                    })?;
 
                 let mut config_builder = aws_config::defaults(BehaviorVersion::latest())
                     .stalled_stream_protection(StalledStreamProtectionConfig::disabled())
@@ -437,13 +449,11 @@ impl BedrockModel {
                 }
 
                 let config = self.handler.block_on(config_builder.load());
-                Ok(BedrockClient::new(&config))
+                anyhow::Ok(BedrockClient::new(&config))
             })
-            .map_err(|err| anyhow!("Failed to initialize Bedrock client: {err}"))?;
+            .context("initializing Bedrock client")?;
 
-        self.client
-            .get()
-            .ok_or_else(|| anyhow!("Bedrock client not initialized"))
+        self.client.get().context("Bedrock client not initialized")
     }
 
     fn stream_completion(
@@ -490,12 +500,17 @@ impl LanguageModel for BedrockModel {
         self.model.supports_tool_use()
     }
 
+    fn supports_images(&self) -> bool {
+        false
+    }
+
     fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
         match choice {
             LanguageModelToolChoice::Auto | LanguageModelToolChoice::Any => {
                 self.model.supports_tool_use()
             }
-            LanguageModelToolChoice::None => false,
+            // Add support for None - we'll filter tool calls at response
+            LanguageModelToolChoice::None => self.model.supports_tool_use(),
         }
     }
 
@@ -503,11 +518,11 @@ impl LanguageModel for BedrockModel {
         format!("bedrock/{}", self.model.id())
     }
 
-    fn max_token_count(&self) -> usize {
+    fn max_token_count(&self) -> u64 {
         self.model.max_token_count()
     }
 
-    fn max_output_tokens(&self) -> Option<u32> {
+    fn max_output_tokens(&self) -> Option<u64> {
         Some(self.model.max_output_tokens())
     }
 
@@ -515,7 +530,7 @@ impl LanguageModel for BedrockModel {
         &self,
         request: LanguageModelRequest,
         cx: &App,
-    ) -> BoxFuture<'static, Result<usize>> {
+    ) -> BoxFuture<'static, Result<u64>> {
         get_bedrock_tokens(request, cx)
     }
 
@@ -527,37 +542,32 @@ impl LanguageModel for BedrockModel {
         'static,
         Result<
             BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
+            LanguageModelCompletionError,
         >,
     > {
-        let Ok(region) = cx.read_entity(&self.state, |state, _cx| {
-            // Get region - from credentials or directly from settings
-            let region = state
-                .credentials
-                .as_ref()
-                .map(|s| s.region.clone())
-                .unwrap_or(String::from("us-east-1"));
-
-            region
-        }) else {
-            return async move { Err(anyhow!("App State Dropped")) }.boxed();
+        let Ok(region) = cx.read_entity(&self.state, |state, _cx| state.get_region()) else {
+            return async move { Err(anyhow::anyhow!("App State Dropped").into()) }.boxed();
         };
 
         let model_id = match self.model.cross_region_inference_id(&region) {
             Ok(s) => s,
             Err(e) => {
-                return async move { Err(e) }.boxed();
+                return async move { Err(e.into()) }.boxed();
             }
         };
 
+        let deny_tool_calls = request.tool_choice == Some(LanguageModelToolChoice::None);
+
         let request = match into_bedrock(
             request,
             model_id,
             self.model.default_temperature(),
             self.model.max_output_tokens(),
             self.model.mode(),
+            self.model.supports_caching(),
         ) {
             Ok(request) => request,
-            Err(err) => return futures::future::ready(Err(err)).boxed(),
+            Err(err) => return futures::future::ready(Err(err.into())).boxed(),
         };
 
         let owned_handle = self.handler.clone();
@@ -565,25 +575,53 @@ impl LanguageModel for BedrockModel {
         let request = self.stream_completion(request, cx);
         let future = self.request_limiter.stream(async move {
             let response = request.map_err(|err| anyhow!(err))?.await;
-            Ok(map_to_language_model_completion_events(
-                response,
-                owned_handle,
-            ))
+            let events = map_to_language_model_completion_events(response, owned_handle);
+
+            if deny_tool_calls {
+                Ok(deny_tool_use_events(events).boxed())
+            } else {
+                Ok(events.boxed())
+            }
         });
+
         async move { Ok(future.await?.boxed()) }.boxed()
     }
 
     fn cache_configuration(&self) -> Option<LanguageModelCacheConfiguration> {
-        None
+        self.model
+            .cache_configuration()
+            .map(|config| LanguageModelCacheConfiguration {
+                max_cache_anchors: config.max_cache_anchors,
+                should_speculate: false,
+                min_total_token: config.min_total_token,
+            })
     }
 }
 
+fn deny_tool_use_events(
+    events: impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
+) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
+    events.map(|event| {
+        match event {
+            Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => {
+                // Convert tool use to an error message if model decided to call it
+                Ok(LanguageModelCompletionEvent::Text(format!(
+                    "\n\n[Error: Tool calls are disabled in this context. Attempted to call '{}']",
+                    tool_use.name
+                )))
+            }
+            other => other,
+        }
+    })
+}
+
 pub fn into_bedrock(
     request: LanguageModelRequest,
     model: String,
     default_temperature: f32,
-    max_output_tokens: u32,
+    max_output_tokens: u64,
     mode: BedrockModelMode,
+    supports_caching: bool,
 ) -> Result<bedrock::Request> {
     let mut new_messages: Vec<BedrockMessage> = Vec::new();
     let mut system_message = String::new();
@@ -595,7 +633,7 @@ pub fn into_bedrock(
 
         match message.role {
             Role::User | Role::Assistant => {
-                let bedrock_message_content: Vec<BedrockInnerContent> = message
+                let mut bedrock_message_content: Vec<BedrockInnerContent> = message
                     .content
                     .into_iter()
                     .filter_map(|content| match content {
@@ -607,6 +645,11 @@ pub fn into_bedrock(
                             }
                         }
                         MessageContent::Thinking { text, signature } => {
+                            if model.contains(Model::DeepSeekR1.request_id()) {
+                                // DeepSeekR1 doesn't support thinking blocks
+                                // And the AWS API demands that you strip them
+                                return None;
+                            }
                             let thinking = BedrockThinkingTextBlock::builder()
                                 .text(text)
                                 .set_signature(signature)
@@ -619,25 +662,46 @@ pub fn into_bedrock(
                             ))
                         }
                         MessageContent::RedactedThinking(blob) => {
+                            if model.contains(Model::DeepSeekR1.request_id()) {
+                                // DeepSeekR1 doesn't support thinking blocks
+                                // And the AWS API demands that you strip them
+                                return None;
+                            }
                             let redacted =
                                 BedrockThinkingBlock::RedactedContent(BedrockBlob::new(blob));
 
                             Some(BedrockInnerContent::ReasoningContent(redacted))
                         }
-                        MessageContent::ToolUse(tool_use) => BedrockToolUseBlock::builder()
-                            .name(tool_use.name.to_string())
-                            .tool_use_id(tool_use.id.to_string())
-                            .input(value_to_aws_document(&tool_use.input))
-                            .build()
-                            .context("failed to build Bedrock tool use block")
-                            .log_err()
-                            .map(BedrockInnerContent::ToolUse),
+                        MessageContent::ToolUse(tool_use) => {
+                            let input = if tool_use.input.is_null() {
+                                // Bedrock API requires valid JsonValue, not null, for tool use input
+                                value_to_aws_document(&serde_json::json!({}))
+                            } else {
+                                value_to_aws_document(&tool_use.input)
+                            };
+                            BedrockToolUseBlock::builder()
+                                .name(tool_use.name.to_string())
+                                .tool_use_id(tool_use.id.to_string())
+                                .input(input)
+                                .build()
+                                .context("failed to build Bedrock tool use block")
+                                .log_err()
+                                .map(BedrockInnerContent::ToolUse)
+                        },
                         MessageContent::ToolResult(tool_result) => {
                             BedrockToolResultBlock::builder()
                                 .tool_use_id(tool_result.tool_use_id.to_string())
-                                .content(BedrockToolResultContentBlock::Text(
-                                    tool_result.content.to_string(),
-                                ))
+                                .content(match tool_result.content {
+                                    LanguageModelToolResultContent::Text(text) => {
+                                        BedrockToolResultContentBlock::Text(text.to_string())
+                                    }
+                                    LanguageModelToolResultContent::Image(_) => {
+                                        BedrockToolResultContentBlock::Text(
+                                            // TODO: Bedrock image support
+                                            "[Tool responded with an image, but Zed doesn't support these in Bedrock models yet]".to_string()
+                                        )
+                                    }
+                                })
                                 .status({
                                     if tool_result.is_error {
                                         BedrockToolResultStatus::Error
@@ -653,6 +717,14 @@ pub fn into_bedrock(
                         _ => None,
                     })
                     .collect();
+                if message.cache && supports_caching {
+                    bedrock_message_content.push(BedrockInnerContent::CachePoint(
+                        CachePointBlock::builder()
+                            .r#type(CachePointType::Default)
+                            .build()
+                            .context("failed to build cache point block")?,
+                    ));
+                }
                 let bedrock_role = match message.role {
                     Role::User => bedrock::BedrockRole::User,
                     Role::Assistant => bedrock::BedrockRole::Assistant,
@@ -681,7 +753,7 @@ pub fn into_bedrock(
         }
     }
 
-    let tool_spec: Vec<BedrockTool> = request
+    let mut tool_spec: Vec<BedrockTool> = request
         .tools
         .iter()
         .filter_map(|tool| {
@@ -698,6 +770,15 @@ pub fn into_bedrock(
         })
         .collect();
 
+    if !tool_spec.is_empty() && supports_caching {
+        tool_spec.push(BedrockTool::CachePoint(
+            CachePointBlock::builder()
+                .r#type(CachePointType::Default)
+                .build()
+                .context("failed to build cache point block")?,
+        ));
+    }
+
     let tool_choice = match request.tool_choice {
         Some(LanguageModelToolChoice::Auto) | None => {
             BedrockToolChoice::Auto(BedrockAutoToolChoice::builder().build())
@@ -706,7 +787,8 @@ pub fn into_bedrock(
             BedrockToolChoice::Any(BedrockAnyToolChoice::builder().build())
         }
         Some(LanguageModelToolChoice::None) => {
-            return Err(anyhow!("LanguageModelToolChoice::None is not supported"));
+            // For None, we still use Auto but will filter out tool calls in the response
+            BedrockToolChoice::Auto(BedrockAutoToolChoice::builder().build())
         }
     };
     let tool_config: BedrockToolConfig = BedrockToolConfig::builder()
@@ -738,7 +820,7 @@ pub fn into_bedrock(
 pub fn get_bedrock_tokens(
     request: LanguageModelRequest,
     cx: &App,
-) -> BoxFuture<'static, Result<usize>> {
+) -> BoxFuture<'static, Result<u64>> {
     cx.background_executor()
         .spawn(async move {
             let messages = request.messages;
@@ -762,9 +844,14 @@ pub fn get_bedrock_tokens(
                         MessageContent::ToolUse(_tool_use) => {
                             // TODO: Estimate token usage from tool uses.
                         }
-                        MessageContent::ToolResult(tool_result) => {
-                            string_contents.push_str(&tool_result.content);
-                        }
+                        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();
+                            }
+                        },
                     }
                 }
 
@@ -785,7 +872,7 @@ pub fn get_bedrock_tokens(
             // 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)
+                .map(|tokens| (tokens + tokens_from_images) as u64)
         })
         .boxed()
 }
@@ -933,11 +1020,12 @@ pub fn map_to_language_model_completion_events(
                                             let completion_event =
                                                 LanguageModelCompletionEvent::UsageUpdate(
                                                     TokenUsage {
-                                                        input_tokens: metadata.input_tokens as u32,
-                                                        output_tokens: metadata.output_tokens
-                                                            as u32,
-                                                        cache_creation_input_tokens: default(),
-                                                        cache_read_input_tokens: default(),
+                                                        input_tokens: metadata.input_tokens as u64,
+                                                        output_tokens: metadata.output_tokens as u64,
+                                                        cache_creation_input_tokens:
+                                                            metadata.cache_write_input_tokens.unwrap_or_default() as u64,
+                                                        cache_read_input_tokens:
+                                                            metadata.cache_read_input_tokens.unwrap_or_default() as u64,
                                                     },
                                                 );
                                             return Some((Some(Ok(completion_event)), state));

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

@@ -1,70 +1,54 @@
 use anthropic::{AnthropicModelMode, parse_prompt_too_long};
-use anyhow::{Result, anyhow};
-use client::{Client, UserStore, zed_urls};
-use collections::BTreeMap;
-use feature_flags::{FeatureFlagAppExt, LlmClosedBetaFeatureFlag};
+use anyhow::{Context as _, Result, anyhow};
+use client::{Client, ModelRequestUsage, UserStore, zed_urls};
 use futures::{
     AsyncBufReadExt, FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream,
 };
+use google_ai::GoogleModelMode;
 use gpui::{
     AnyElement, AnyView, App, AsyncApp, Context, Entity, SemanticVersion, Subscription, Task,
 };
 use http_client::{AsyncBody, HttpClient, Method, Response, StatusCode};
 use language_model::{
-    AuthenticateError, CloudModel, LanguageModel, LanguageModelCacheConfiguration,
+    AuthenticateError, LanguageModel, LanguageModelCacheConfiguration,
     LanguageModelCompletionError, LanguageModelId, LanguageModelKnownError, LanguageModelName,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
     LanguageModelProviderTosView, LanguageModelRequest, LanguageModelToolChoice,
-    LanguageModelToolSchemaFormat, ModelRequestLimitReachedError, RateLimiter, RequestUsage,
+    LanguageModelToolSchemaFormat, ModelRequestLimitReachedError, RateLimiter,
     ZED_CLOUD_PROVIDER_ID,
 };
 use language_model::{
-    LanguageModelAvailability, LanguageModelCompletionEvent, LanguageModelProvider, LlmApiToken,
-    MaxMonthlySpendReachedError, PaymentRequiredError, RefreshLlmTokenListener,
+    LanguageModelCompletionEvent, LanguageModelProvider, LlmApiToken, PaymentRequiredError,
+    RefreshLlmTokenListener,
 };
 use proto::Plan;
 use release_channel::AppVersion;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize, de::DeserializeOwned};
-use settings::{Settings, SettingsStore};
+use settings::SettingsStore;
 use smol::Timer;
 use smol::io::{AsyncReadExt, BufReader};
 use std::pin::Pin;
 use std::str::FromStr as _;
-use std::{
-    sync::{Arc, LazyLock},
-    time::Duration,
-};
-use strum::IntoEnumIterator;
+use std::sync::Arc;
+use std::time::Duration;
 use thiserror::Error;
 use ui::{TintColor, prelude::*};
+use util::{ResultExt as _, maybe};
 use zed_llm_client::{
     CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CURRENT_PLAN_HEADER_NAME, CompletionBody,
     CompletionRequestStatus, CountTokensBody, CountTokensResponse, EXPIRED_LLM_TOKEN_HEADER_NAME,
-    MAX_LLM_MONTHLY_SPEND_REACHED_HEADER_NAME, MODEL_REQUESTS_RESOURCE_HEADER_VALUE,
+    ListModelsResponse, MODEL_REQUESTS_RESOURCE_HEADER_VALUE,
     SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, SUBSCRIPTION_LIMIT_RESOURCE_HEADER_NAME,
     TOOL_USE_LIMIT_REACHED_HEADER_NAME, ZED_VERSION_HEADER_NAME,
 };
 
-use crate::AllLanguageModelSettings;
 use crate::provider::anthropic::{AnthropicEventMapper, count_anthropic_tokens, into_anthropic};
 use crate::provider::google::{GoogleEventMapper, into_google};
 use crate::provider::open_ai::{OpenAiEventMapper, count_open_ai_tokens, into_open_ai};
 
 pub const PROVIDER_NAME: &str = "Zed";
 
-const ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: Option<&str> =
-    option_env!("ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON");
-
-fn zed_cloud_provider_additional_models() -> &'static [AvailableModel] {
-    static ADDITIONAL_MODELS: LazyLock<Vec<AvailableModel>> = LazyLock::new(|| {
-        ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON
-            .map(|json| serde_json::from_str(json).unwrap())
-            .unwrap_or_default()
-    });
-    ADDITIONAL_MODELS.as_slice()
-}
-
 #[derive(Default, Clone, Debug, PartialEq)]
 pub struct ZedDotDevSettings {
     pub available_models: Vec<AvailableModel>,
@@ -89,9 +73,9 @@ pub struct AvailableModel {
     /// The size of the context window, indicating the maximum number of tokens the model can process.
     pub max_tokens: usize,
     /// The maximum number of output tokens allowed by the model.
-    pub max_output_tokens: Option<u32>,
+    pub max_output_tokens: Option<u64>,
     /// The maximum number of completion tokens allowed by the model (o1-* only)
-    pub max_completion_tokens: Option<u32>,
+    pub max_completion_tokens: Option<u64>,
     /// Override this model with a different Anthropic model for tool calls.
     pub tool_override: Option<String>,
     /// Indicates whether this custom model supports caching.
@@ -137,6 +121,11 @@ pub struct State {
     user_store: Entity<UserStore>,
     status: client::Status,
     accept_terms: Option<Task<Result<()>>>,
+    models: Vec<Arc<zed_llm_client::LanguageModel>>,
+    default_model: Option<Arc<zed_llm_client::LanguageModel>>,
+    default_fast_model: Option<Arc<zed_llm_client::LanguageModel>>,
+    recommended_models: Vec<Arc<zed_llm_client::LanguageModel>>,
+    _fetch_models_task: Task<()>,
     _settings_subscription: Subscription,
     _llm_token_subscription: Subscription,
 }
@@ -156,6 +145,72 @@ impl State {
             user_store,
             status,
             accept_terms: None,
+            models: Vec::new(),
+            default_model: None,
+            default_fast_model: None,
+            recommended_models: Vec::new(),
+            _fetch_models_task: cx.spawn(async move |this, cx| {
+                maybe!(async move {
+                    let (client, llm_api_token) = this
+                        .read_with(cx, |this, _cx| (client.clone(), this.llm_api_token.clone()))?;
+
+                    loop {
+                        let status = this.read_with(cx, |this, _cx| this.status)?;
+                        if matches!(status, client::Status::Connected { .. }) {
+                            break;
+                        }
+
+                        cx.background_executor()
+                            .timer(Duration::from_millis(100))
+                            .await;
+                    }
+
+                    let response = Self::fetch_models(client, llm_api_token).await?;
+                    cx.update(|cx| {
+                        this.update(cx, |this, cx| {
+                            let mut models = Vec::new();
+
+                            for model in response.models {
+                                models.push(Arc::new(model.clone()));
+
+                                // Right now we represent thinking variants of models as separate models on the client,
+                                // so we need to insert variants for any model that supports thinking.
+                                if model.supports_thinking {
+                                    models.push(Arc::new(zed_llm_client::LanguageModel {
+                                        id: zed_llm_client::LanguageModelId(
+                                            format!("{}-thinking", model.id).into(),
+                                        ),
+                                        display_name: format!("{} Thinking", model.display_name),
+                                        ..model
+                                    }));
+                                }
+                            }
+
+                            this.default_model = models
+                                .iter()
+                                .find(|model| model.id == response.default_model)
+                                .cloned();
+                            this.default_fast_model = models
+                                .iter()
+                                .find(|model| model.id == response.default_fast_model)
+                                .cloned();
+                            this.recommended_models = response
+                                .recommended_models
+                                .iter()
+                                .filter_map(|id| models.iter().find(|model| &model.id == id))
+                                .cloned()
+                                .collect();
+                            this.models = models;
+                            cx.notify();
+                        })
+                    })??;
+
+                    anyhow::Ok(())
+                })
+                .await
+                .context("failed to fetch Zed models")
+                .log_err();
+            }),
             _settings_subscription: cx.observe_global::<SettingsStore>(|_, cx| {
                 cx.notify();
             }),
@@ -208,6 +263,37 @@ impl State {
             })
         }));
     }
+
+    async fn fetch_models(
+        client: Arc<Client>,
+        llm_api_token: LlmApiToken,
+    ) -> Result<ListModelsResponse> {
+        let http_client = &client.http_client();
+        let token = llm_api_token.acquire(&client).await?;
+
+        let request = http_client::Request::builder()
+            .method(Method::GET)
+            .uri(http_client.build_zed_llm_url("/models", &[])?.as_ref())
+            .header("Authorization", format!("Bearer {token}"))
+            .body(AsyncBody::empty())?;
+        let mut response = http_client
+            .send(request)
+            .await
+            .context("failed to send list models request")?;
+
+        if response.status().is_success() {
+            let mut body = String::new();
+            response.body_mut().read_to_string(&mut body).await?;
+            return Ok(serde_json::from_str(&body)?);
+        } else {
+            let mut body = String::new();
+            response.body_mut().read_to_string(&mut body).await?;
+            anyhow::bail!(
+                "error listing models.\nStatus: {:?}\nBody: {body}",
+                response.status(),
+            );
+        }
+    }
 }
 
 impl CloudLanguageModelProvider {
@@ -242,11 +328,11 @@ impl CloudLanguageModelProvider {
 
     fn create_language_model(
         &self,
-        model: CloudModel,
+        model: Arc<zed_llm_client::LanguageModel>,
         llm_api_token: LlmApiToken,
     ) -> Arc<dyn LanguageModel> {
         Arc::new(CloudLanguageModel {
-            id: LanguageModelId::from(model.id().to_string()),
+            id: LanguageModelId(SharedString::from(model.id.0.clone())),
             model,
             llm_api_token: llm_api_token.clone(),
             client: self.client.clone(),
@@ -277,113 +363,35 @@ impl LanguageModelProvider for CloudLanguageModelProvider {
     }
 
     fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
+        let default_model = self.state.read(cx).default_model.clone()?;
         let llm_api_token = self.state.read(cx).llm_api_token.clone();
-        let model = CloudModel::Anthropic(anthropic::Model::Claude3_7Sonnet);
-        Some(self.create_language_model(model, llm_api_token))
+        Some(self.create_language_model(default_model, llm_api_token))
     }
 
     fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
+        let default_fast_model = self.state.read(cx).default_fast_model.clone()?;
         let llm_api_token = self.state.read(cx).llm_api_token.clone();
-        let model = CloudModel::Anthropic(anthropic::Model::Claude3_5Sonnet);
-        Some(self.create_language_model(model, llm_api_token))
+        Some(self.create_language_model(default_fast_model, llm_api_token))
     }
 
     fn recommended_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
         let llm_api_token = self.state.read(cx).llm_api_token.clone();
-        [
-            CloudModel::Anthropic(anthropic::Model::Claude3_7Sonnet),
-            CloudModel::Anthropic(anthropic::Model::Claude3_7SonnetThinking),
-        ]
-        .into_iter()
-        .map(|model| self.create_language_model(model, llm_api_token.clone()))
-        .collect()
+        self.state
+            .read(cx)
+            .recommended_models
+            .iter()
+            .cloned()
+            .map(|model| self.create_language_model(model, llm_api_token.clone()))
+            .collect()
     }
 
     fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
-        let mut models = BTreeMap::default();
-
-        if cx.is_staff() {
-            for model in anthropic::Model::iter() {
-                if !matches!(model, anthropic::Model::Custom { .. }) {
-                    models.insert(model.id().to_string(), CloudModel::Anthropic(model));
-                }
-            }
-            for model in open_ai::Model::iter() {
-                if !matches!(model, open_ai::Model::Custom { .. }) {
-                    models.insert(model.id().to_string(), CloudModel::OpenAi(model));
-                }
-            }
-            for model in google_ai::Model::iter() {
-                if !matches!(model, google_ai::Model::Custom { .. }) {
-                    models.insert(model.id().to_string(), CloudModel::Google(model));
-                }
-            }
-        } else {
-            models.insert(
-                anthropic::Model::Claude3_5Sonnet.id().to_string(),
-                CloudModel::Anthropic(anthropic::Model::Claude3_5Sonnet),
-            );
-            models.insert(
-                anthropic::Model::Claude3_7Sonnet.id().to_string(),
-                CloudModel::Anthropic(anthropic::Model::Claude3_7Sonnet),
-            );
-            models.insert(
-                anthropic::Model::Claude3_7SonnetThinking.id().to_string(),
-                CloudModel::Anthropic(anthropic::Model::Claude3_7SonnetThinking),
-            );
-        }
-
-        let llm_closed_beta_models = if cx.has_flag::<LlmClosedBetaFeatureFlag>() {
-            zed_cloud_provider_additional_models()
-        } else {
-            &[]
-        };
-
-        // Override with available models from settings
-        for model in AllLanguageModelSettings::get_global(cx)
-            .zed_dot_dev
-            .available_models
+        let llm_api_token = self.state.read(cx).llm_api_token.clone();
+        self.state
+            .read(cx)
+            .models
             .iter()
-            .chain(llm_closed_beta_models)
             .cloned()
-        {
-            let model = match model.provider {
-                AvailableProvider::Anthropic => CloudModel::Anthropic(anthropic::Model::Custom {
-                    name: model.name.clone(),
-                    display_name: model.display_name.clone(),
-                    max_tokens: model.max_tokens,
-                    tool_override: model.tool_override.clone(),
-                    cache_configuration: model.cache_configuration.as_ref().map(|config| {
-                        anthropic::AnthropicModelCacheConfiguration {
-                            max_cache_anchors: config.max_cache_anchors,
-                            should_speculate: config.should_speculate,
-                            min_total_token: config.min_total_token,
-                        }
-                    }),
-                    default_temperature: model.default_temperature,
-                    max_output_tokens: model.max_output_tokens,
-                    extra_beta_headers: model.extra_beta_headers.clone(),
-                    mode: model.mode.unwrap_or_default().into(),
-                }),
-                AvailableProvider::OpenAi => CloudModel::OpenAi(open_ai::Model::Custom {
-                    name: model.name.clone(),
-                    display_name: model.display_name.clone(),
-                    max_tokens: model.max_tokens,
-                    max_output_tokens: model.max_output_tokens,
-                    max_completion_tokens: model.max_completion_tokens,
-                }),
-                AvailableProvider::Google => CloudModel::Google(google_ai::Model::Custom {
-                    name: model.name.clone(),
-                    display_name: model.display_name.clone(),
-                    max_tokens: model.max_tokens,
-                }),
-            };
-            models.insert(model.id().to_string(), model.clone());
-        }
-
-        let llm_api_token = self.state.read(cx).llm_api_token.clone();
-        models
-            .into_values()
             .map(|model| self.create_language_model(model, llm_api_token.clone()))
             .collect()
     }
@@ -514,7 +522,7 @@ fn render_accept_terms(
 
 pub struct CloudLanguageModel {
     id: LanguageModelId,
-    model: CloudModel,
+    model: Arc<zed_llm_client::LanguageModel>,
     llm_api_token: LlmApiToken,
     client: Arc<Client>,
     request_limiter: RateLimiter,
@@ -522,7 +530,7 @@ pub struct CloudLanguageModel {
 
 struct PerformLlmCompletionResponse {
     response: Response<AsyncBody>,
-    usage: Option<RequestUsage>,
+    usage: Option<ModelRequestUsage>,
     tool_use_limit_reached: bool,
     includes_status_messages: bool,
 }
@@ -573,7 +581,7 @@ impl CloudLanguageModel {
                 let usage = if includes_status_messages {
                     None
                 } else {
-                    RequestUsage::from_headers(response.headers()).ok()
+                    ModelRequestUsage::from_headers(response.headers()).ok()
                 };
 
                 return Ok(PerformLlmCompletionResponse {
@@ -589,13 +597,6 @@ impl CloudLanguageModel {
             {
                 retries_remaining -= 1;
                 token = llm_api_token.refresh(&client).await?;
-            } else if status == StatusCode::FORBIDDEN
-                && response
-                    .headers()
-                    .get(MAX_LLM_MONTHLY_SPEND_REACHED_HEADER_NAME)
-                    .is_some()
-            {
-                return Err(anyhow!(MaxMonthlySpendReachedError));
             } else if status == StatusCode::FORBIDDEN
                 && response
                     .headers()
@@ -622,7 +623,7 @@ impl CloudLanguageModel {
                     }
                 }
 
-                return Err(anyhow!("Forbidden"));
+                anyhow::bail!("Forbidden");
             } else if status.as_u16() >= 500 && status.as_u16() < 600 {
                 // If we encounter an error in the 500 range, retry after a delay.
                 // We've seen at least these in the wild from API providers:
@@ -633,10 +634,10 @@ impl CloudLanguageModel {
                 if retries_remaining == 0 {
                     let mut body = String::new();
                     response.body_mut().read_to_string(&mut body).await?;
-                    return Err(anyhow!(
+                    anyhow::bail!(
                         "cloud language model completion failed after {} retries with status {status}: {body}",
                         Self::MAX_RETRIES
-                    ));
+                    );
                 }
 
                 Timer::after(retry_delay).await;
@@ -667,7 +668,7 @@ impl LanguageModel for CloudLanguageModel {
     }
 
     fn name(&self) -> LanguageModelName {
-        LanguageModelName::from(self.model.display_name().to_string())
+        LanguageModelName::from(self.model.display_name.clone())
     }
 
     fn provider_id(&self) -> LanguageModelProviderId {
@@ -679,11 +680,11 @@ impl LanguageModel for CloudLanguageModel {
     }
 
     fn supports_tools(&self) -> bool {
-        match self.model {
-            CloudModel::Anthropic(_) => true,
-            CloudModel::Google(_) => true,
-            CloudModel::OpenAi(_) => true,
-        }
+        self.model.supports_tools
+    }
+
+    fn supports_images(&self) -> bool {
+        self.model.supports_images
     }
 
     fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
@@ -694,34 +695,41 @@ impl LanguageModel for CloudLanguageModel {
         }
     }
 
-    fn telemetry_id(&self) -> String {
-        format!("zed.dev/{}", self.model.id())
+    fn supports_burn_mode(&self) -> bool {
+        self.model.supports_max_mode
     }
 
-    fn availability(&self) -> LanguageModelAvailability {
-        self.model.availability()
+    fn telemetry_id(&self) -> String {
+        format!("zed.dev/{}", self.model.id)
     }
 
     fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
-        self.model.tool_input_format()
+        match self.model.provider {
+            zed_llm_client::LanguageModelProvider::Anthropic
+            | zed_llm_client::LanguageModelProvider::OpenAi => {
+                LanguageModelToolSchemaFormat::JsonSchema
+            }
+            zed_llm_client::LanguageModelProvider::Google => {
+                LanguageModelToolSchemaFormat::JsonSchemaSubset
+            }
+        }
     }
 
-    fn max_token_count(&self) -> usize {
-        self.model.max_token_count()
+    fn max_token_count(&self) -> u64 {
+        self.model.max_token_count as u64
     }
 
     fn cache_configuration(&self) -> Option<LanguageModelCacheConfiguration> {
-        match &self.model {
-            CloudModel::Anthropic(model) => {
-                model
-                    .cache_configuration()
-                    .map(|cache| LanguageModelCacheConfiguration {
-                        max_cache_anchors: cache.max_cache_anchors,
-                        should_speculate: cache.should_speculate,
-                        min_total_token: cache.min_total_token,
-                    })
+        match &self.model.provider {
+            zed_llm_client::LanguageModelProvider::Anthropic => {
+                Some(LanguageModelCacheConfiguration {
+                    min_total_token: 2_048,
+                    should_speculate: true,
+                    max_cache_anchors: 4,
+                })
             }
-            CloudModel::OpenAi(_) | CloudModel::Google(_) => None,
+            zed_llm_client::LanguageModelProvider::OpenAi
+            | zed_llm_client::LanguageModelProvider::Google => None,
         }
     }
 
@@ -729,15 +737,22 @@ impl LanguageModel for CloudLanguageModel {
         &self,
         request: LanguageModelRequest,
         cx: &App,
-    ) -> BoxFuture<'static, Result<usize>> {
-        match self.model.clone() {
-            CloudModel::Anthropic(_) => count_anthropic_tokens(request, cx),
-            CloudModel::OpenAi(model) => count_open_ai_tokens(request, model, cx),
-            CloudModel::Google(model) => {
+    ) -> BoxFuture<'static, Result<u64>> {
+        match self.model.provider {
+            zed_llm_client::LanguageModelProvider::Anthropic => count_anthropic_tokens(request, cx),
+            zed_llm_client::LanguageModelProvider::OpenAi => {
+                let model = match open_ai::Model::from_id(&self.model.id.0) {
+                    Ok(model) => model,
+                    Err(err) => return async move { Err(anyhow!(err)) }.boxed(),
+                };
+                count_open_ai_tokens(request, model, cx)
+            }
+            zed_llm_client::LanguageModelProvider::Google => {
                 let client = self.client.clone();
                 let llm_api_token = self.llm_api_token.clone();
-                let model_id = model.id().to_string();
-                let generate_content_request = into_google(request, model_id.clone());
+                let model_id = self.model.id.to_string();
+                let generate_content_request =
+                    into_google(request, model_id.clone(), GoogleModelMode::Default);
                 async move {
                     let http_client = &client.http_client();
                     let token = llm_api_token.acquire(&client).await?;
@@ -771,7 +786,7 @@ impl LanguageModel for CloudLanguageModel {
                         let response_body: CountTokensResponse =
                             serde_json::from_str(&response_body)?;
 
-                        Ok(response_body.tokens)
+                        Ok(response_body.tokens as u64)
                     } else {
                         Err(anyhow!(ApiError {
                             status,
@@ -792,20 +807,28 @@ impl LanguageModel for CloudLanguageModel {
         'static,
         Result<
             BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
+            LanguageModelCompletionError,
         >,
     > {
         let thread_id = request.thread_id.clone();
         let prompt_id = request.prompt_id.clone();
+        let intent = request.intent;
         let mode = request.mode;
         let app_version = cx.update(|cx| AppVersion::global(cx)).ok();
-        match &self.model {
-            CloudModel::Anthropic(model) => {
+        match self.model.provider {
+            zed_llm_client::LanguageModelProvider::Anthropic => {
                 let request = into_anthropic(
                     request,
-                    model.request_id().into(),
-                    model.default_temperature(),
-                    model.max_output_tokens(),
-                    model.mode(),
+                    self.model.id.to_string(),
+                    1.0,
+                    self.model.max_output_tokens as u64,
+                    if self.model.id.0.ends_with("-thinking") {
+                        AnthropicModelMode::Thinking {
+                            budget_tokens: Some(4_096),
+                        }
+                    } else {
+                        AnthropicModelMode::Default
+                    },
                 );
                 let client = self.client.clone();
                 let llm_api_token = self.llm_api_token.clone();
@@ -822,10 +845,12 @@ impl LanguageModel for CloudLanguageModel {
                         CompletionBody {
                             thread_id,
                             prompt_id,
+                            intent,
                             mode,
                             provider: zed_llm_client::LanguageModelProvider::Anthropic,
                             model: request.model.clone(),
-                            provider_request: serde_json::to_value(&request)?,
+                            provider_request: serde_json::to_value(&request)
+                                .map_err(|e| anyhow!(e))?,
                         },
                     )
                     .await
@@ -857,9 +882,18 @@ impl LanguageModel for CloudLanguageModel {
                 });
                 async move { Ok(future.await?.boxed()) }.boxed()
             }
-            CloudModel::OpenAi(model) => {
+            zed_llm_client::LanguageModelProvider::OpenAi => {
                 let client = self.client.clone();
-                let request = into_open_ai(request, model, model.max_output_tokens());
+                let model = match open_ai::Model::from_id(&self.model.id.0) {
+                    Ok(model) => model,
+                    Err(err) => return async move { Err(anyhow!(err).into()) }.boxed(),
+                };
+                let request = into_open_ai(
+                    request,
+                    model.id(),
+                    model.supports_parallel_tool_calls(),
+                    None,
+                );
                 let llm_api_token = self.llm_api_token.clone();
                 let future = self.request_limiter.stream(async move {
                     let PerformLlmCompletionResponse {
@@ -874,10 +908,12 @@ impl LanguageModel for CloudLanguageModel {
                         CompletionBody {
                             thread_id,
                             prompt_id,
+                            intent,
                             mode,
                             provider: zed_llm_client::LanguageModelProvider::OpenAi,
                             model: request.model.clone(),
-                            provider_request: serde_json::to_value(&request)?,
+                            provider_request: serde_json::to_value(&request)
+                                .map_err(|e| anyhow!(e))?,
                         },
                     )
                     .await?;
@@ -894,9 +930,10 @@ impl LanguageModel for CloudLanguageModel {
                 });
                 async move { Ok(future.await?.boxed()) }.boxed()
             }
-            CloudModel::Google(model) => {
+            zed_llm_client::LanguageModelProvider::Google => {
                 let client = self.client.clone();
-                let request = into_google(request, model.id().into());
+                let request =
+                    into_google(request, self.model.id.to_string(), GoogleModelMode::Default);
                 let llm_api_token = self.llm_api_token.clone();
                 let future = self.request_limiter.stream(async move {
                     let PerformLlmCompletionResponse {
@@ -911,10 +948,12 @@ impl LanguageModel for CloudLanguageModel {
                         CompletionBody {
                             thread_id,
                             prompt_id,
+                            intent,
                             mode,
                             provider: zed_llm_client::LanguageModelProvider::Google,
                             model: request.model.model_id.clone(),
-                            provider_request: serde_json::to_value(&request)?,
+                            provider_request: serde_json::to_value(&request)
+                                .map_err(|e| anyhow!(e))?,
                         },
                     )
                     .await?;
@@ -968,7 +1007,7 @@ where
 }
 
 fn usage_updated_event<T>(
-    usage: Option<RequestUsage>,
+    usage: Option<ModelRequestUsage>,
 ) -> impl Stream<Item = Result<CloudCompletionEvent<T>>> {
     futures::stream::iter(usage.map(|usage| {
         Ok(CloudCompletionEvent::Status(

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

@@ -5,8 +5,9 @@ use std::sync::Arc;
 use anyhow::{Result, anyhow};
 use collections::HashMap;
 use copilot::copilot_chat::{
-    ChatMessage, CopilotChat, Model as CopilotChatModel, Request as CopilotChatRequest,
-    ResponseEvent, Tool, ToolCall,
+    ChatMessage, ChatMessageContent, ChatMessagePart, CopilotChat, ImageUrl,
+    Model as CopilotChatModel, ModelVendor, Request as CopilotChatRequest, ResponseEvent, Tool,
+    ToolCall,
 };
 use copilot::{Copilot, Status};
 use futures::future::BoxFuture;
@@ -16,17 +17,19 @@ use gpui::{
     Action, Animation, AnimationExt, AnyView, App, AsyncApp, Entity, Render, Subscription, Task,
     Transformation, percentage, svg,
 };
+use language::language_settings::all_language_settings;
 use language_model::{
     AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
     LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
     LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelRequestMessage, LanguageModelToolChoice, LanguageModelToolUse, MessageContent,
-    RateLimiter, Role, StopReason,
+    LanguageModelRequestMessage, LanguageModelToolChoice, LanguageModelToolResultContent,
+    LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role,
+    StopReason, TokenUsage,
 };
 use settings::SettingsStore;
 use std::time::Duration;
-use strum::IntoEnumIterator;
 use ui::prelude::*;
+use util::debug_panic;
 
 use super::anthropic::count_anthropic_tokens;
 use super::google::count_google_tokens;
@@ -35,9 +38,6 @@ use super::open_ai::count_open_ai_tokens;
 const PROVIDER_ID: &str = "copilot_chat";
 const PROVIDER_NAME: &str = "GitHub Copilot Chat";
 
-#[derive(Default, Clone, Debug, PartialEq)]
-pub struct CopilotChatSettings {}
-
 pub struct CopilotChatLanguageModelProvider {
     state: Entity<State>,
 }
@@ -58,11 +58,24 @@ impl State {
 impl CopilotChatLanguageModelProvider {
     pub fn new(cx: &mut App) -> Self {
         let state = cx.new(|cx| {
-            let _copilot_chat_subscription = CopilotChat::global(cx)
+            let copilot_chat_subscription = CopilotChat::global(cx)
                 .map(|copilot_chat| cx.observe(&copilot_chat, |_, _, cx| cx.notify()));
             State {
-                _copilot_chat_subscription,
+                _copilot_chat_subscription: copilot_chat_subscription,
                 _settings_subscription: cx.observe_global::<SettingsStore>(|_, cx| {
+                    if let Some(copilot_chat) = CopilotChat::global(cx) {
+                        let language_settings = all_language_settings(None, cx);
+                        let configuration = copilot::copilot_chat::CopilotChatConfiguration {
+                            enterprise_uri: language_settings
+                                .edit_predictions
+                                .copilot
+                                .enterprise_uri
+                                .clone(),
+                        };
+                        copilot_chat.update(cx, |chat, cx| {
+                            chat.set_configuration(configuration, cx);
+                        });
+                    }
                     cx.notify();
                 }),
             }
@@ -100,17 +113,26 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider {
         IconName::Copilot
     }
 
-    fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
-        Some(self.create_language_model(CopilotChatModel::default()))
+    fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
+        let models = CopilotChat::global(cx).and_then(|m| m.read(cx).models())?;
+        models
+            .first()
+            .map(|model| self.create_language_model(model.clone()))
     }
 
-    fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
-        Some(self.create_language_model(CopilotChatModel::default_fast()))
+    fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
+        // The default model should be Copilot Chat's 'base model', which is likely a relatively fast
+        // model (e.g. 4o) and a sensible choice when considering premium requests
+        self.default_model(cx)
     }
 
-    fn provided_models(&self, _cx: &App) -> Vec<Arc<dyn LanguageModel>> {
-        CopilotChatModel::iter()
-            .map(|model| self.create_language_model(model))
+    fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
+        let Some(models) = CopilotChat::global(cx).and_then(|m| m.read(cx).models()) else {
+            return Vec::new();
+        };
+        models
+            .iter()
+            .map(|model| self.create_language_model(model.clone()))
             .collect()
     }
 
@@ -187,13 +209,19 @@ impl LanguageModel for CopilotChatLanguageModel {
     }
 
     fn supports_tools(&self) -> bool {
-        match self.model {
-            CopilotChatModel::Gpt4o
-            | CopilotChatModel::Gpt4_1
-            | CopilotChatModel::O4Mini
-            | CopilotChatModel::Claude3_5Sonnet
-            | CopilotChatModel::Claude3_7Sonnet => true,
-            _ => false,
+        self.model.supports_tools()
+    }
+
+    fn supports_images(&self) -> bool {
+        self.model.supports_vision()
+    }
+
+    fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
+        match self.model.vendor() {
+            ModelVendor::OpenAI | ModelVendor::Anthropic => {
+                LanguageModelToolSchemaFormat::JsonSchema
+            }
+            ModelVendor::Google => LanguageModelToolSchemaFormat::JsonSchemaSubset,
         }
     }
 
@@ -209,7 +237,7 @@ impl LanguageModel for CopilotChatLanguageModel {
         format!("copilot_chat/{}", self.model.id())
     }
 
-    fn max_token_count(&self) -> usize {
+    fn max_token_count(&self) -> u64 {
         self.model.max_token_count()
     }
 
@@ -217,26 +245,14 @@ impl LanguageModel for CopilotChatLanguageModel {
         &self,
         request: LanguageModelRequest,
         cx: &App,
-    ) -> BoxFuture<'static, Result<usize>> {
-        match self.model {
-            CopilotChatModel::Claude3_5Sonnet
-            | CopilotChatModel::Claude3_7Sonnet
-            | CopilotChatModel::Claude3_7SonnetThinking => count_anthropic_tokens(request, cx),
-            CopilotChatModel::Gemini20Flash | CopilotChatModel::Gemini25Pro => {
-                count_google_tokens(request, cx)
-            }
-            CopilotChatModel::Gpt4o => count_open_ai_tokens(request, open_ai::Model::FourOmni, cx),
-            CopilotChatModel::Gpt4 => count_open_ai_tokens(request, open_ai::Model::Four, cx),
-            CopilotChatModel::Gpt4_1 => {
-                count_open_ai_tokens(request, open_ai::Model::FourPointOne, cx)
+    ) -> BoxFuture<'static, Result<u64>> {
+        match self.model.vendor() {
+            ModelVendor::Anthropic => count_anthropic_tokens(request, cx),
+            ModelVendor::Google => count_google_tokens(request, cx),
+            ModelVendor::OpenAI => {
+                let model = open_ai::Model::from_id(self.model.id()).unwrap_or_default();
+                count_open_ai_tokens(request, model, cx)
             }
-            CopilotChatModel::Gpt3_5Turbo => {
-                count_open_ai_tokens(request, open_ai::Model::ThreePointFiveTurbo, cx)
-            }
-            CopilotChatModel::O1 => count_open_ai_tokens(request, open_ai::Model::O1, cx),
-            CopilotChatModel::O3Mini => count_open_ai_tokens(request, open_ai::Model::O3Mini, cx),
-            CopilotChatModel::O3 => count_open_ai_tokens(request, open_ai::Model::O3, cx),
-            CopilotChatModel::O4Mini => count_open_ai_tokens(request, open_ai::Model::O4Mini, cx),
         }
     }
 
@@ -248,27 +264,12 @@ impl LanguageModel for CopilotChatLanguageModel {
         'static,
         Result<
             BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
+            LanguageModelCompletionError,
         >,
     > {
-        if let Some(message) = request.messages.last() {
-            if message.contents_empty() {
-                const EMPTY_PROMPT_MSG: &str =
-                    "Empty prompts aren't allowed. Please provide a non-empty prompt.";
-                return futures::future::ready(Err(anyhow::anyhow!(EMPTY_PROMPT_MSG))).boxed();
-            }
-
-            // Copilot Chat has a restriction that the final message must be from the user.
-            // While their API does return an error message for this, we can catch it earlier
-            // and provide a more helpful error message.
-            if !matches!(message.role, Role::User) {
-                const USER_ROLE_MSG: &str = "The final message must be from the user. To provide a system prompt, you must provide the system prompt followed by a user prompt.";
-                return futures::future::ready(Err(anyhow::anyhow!(USER_ROLE_MSG))).boxed();
-            }
-        }
-
-        let copilot_request = match self.to_copilot_chat_request(request) {
+        let copilot_request = match into_copilot_chat(&self.model, request) {
             Ok(request) => request,
-            Err(err) => return futures::future::ready(Err(err)).boxed(),
+            Err(err) => return futures::future::ready(Err(err.into())).boxed(),
         };
         let is_streaming = copilot_request.stream;
 
@@ -360,6 +361,17 @@ pub fn map_to_language_model_completion_events(
                             }
                         }
 
+                        if let Some(usage) = event.usage {
+                            events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(
+                                TokenUsage {
+                                    input_tokens: usage.prompt_tokens,
+                                    output_tokens: usage.completion_tokens,
+                                    cache_creation_input_tokens: 0,
+                                    cache_read_input_tokens: 0,
+                                },
+                            )));
+                        }
+
                         match choice.finish_reason.as_deref() {
                             Some("stop") => {
                                 events.push(Ok(LanguageModelCompletionEvent::Stop(
@@ -425,138 +437,180 @@ pub fn map_to_language_model_completion_events(
     .flat_map(futures::stream::iter)
 }
 
-impl CopilotChatLanguageModel {
-    pub fn to_copilot_chat_request(
-        &self,
-        request: LanguageModelRequest,
-    ) -> Result<CopilotChatRequest> {
-        let model = self.model.clone();
-
-        let mut request_messages: Vec<LanguageModelRequestMessage> = Vec::new();
-        for message in request.messages {
-            if let Some(last_message) = request_messages.last_mut() {
-                if last_message.role == message.role {
-                    last_message.content.extend(message.content);
-                } else {
-                    request_messages.push(message);
-                }
+fn into_copilot_chat(
+    model: &copilot::copilot_chat::Model,
+    request: LanguageModelRequest,
+) -> Result<CopilotChatRequest> {
+    let mut request_messages: Vec<LanguageModelRequestMessage> = Vec::new();
+    for message in request.messages {
+        if let Some(last_message) = request_messages.last_mut() {
+            if last_message.role == message.role {
+                last_message.content.extend(message.content);
             } else {
                 request_messages.push(message);
             }
+        } else {
+            request_messages.push(message);
         }
+    }
+
+    let mut tool_called = false;
+    let mut messages: Vec<ChatMessage> = Vec::new();
+    for message in request_messages {
+        match message.role {
+            Role::User => {
+                for content in &message.content {
+                    if let MessageContent::ToolResult(tool_result) = content {
+                        let content = match &tool_result.content {
+                            LanguageModelToolResultContent::Text(text) => text.to_string().into(),
+                            LanguageModelToolResultContent::Image(image) => {
+                                if model.supports_vision() {
+                                    ChatMessageContent::Multipart(vec![ChatMessagePart::Image {
+                                        image_url: ImageUrl {
+                                            url: image.to_base64_url(),
+                                        },
+                                    }])
+                                } else {
+                                    debug_panic!(
+                                        "This should be caught at {} level",
+                                        tool_result.tool_name
+                                    );
+                                    "[Tool responded with an image, but this model does not support vision]".to_string().into()
+                                }
+                            }
+                        };
 
-        let mut tool_called = false;
-        let mut messages: Vec<ChatMessage> = Vec::new();
-        for message in request_messages {
-            let text_content = {
-                let mut buffer = String::new();
-                for string in message.content.iter().filter_map(|content| match content {
-                    MessageContent::Text(text) | MessageContent::Thinking { text, .. } => {
-                        Some(text.as_str())
+                        messages.push(ChatMessage::Tool {
+                            tool_call_id: tool_result.tool_use_id.to_string(),
+                            content,
+                        });
                     }
-                    MessageContent::ToolUse(_)
-                    | MessageContent::RedactedThinking(_)
-                    | MessageContent::ToolResult(_)
-                    | MessageContent::Image(_) => None,
-                }) {
-                    buffer.push_str(string);
                 }
 
-                buffer
-            };
-
-            match message.role {
-                Role::User => {
-                    for content in &message.content {
-                        if let MessageContent::ToolResult(tool_result) = content {
-                            messages.push(ChatMessage::Tool {
-                                tool_call_id: tool_result.tool_use_id.to_string(),
-                                content: tool_result.content.to_string(),
+                let mut content_parts = Vec::new();
+                for content in &message.content {
+                    match content {
+                        MessageContent::Text(text) | MessageContent::Thinking { text, .. }
+                            if !text.is_empty() =>
+                        {
+                            if let Some(ChatMessagePart::Text { text: text_content }) =
+                                content_parts.last_mut()
+                            {
+                                text_content.push_str(text);
+                            } else {
+                                content_parts.push(ChatMessagePart::Text {
+                                    text: text.to_string(),
+                                });
+                            }
+                        }
+                        MessageContent::Image(image) if model.supports_vision() => {
+                            content_parts.push(ChatMessagePart::Image {
+                                image_url: ImageUrl {
+                                    url: image.to_base64_url(),
+                                },
                             });
                         }
+                        _ => {}
                     }
+                }
 
-                    if !text_content.is_empty() {
-                        messages.push(ChatMessage::User {
-                            content: text_content,
+                if !content_parts.is_empty() {
+                    messages.push(ChatMessage::User {
+                        content: content_parts.into(),
+                    });
+                }
+            }
+            Role::Assistant => {
+                let mut tool_calls = Vec::new();
+                for content in &message.content {
+                    if let MessageContent::ToolUse(tool_use) = content {
+                        tool_called = true;
+                        tool_calls.push(ToolCall {
+                            id: tool_use.id.to_string(),
+                            content: copilot::copilot_chat::ToolCallContent::Function {
+                                function: copilot::copilot_chat::FunctionContent {
+                                    name: tool_use.name.to_string(),
+                                    arguments: serde_json::to_string(&tool_use.input)?,
+                                },
+                            },
                         });
                     }
                 }
-                Role::Assistant => {
-                    let mut tool_calls = Vec::new();
-                    for content in &message.content {
-                        if let MessageContent::ToolUse(tool_use) = content {
-                            tool_called = true;
-                            tool_calls.push(ToolCall {
-                                id: tool_use.id.to_string(),
-                                content: copilot::copilot_chat::ToolCallContent::Function {
-                                    function: copilot::copilot_chat::FunctionContent {
-                                        name: tool_use.name.to_string(),
-                                        arguments: serde_json::to_string(&tool_use.input)?,
-                                    },
-                                },
-                            });
+
+                let text_content = {
+                    let mut buffer = String::new();
+                    for string in message.content.iter().filter_map(|content| match content {
+                        MessageContent::Text(text) | MessageContent::Thinking { text, .. } => {
+                            Some(text.as_str())
                         }
+                        MessageContent::ToolUse(_)
+                        | MessageContent::RedactedThinking(_)
+                        | MessageContent::ToolResult(_)
+                        | MessageContent::Image(_) => None,
+                    }) {
+                        buffer.push_str(string);
                     }
 
-                    messages.push(ChatMessage::Assistant {
-                        content: if text_content.is_empty() {
-                            None
-                        } else {
-                            Some(text_content)
-                        },
-                        tool_calls,
-                    });
-                }
-                Role::System => messages.push(ChatMessage::System {
-                    content: message.string_contents(),
-                }),
+                    buffer
+                };
+
+                messages.push(ChatMessage::Assistant {
+                    content: if text_content.is_empty() {
+                        ChatMessageContent::empty()
+                    } else {
+                        text_content.into()
+                    },
+                    tool_calls,
+                });
             }
+            Role::System => messages.push(ChatMessage::System {
+                content: message.string_contents(),
+            }),
         }
+    }
 
-        let mut tools = request
-            .tools
-            .iter()
-            .map(|tool| Tool::Function {
-                function: copilot::copilot_chat::Function {
-                    name: tool.name.clone(),
-                    description: tool.description.clone(),
-                    parameters: tool.input_schema.clone(),
-                },
-            })
-            .collect::<Vec<_>>();
-
-        // The API will return a Bad Request (with no error message) when tools
-        // were used previously in the conversation but no tools are provided as
-        // part of this request. Inserting a dummy tool seems to circumvent this
-        // error.
-        if tool_called && tools.is_empty() {
-            tools.push(Tool::Function {
-                function: copilot::copilot_chat::Function {
-                    name: "noop".to_string(),
-                    description: "No operation".to_string(),
-                    parameters: serde_json::json!({
-                        "type": "object"
-                    }),
-                },
-            });
-        }
-
-        Ok(CopilotChatRequest {
-            intent: true,
-            n: 1,
-            stream: model.uses_streaming(),
-            temperature: 0.1,
-            model,
-            messages,
-            tools,
-            tool_choice: request.tool_choice.map(|choice| match choice {
-                LanguageModelToolChoice::Auto => copilot::copilot_chat::ToolChoice::Auto,
-                LanguageModelToolChoice::Any => copilot::copilot_chat::ToolChoice::Any,
-                LanguageModelToolChoice::None => copilot::copilot_chat::ToolChoice::None,
-            }),
+    let mut tools = request
+        .tools
+        .iter()
+        .map(|tool| Tool::Function {
+            function: copilot::copilot_chat::Function {
+                name: tool.name.clone(),
+                description: tool.description.clone(),
+                parameters: tool.input_schema.clone(),
+            },
         })
+        .collect::<Vec<_>>();
+
+    // The API will return a Bad Request (with no error message) when tools
+    // were used previously in the conversation but no tools are provided as
+    // part of this request. Inserting a dummy tool seems to circumvent this
+    // error.
+    if tool_called && tools.is_empty() {
+        tools.push(Tool::Function {
+            function: copilot::copilot_chat::Function {
+                name: "noop".to_string(),
+                description: "No operation".to_string(),
+                parameters: serde_json::json!({
+                    "type": "object"
+                }),
+            },
+        });
     }
+
+    Ok(CopilotChatRequest {
+        intent: true,
+        n: 1,
+        stream: model.uses_streaming(),
+        temperature: 0.1,
+        model: model.id().to_string(),
+        messages,
+        tools,
+        tool_choice: request.tool_choice.map(|choice| match choice {
+            LanguageModelToolChoice::Auto => copilot::copilot_chat::ToolChoice::Auto,
+            LanguageModelToolChoice::Any => copilot::copilot_chat::ToolChoice::Any,
+            LanguageModelToolChoice::None => copilot::copilot_chat::ToolChoice::None,
+        }),
+    })
 }
 
 struct ConfigurationView {

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

@@ -1,7 +1,8 @@
 use anyhow::{Context as _, Result, anyhow};
-use collections::BTreeMap;
+use collections::{BTreeMap, HashMap};
 use credentials_provider::CredentialsProvider;
 use editor::{Editor, EditorElement, EditorStyle};
+use futures::Stream;
 use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
 use gpui::{
     AnyView, AppContext as _, AsyncApp, Entity, FontStyle, Subscription, Task, TextStyle,
@@ -12,11 +13,14 @@ use language_model::{
     AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
     LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
     LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, RateLimiter, Role,
+    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
+    RateLimiter, Role, StopReason, TokenUsage,
 };
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
+use std::pin::Pin;
+use std::str::FromStr;
 use std::sync::Arc;
 use theme::ThemeSettings;
 use ui::{Icon, IconName, List, prelude::*};
@@ -28,6 +32,13 @@ const PROVIDER_ID: &str = "deepseek";
 const PROVIDER_NAME: &str = "DeepSeek";
 const DEEPSEEK_API_KEY_VAR: &str = "DEEPSEEK_API_KEY";
 
+#[derive(Default)]
+struct RawToolCall {
+    id: String,
+    name: String,
+    arguments: String,
+}
+
 #[derive(Default, Clone, Debug, PartialEq)]
 pub struct DeepSeekSettings {
     pub api_url: String,
@@ -38,8 +49,8 @@ pub struct DeepSeekSettings {
 pub struct AvailableModel {
     pub name: String,
     pub display_name: Option<String>,
-    pub max_tokens: usize,
-    pub max_output_tokens: Option<u32>,
+    pub max_tokens: u64,
+    pub max_output_tokens: Option<u64>,
 }
 
 pub struct DeepSeekLanguageModelProvider {
@@ -251,7 +262,7 @@ impl DeepSeekLanguageModel {
         };
 
         let future = self.request_limiter.stream(async move {
-            let api_key = api_key.ok_or_else(|| anyhow!("Missing DeepSeek API Key"))?;
+            let api_key = api_key.context("Missing DeepSeek API Key")?;
             let request =
                 deepseek::stream_completion(http_client.as_ref(), &api_url, &api_key, request);
             let response = request.await?;
@@ -280,10 +291,14 @@ impl LanguageModel for DeepSeekLanguageModel {
     }
 
     fn supports_tools(&self) -> bool {
-        false
+        true
     }
 
     fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool {
+        true
+    }
+
+    fn supports_images(&self) -> bool {
         false
     }
 
@@ -291,11 +306,11 @@ impl LanguageModel for DeepSeekLanguageModel {
         format!("deepseek/{}", self.model.id())
     }
 
-    fn max_token_count(&self) -> usize {
+    fn max_token_count(&self) -> u64 {
         self.model.max_token_count()
     }
 
-    fn max_output_tokens(&self) -> Option<u32> {
+    fn max_output_tokens(&self) -> Option<u64> {
         self.model.max_output_tokens()
     }
 
@@ -303,7 +318,7 @@ impl LanguageModel for DeepSeekLanguageModel {
         &self,
         request: LanguageModelRequest,
         cx: &App,
-    ) -> BoxFuture<'static, Result<usize>> {
+    ) -> BoxFuture<'static, Result<u64>> {
         cx.background_spawn(async move {
             let messages = request
                 .messages
@@ -320,7 +335,7 @@ impl LanguageModel for DeepSeekLanguageModel {
                 })
                 .collect::<Vec<_>>();
 
-            tiktoken_rs::num_tokens_from_messages("gpt-4", &messages)
+            tiktoken_rs::num_tokens_from_messages("gpt-4", &messages).map(|tokens| tokens as u64)
         })
         .boxed()
     }
@@ -333,37 +348,15 @@ impl LanguageModel for DeepSeekLanguageModel {
         'static,
         Result<
             BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
+            LanguageModelCompletionError,
         >,
     > {
-        let request = into_deepseek(
-            request,
-            self.model.id().to_string(),
-            self.max_output_tokens(),
-        );
+        let request = into_deepseek(request, &self.model, self.max_output_tokens());
         let stream = self.stream_completion(request, cx);
 
         async move {
-            let stream = stream.await?;
-            Ok(stream
-                .map(|result| {
-                    result
-                        .and_then(|response| {
-                            response
-                                .choices
-                                .first()
-                                .ok_or_else(|| anyhow!("Empty response"))
-                                .map(|choice| {
-                                    choice
-                                        .delta
-                                        .content
-                                        .clone()
-                                        .unwrap_or_default()
-                                        .map(LanguageModelCompletionEvent::Text)
-                                })
-                        })
-                        .map_err(LanguageModelCompletionError::Other)
-                })
-                .boxed())
+            let mapper = DeepSeekEventMapper::new();
+            Ok(mapper.map_stream(stream.await?).boxed())
         }
         .boxed()
     }
@@ -371,69 +364,67 @@ impl LanguageModel for DeepSeekLanguageModel {
 
 pub fn into_deepseek(
     request: LanguageModelRequest,
-    model: String,
-    max_output_tokens: Option<u32>,
+    model: &deepseek::Model,
+    max_output_tokens: Option<u64>,
 ) -> deepseek::Request {
-    let is_reasoner = model == "deepseek-reasoner";
-
-    let len = request.messages.len();
-    let merged_messages =
-        request
-            .messages
-            .into_iter()
-            .fold(Vec::with_capacity(len), |mut acc, msg| {
-                let role = msg.role;
-                let content = msg.string_contents();
-
-                if is_reasoner {
-                    if let Some(last_msg) = acc.last_mut() {
-                        match (last_msg, role) {
-                            (deepseek::RequestMessage::User { content: last }, Role::User) => {
-                                last.push(' ');
-                                last.push_str(&content);
-                                return acc;
-                            }
-
-                            (
-                                deepseek::RequestMessage::Assistant {
-                                    content: last_content,
-                                    ..
-                                },
-                                Role::Assistant,
-                            ) => {
-                                *last_content = last_content
-                                    .take()
-                                    .map(|c| {
-                                        let mut s =
-                                            String::with_capacity(c.len() + content.len() + 1);
-                                        s.push_str(&c);
-                                        s.push(' ');
-                                        s.push_str(&content);
-                                        s
-                                    })
-                                    .or(Some(content));
-
-                                return acc;
-                            }
-                            _ => {}
-                        }
-                    }
-                }
-
-                acc.push(match role {
-                    Role::User => deepseek::RequestMessage::User { content },
+    let is_reasoner = *model == deepseek::Model::Reasoner;
+
+    let mut messages = Vec::new();
+    for message in request.messages {
+        for content in message.content {
+            match content {
+                MessageContent::Text(text) => messages.push(match message.role {
+                    Role::User => deepseek::RequestMessage::User { content: text },
                     Role::Assistant => deepseek::RequestMessage::Assistant {
-                        content: Some(content),
+                        content: Some(text),
                         tool_calls: Vec::new(),
                     },
-                    Role::System => deepseek::RequestMessage::System { content },
-                });
-                acc
-            });
+                    Role::System => deepseek::RequestMessage::System { content: text },
+                }),
+                MessageContent::Thinking { .. } => {}
+                MessageContent::RedactedThinking(_) => {}
+                MessageContent::Image(_) => {}
+                MessageContent::ToolUse(tool_use) => {
+                    let tool_call = deepseek::ToolCall {
+                        id: tool_use.id.to_string(),
+                        content: deepseek::ToolCallContent::Function {
+                            function: deepseek::FunctionContent {
+                                name: tool_use.name.to_string(),
+                                arguments: serde_json::to_string(&tool_use.input)
+                                    .unwrap_or_default(),
+                            },
+                        },
+                    };
+
+                    if let Some(deepseek::RequestMessage::Assistant { tool_calls, .. }) =
+                        messages.last_mut()
+                    {
+                        tool_calls.push(tool_call);
+                    } else {
+                        messages.push(deepseek::RequestMessage::Assistant {
+                            content: None,
+                            tool_calls: vec![tool_call],
+                        });
+                    }
+                }
+                MessageContent::ToolResult(tool_result) => {
+                    match &tool_result.content {
+                        LanguageModelToolResultContent::Text(text) => {
+                            messages.push(deepseek::RequestMessage::Tool {
+                                content: text.to_string(),
+                                tool_call_id: tool_result.tool_use_id.to_string(),
+                            });
+                        }
+                        LanguageModelToolResultContent::Image(_) => {}
+                    };
+                }
+            }
+        }
+    }
 
     deepseek::Request {
-        model,
-        messages: merged_messages,
+        model: model.id().to_string(),
+        messages,
         stream: true,
         max_tokens: max_output_tokens,
         temperature: if is_reasoner {
@@ -456,6 +447,119 @@ pub fn into_deepseek(
     }
 }
 
+pub struct DeepSeekEventMapper {
+    tool_calls_by_index: HashMap<usize, RawToolCall>,
+}
+
+impl DeepSeekEventMapper {
+    pub fn new() -> Self {
+        Self {
+            tool_calls_by_index: HashMap::default(),
+        }
+    }
+
+    pub fn map_stream(
+        mut self,
+        events: Pin<Box<dyn Send + Stream<Item = Result<deepseek::StreamResponse>>>>,
+    ) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
+    {
+        events.flat_map(move |event| {
+            futures::stream::iter(match event {
+                Ok(event) => self.map_event(event),
+                Err(error) => vec![Err(LanguageModelCompletionError::Other(anyhow!(error)))],
+            })
+        })
+    }
+
+    pub fn map_event(
+        &mut self,
+        event: deepseek::StreamResponse,
+    ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
+        let Some(choice) = event.choices.first() else {
+            return vec![Err(LanguageModelCompletionError::Other(anyhow!(
+                "Response contained no choices"
+            )))];
+        };
+
+        let mut events = Vec::new();
+        if let Some(content) = choice.delta.content.clone() {
+            events.push(Ok(LanguageModelCompletionEvent::Text(content)));
+        }
+
+        if let Some(reasoning_content) = choice.delta.reasoning_content.clone() {
+            events.push(Ok(LanguageModelCompletionEvent::Thinking {
+                text: reasoning_content,
+                signature: None,
+            }));
+        }
+
+        if let Some(tool_calls) = choice.delta.tool_calls.as_ref() {
+            for tool_call in tool_calls {
+                let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
+
+                if let Some(tool_id) = tool_call.id.clone() {
+                    entry.id = tool_id;
+                }
+
+                if let Some(function) = tool_call.function.as_ref() {
+                    if let Some(name) = function.name.clone() {
+                        entry.name = name;
+                    }
+
+                    if let Some(arguments) = function.arguments.clone() {
+                        entry.arguments.push_str(&arguments);
+                    }
+                }
+            }
+        }
+
+        if let Some(usage) = event.usage {
+            events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
+                input_tokens: usage.prompt_tokens,
+                output_tokens: usage.completion_tokens,
+                cache_creation_input_tokens: 0,
+                cache_read_input_tokens: 0,
+            })));
+        }
+
+        match choice.finish_reason.as_deref() {
+            Some("stop") => {
+                events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
+            }
+            Some("tool_calls") => {
+                events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
+                    match serde_json::Value::from_str(&tool_call.arguments) {
+                        Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
+                            LanguageModelToolUse {
+                                id: tool_call.id.clone().into(),
+                                name: tool_call.name.as_str().into(),
+                                is_input_complete: true,
+                                input,
+                                raw_input: tool_call.arguments.clone(),
+                            },
+                        )),
+                        Err(error) => Err(LanguageModelCompletionError::BadInputJson {
+                            id: tool_call.id.into(),
+                            tool_name: tool_call.name.as_str().into(),
+                            raw_input: tool_call.arguments.into(),
+                            json_parse_error: error.to_string(),
+                        }),
+                    }
+                }));
+
+                events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
+            }
+            Some(stop_reason) => {
+                log::error!("Unexpected DeepSeek stop_reason: {stop_reason:?}",);
+                events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
+            }
+            None => {}
+        }
+
+        events
+    }
+}
+
 struct ConfigurationView {
     api_key_editor: Entity<Editor>,
     state: Entity<State>,

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

@@ -4,7 +4,8 @@ use credentials_provider::CredentialsProvider;
 use editor::{Editor, EditorElement, EditorStyle};
 use futures::{FutureExt, Stream, StreamExt, future::BoxFuture};
 use google_ai::{
-    FunctionDeclaration, GenerateContentResponse, Part, SystemInstruction, UsageMetadata,
+    FunctionDeclaration, GenerateContentResponse, GoogleModelMode, Part, SystemInstruction,
+    ThinkingConfig, UsageMetadata,
 };
 use gpui::{
     AnyView, App, AsyncApp, Context, Entity, FontStyle, Subscription, Task, TextStyle, WhiteSpace,
@@ -45,11 +46,41 @@ pub struct GoogleSettings {
     pub available_models: Vec<AvailableModel>,
 }
 
+#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(tag = "type", rename_all = "lowercase")]
+pub enum ModelMode {
+    #[default]
+    Default,
+    Thinking {
+        /// The maximum number of tokens to use for reasoning. Must be lower than the model's `max_output_tokens`.
+        budget_tokens: Option<u32>,
+    },
+}
+
+impl From<ModelMode> for GoogleModelMode {
+    fn from(value: ModelMode) -> Self {
+        match value {
+            ModelMode::Default => GoogleModelMode::Default,
+            ModelMode::Thinking { budget_tokens } => GoogleModelMode::Thinking { budget_tokens },
+        }
+    }
+}
+
+impl From<GoogleModelMode> for ModelMode {
+    fn from(value: GoogleModelMode) -> Self {
+        match value {
+            GoogleModelMode::Default => ModelMode::Default,
+            GoogleModelMode::Thinking { budget_tokens } => ModelMode::Thinking { budget_tokens },
+        }
+    }
+}
+
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub struct AvailableModel {
     name: String,
     display_name: Option<String>,
-    max_tokens: usize,
+    max_tokens: u64,
+    mode: Option<ModelMode>,
 }
 
 pub struct GoogleLanguageModelProvider {
@@ -216,6 +247,7 @@ impl LanguageModelProvider for GoogleLanguageModelProvider {
                     name: model.name.clone(),
                     display_name: model.display_name.clone(),
                     max_tokens: model.max_tokens,
+                    mode: model.mode.unwrap_or_default().into(),
                 },
             );
         }
@@ -279,7 +311,7 @@ impl GoogleLanguageModel {
         };
 
         async move {
-            let api_key = api_key.ok_or_else(|| anyhow!("Missing Google API key"))?;
+            let api_key = api_key.context("Missing Google API key")?;
             let request = google_ai::stream_generate_content(
                 http_client.as_ref(),
                 &api_url,
@@ -310,7 +342,11 @@ impl LanguageModel for GoogleLanguageModel {
     }
 
     fn supports_tools(&self) -> bool {
-        true
+        self.model.supports_tools()
+    }
+
+    fn supports_images(&self) -> bool {
+        self.model.supports_images()
     }
 
     fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
@@ -326,20 +362,24 @@ impl LanguageModel for GoogleLanguageModel {
     }
 
     fn telemetry_id(&self) -> String {
-        format!("google/{}", self.model.id())
+        format!("google/{}", self.model.request_id())
     }
 
-    fn max_token_count(&self) -> usize {
+    fn max_token_count(&self) -> u64 {
         self.model.max_token_count()
     }
 
+    fn max_output_tokens(&self) -> Option<u64> {
+        self.model.max_output_tokens()
+    }
+
     fn count_tokens(
         &self,
         request: LanguageModelRequest,
         cx: &App,
-    ) -> BoxFuture<'static, Result<usize>> {
-        let model_id = self.model.id().to_string();
-        let request = into_google(request, model_id.clone());
+    ) -> BoxFuture<'static, Result<u64>> {
+        let model_id = self.model.request_id().to_string();
+        let request = into_google(request, model_id.clone(), self.model.mode());
         let http_client = self.http_client.clone();
         let api_key = self.state.read(cx).api_key.clone();
 
@@ -347,7 +387,7 @@ impl LanguageModel for GoogleLanguageModel {
         let api_url = settings.api_url.clone();
 
         async move {
-            let api_key = api_key.ok_or_else(|| anyhow!("Missing Google API key"))?;
+            let api_key = api_key.context("Missing Google API key")?;
             let response = google_ai::count_tokens(
                 http_client.as_ref(),
                 &api_url,
@@ -373,9 +413,14 @@ impl LanguageModel for GoogleLanguageModel {
                 'static,
                 Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
             >,
+            LanguageModelCompletionError,
         >,
     > {
-        let request = into_google(request, self.model.id().to_string());
+        let request = into_google(
+            request,
+            self.model.request_id().to_string(),
+            self.model.mode(),
+        );
         let request = self.stream_completion(request, cx);
         let future = self.request_limiter.stream(async move {
             let response = request
@@ -390,47 +435,88 @@ impl LanguageModel for GoogleLanguageModel {
 pub fn into_google(
     mut request: LanguageModelRequest,
     model_id: String,
+    mode: GoogleModelMode,
 ) -> google_ai::GenerateContentRequest {
     fn map_content(content: Vec<MessageContent>) -> Vec<Part> {
         content
             .into_iter()
-            .filter_map(|content| match content {
-                language_model::MessageContent::Text(text)
-                | language_model::MessageContent::Thinking { text, .. } => {
+            .flat_map(|content| match content {
+                language_model::MessageContent::Text(text) => {
                     if !text.is_empty() {
-                        Some(Part::TextPart(google_ai::TextPart { text }))
+                        vec![Part::TextPart(google_ai::TextPart { text })]
                     } else {
-                        None
+                        vec![]
                     }
                 }
-                language_model::MessageContent::RedactedThinking(_) => None,
+                language_model::MessageContent::Thinking {
+                    text: _,
+                    signature: Some(signature),
+                } => {
+                    if !signature.is_empty() {
+                        vec![Part::ThoughtPart(google_ai::ThoughtPart {
+                            thought: true,
+                            thought_signature: signature,
+                        })]
+                    } else {
+                        vec![]
+                    }
+                }
+                language_model::MessageContent::Thinking { .. } => {
+                    vec![]
+                }
+                language_model::MessageContent::RedactedThinking(_) => vec![],
                 language_model::MessageContent::Image(image) => {
-                    Some(Part::InlineDataPart(google_ai::InlineDataPart {
+                    vec![Part::InlineDataPart(google_ai::InlineDataPart {
                         inline_data: google_ai::GenerativeContentBlob {
                             mime_type: "image/png".to_string(),
                             data: image.source.to_string(),
                         },
-                    }))
+                    })]
                 }
                 language_model::MessageContent::ToolUse(tool_use) => {
-                    Some(Part::FunctionCallPart(google_ai::FunctionCallPart {
+                    vec![Part::FunctionCallPart(google_ai::FunctionCallPart {
                         function_call: google_ai::FunctionCall {
                             name: tool_use.name.to_string(),
                             args: tool_use.input,
                         },
-                    }))
+                    })]
+                }
+                language_model::MessageContent::ToolResult(tool_result) => {
+                    match tool_result.content {
+                        language_model::LanguageModelToolResultContent::Text(text) => {
+                            vec![Part::FunctionResponsePart(
+                                google_ai::FunctionResponsePart {
+                                    function_response: google_ai::FunctionResponse {
+                                        name: tool_result.tool_name.to_string(),
+                                        // The API expects a valid JSON object
+                                        response: serde_json::json!({
+                                            "output": text
+                                        }),
+                                    },
+                                },
+                            )]
+                        }
+                        language_model::LanguageModelToolResultContent::Image(image) => {
+                            vec![
+                                Part::FunctionResponsePart(google_ai::FunctionResponsePart {
+                                    function_response: google_ai::FunctionResponse {
+                                        name: tool_result.tool_name.to_string(),
+                                        // The API expects a valid JSON object
+                                        response: serde_json::json!({
+                                            "output": "Tool responded with an image"
+                                        }),
+                                    },
+                                }),
+                                Part::InlineDataPart(google_ai::InlineDataPart {
+                                    inline_data: google_ai::GenerativeContentBlob {
+                                        mime_type: "image/png".to_string(),
+                                        data: image.source.to_string(),
+                                    },
+                                }),
+                            ]
+                        }
+                    }
                 }
-                language_model::MessageContent::ToolResult(tool_result) => Some(
-                    Part::FunctionResponsePart(google_ai::FunctionResponsePart {
-                        function_response: google_ai::FunctionResponse {
-                            name: tool_result.tool_name.to_string(),
-                            // The API expects a valid JSON object
-                            response: serde_json::json!({
-                                "output": tool_result.content
-                            }),
-                        },
-                    }),
-                ),
             })
             .collect()
     }
@@ -475,6 +561,12 @@ pub fn into_google(
             stop_sequences: Some(request.stop),
             max_output_tokens: None,
             temperature: request.temperature.map(|t| t as f64).or(Some(1.0)),
+            thinking_config: match mode {
+                GoogleModelMode::Thinking { budget_tokens } => {
+                    budget_tokens.map(|thinking_budget| ThinkingConfig { thinking_budget })
+                }
+                GoogleModelMode::Default => None,
+            },
             top_p: None,
             top_k: None,
         }),
@@ -591,6 +683,12 @@ impl GoogleEventMapper {
                             )));
                         }
                         Part::FunctionResponsePart(_) => {}
+                        Part::ThoughtPart(part) => {
+                            events.push(Ok(LanguageModelCompletionEvent::Thinking {
+                                text: "(Encrypted thought)".to_string(), // TODO: Can we populate this from thought summaries?
+                                signature: Some(part.thought_signature),
+                            }));
+                        }
                     });
             }
         }
@@ -599,6 +697,7 @@ impl GoogleEventMapper {
         // responds with `finish_reason: STOP`
         if wants_to_use_tool {
             self.stop_reason = StopReason::ToolUse;
+            events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
         }
         events
     }
@@ -607,7 +706,7 @@ impl GoogleEventMapper {
 pub fn count_google_tokens(
     request: LanguageModelRequest,
     cx: &App,
-) -> BoxFuture<'static, Result<usize>> {
+) -> BoxFuture<'static, Result<u64>> {
     // We couldn't use the GoogleLanguageModelProvider to count tokens because the github copilot doesn't have the access to google_ai directly.
     // So we have to use tokenizer from tiktoken_rs to count tokens.
     cx.background_spawn(async move {
@@ -628,7 +727,7 @@ pub fn count_google_tokens(
 
         // Tiktoken doesn't yet support these models, so we manually use the
         // same tokenizer as GPT-4.
-        tiktoken_rs::num_tokens_from_messages("gpt-4", &messages)
+        tiktoken_rs::num_tokens_from_messages("gpt-4", &messages).map(|tokens| tokens as u64)
     })
     .boxed()
 }
@@ -655,10 +754,15 @@ fn update_usage(usage: &mut UsageMetadata, new: &UsageMetadata) {
 }
 
 fn convert_usage(usage: &UsageMetadata) -> language_model::TokenUsage {
+    let prompt_tokens = usage.prompt_token_count.unwrap_or(0);
+    let cached_tokens = usage.cached_content_token_count.unwrap_or(0);
+    let input_tokens = prompt_tokens - cached_tokens;
+    let output_tokens = usage.candidates_token_count.unwrap_or(0);
+
     language_model::TokenUsage {
-        input_tokens: usage.prompt_token_count.unwrap_or(0) as u32,
-        output_tokens: usage.candidates_token_count.unwrap_or(0) as u32,
-        cache_read_input_tokens: usage.cached_content_token_count.unwrap_or(0) as u32,
+        input_tokens,
+        output_tokens,
+        cache_read_input_tokens: cached_tokens,
         cache_creation_input_tokens: 0,
     }
 }

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

@@ -1,23 +1,25 @@
 use anyhow::{Result, anyhow};
+use collections::HashMap;
+use futures::Stream;
 use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
 use gpui::{AnyView, App, AsyncApp, Context, Subscription, Task};
 use http_client::HttpClient;
 use language_model::{
     AuthenticateError, LanguageModelCompletionError, LanguageModelCompletionEvent,
-    LanguageModelToolChoice,
+    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
+    StopReason, TokenUsage,
 };
 use language_model::{
     LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider,
     LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
     LanguageModelRequest, RateLimiter, Role,
 };
-use lmstudio::{
-    ChatCompletionRequest, ChatMessage, ModelType, get_models, preload_model,
-    stream_chat_completion,
-};
+use lmstudio::{ModelType, get_models};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
+use std::pin::Pin;
+use std::str::FromStr;
 use std::{collections::BTreeMap, sync::Arc};
 use ui::{ButtonLike, Indicator, List, prelude::*};
 use util::ResultExt;
@@ -40,12 +42,11 @@ pub struct LmStudioSettings {
 
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub struct AvailableModel {
-    /// The model name in the LM Studio API. e.g. qwen2.5-coder-7b, phi-4, etc
     pub name: String,
-    /// The model's name in Zed's UI, such as in the model selector dropdown menu in the assistant panel.
     pub display_name: Option<String>,
-    /// The model's context window size.
-    pub max_tokens: usize,
+    pub max_tokens: u64,
+    pub supports_tool_calls: bool,
+    pub supports_images: bool,
 }
 
 pub struct LmStudioLanguageModelProvider {
@@ -77,7 +78,17 @@ impl State {
             let mut models: Vec<lmstudio::Model> = models
                 .into_iter()
                 .filter(|model| model.r#type != ModelType::Embeddings)
-                .map(|model| lmstudio::Model::new(&model.id, None, None))
+                .map(|model| {
+                    lmstudio::Model::new(
+                        &model.id,
+                        None,
+                        model
+                            .loaded_context_length
+                            .or_else(|| model.max_context_length),
+                        model.capabilities.supports_tool_calls(),
+                        model.capabilities.supports_images() || model.r#type == ModelType::Vlm,
+                    )
+                })
                 .collect();
 
             models.sort_by(|a, b| a.name.cmp(&b.name));
@@ -156,12 +167,16 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider {
         IconName::AiLmStudio
     }
 
-    fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
-        self.provided_models(cx).into_iter().next()
+    fn default_model(&self, _: &App) -> Option<Arc<dyn LanguageModel>> {
+        // We shouldn't try to select default model, because it might lead to a load call for an unloaded model.
+        // In a constrained environment where user might not have enough resources it'll be a bad UX to select something
+        // to load by default.
+        None
     }
 
-    fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
-        self.default_model(cx)
+    fn default_fast_model(&self, _: &App) -> Option<Arc<dyn LanguageModel>> {
+        // See explanation for default_model.
+        None
     }
 
     fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
@@ -184,6 +199,8 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider {
                     name: model.name.clone(),
                     display_name: model.display_name.clone(),
                     max_tokens: model.max_tokens,
+                    supports_tool_calls: model.supports_tool_calls,
+                    supports_images: model.supports_images,
                 },
             );
         }
@@ -201,15 +218,6 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider {
             .collect()
     }
 
-    fn load_model(&self, model: Arc<dyn LanguageModel>, cx: &App) {
-        let settings = &AllLanguageModelSettings::get_global(cx).lmstudio;
-        let http_client = self.http_client.clone();
-        let api_url = settings.api_url.clone();
-        let id = model.id().0.to_string();
-        cx.spawn(async move |_| preload_model(http_client, &api_url, &id).await)
-            .detach_and_log_err(cx);
-    }
-
     fn is_authenticated(&self, cx: &App) -> bool {
         self.state.read(cx).is_authenticated()
     }
@@ -236,32 +244,136 @@ pub struct LmStudioLanguageModel {
 }
 
 impl LmStudioLanguageModel {
-    fn to_lmstudio_request(&self, request: LanguageModelRequest) -> ChatCompletionRequest {
-        ChatCompletionRequest {
+    fn to_lmstudio_request(
+        &self,
+        request: LanguageModelRequest,
+    ) -> lmstudio::ChatCompletionRequest {
+        let mut messages = Vec::new();
+
+        for message in request.messages {
+            for content in message.content {
+                match content {
+                    MessageContent::Text(text) => add_message_content_part(
+                        lmstudio::MessagePart::Text { text },
+                        message.role,
+                        &mut messages,
+                    ),
+                    MessageContent::Thinking { .. } => {}
+                    MessageContent::RedactedThinking(_) => {}
+                    MessageContent::Image(image) => {
+                        add_message_content_part(
+                            lmstudio::MessagePart::Image {
+                                image_url: lmstudio::ImageUrl {
+                                    url: image.to_base64_url(),
+                                    detail: None,
+                                },
+                            },
+                            message.role,
+                            &mut messages,
+                        );
+                    }
+                    MessageContent::ToolUse(tool_use) => {
+                        let tool_call = lmstudio::ToolCall {
+                            id: tool_use.id.to_string(),
+                            content: lmstudio::ToolCallContent::Function {
+                                function: lmstudio::FunctionContent {
+                                    name: tool_use.name.to_string(),
+                                    arguments: serde_json::to_string(&tool_use.input)
+                                        .unwrap_or_default(),
+                                },
+                            },
+                        };
+
+                        if let Some(lmstudio::ChatMessage::Assistant { tool_calls, .. }) =
+                            messages.last_mut()
+                        {
+                            tool_calls.push(tool_call);
+                        } else {
+                            messages.push(lmstudio::ChatMessage::Assistant {
+                                content: None,
+                                tool_calls: vec![tool_call],
+                            });
+                        }
+                    }
+                    MessageContent::ToolResult(tool_result) => {
+                        let content = match &tool_result.content {
+                            LanguageModelToolResultContent::Text(text) => {
+                                vec![lmstudio::MessagePart::Text {
+                                    text: text.to_string(),
+                                }]
+                            }
+                            LanguageModelToolResultContent::Image(image) => {
+                                vec![lmstudio::MessagePart::Image {
+                                    image_url: lmstudio::ImageUrl {
+                                        url: image.to_base64_url(),
+                                        detail: None,
+                                    },
+                                }]
+                            }
+                        };
+
+                        messages.push(lmstudio::ChatMessage::Tool {
+                            content: content.into(),
+                            tool_call_id: tool_result.tool_use_id.to_string(),
+                        });
+                    }
+                }
+            }
+        }
+
+        lmstudio::ChatCompletionRequest {
             model: self.model.name.clone(),
-            messages: request
-                .messages
+            messages,
+            stream: true,
+            max_tokens: Some(-1),
+            stop: Some(request.stop),
+            // In LM Studio you can configure specific settings you'd like to use for your model.
+            // For example Qwen3 is recommended to be used with 0.7 temperature.
+            // It would be a bad UX to silently override these settings from Zed, so we pass no temperature as a default.
+            temperature: request.temperature.or(None),
+            tools: request
+                .tools
                 .into_iter()
-                .map(|msg| match msg.role {
-                    Role::User => ChatMessage::User {
-                        content: msg.string_contents(),
-                    },
-                    Role::Assistant => ChatMessage::Assistant {
-                        content: Some(msg.string_contents()),
-                        tool_calls: None,
-                    },
-                    Role::System => ChatMessage::System {
-                        content: msg.string_contents(),
+                .map(|tool| lmstudio::ToolDefinition::Function {
+                    function: lmstudio::FunctionDefinition {
+                        name: tool.name,
+                        description: Some(tool.description),
+                        parameters: Some(tool.input_schema),
                     },
                 })
                 .collect(),
-            stream: true,
-            max_tokens: Some(-1),
-            stop: Some(request.stop),
-            temperature: request.temperature.or(Some(0.0)),
-            tools: vec![],
+            tool_choice: request.tool_choice.map(|choice| match choice {
+                LanguageModelToolChoice::Auto => lmstudio::ToolChoice::Auto,
+                LanguageModelToolChoice::Any => lmstudio::ToolChoice::Required,
+                LanguageModelToolChoice::None => lmstudio::ToolChoice::None,
+            }),
         }
     }
+
+    fn stream_completion(
+        &self,
+        request: lmstudio::ChatCompletionRequest,
+        cx: &AsyncApp,
+    ) -> BoxFuture<
+        'static,
+        Result<futures::stream::BoxStream<'static, Result<lmstudio::ResponseStreamEvent>>>,
+    > {
+        let http_client = self.http_client.clone();
+        let Ok(api_url) = cx.update(|cx| {
+            let settings = &AllLanguageModelSettings::get_global(cx).lmstudio;
+            settings.api_url.clone()
+        }) else {
+            return futures::future::ready(Err(anyhow!("App state dropped"))).boxed();
+        };
+
+        let future = self.request_limiter.stream(async move {
+            let request = lmstudio::stream_chat_completion(http_client.as_ref(), &api_url, request);
+            let response = request.await?;
+            Ok(response)
+        });
+
+        async move { Ok(future.await?.boxed()) }.boxed()
+    }
 }
 
 impl LanguageModel for LmStudioLanguageModel {
@@ -282,18 +394,27 @@ impl LanguageModel for LmStudioLanguageModel {
     }
 
     fn supports_tools(&self) -> bool {
-        false
+        self.model.supports_tool_calls()
+    }
+
+    fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
+        self.supports_tools()
+            && match choice {
+                LanguageModelToolChoice::Auto => true,
+                LanguageModelToolChoice::Any => true,
+                LanguageModelToolChoice::None => true,
+            }
     }
 
-    fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool {
-        false
+    fn supports_images(&self) -> bool {
+        self.model.supports_images
     }
 
     fn telemetry_id(&self) -> String {
         format!("lmstudio/{}", self.model.id())
     }
 
-    fn max_token_count(&self) -> usize {
+    fn max_token_count(&self) -> u64 {
         self.model.max_token_count()
     }
 
@@ -301,7 +422,7 @@ impl LanguageModel for LmStudioLanguageModel {
         &self,
         request: LanguageModelRequest,
         _cx: &App,
-    ) -> BoxFuture<'static, Result<usize>> {
+    ) -> BoxFuture<'static, Result<u64>> {
         // Endpoint for this is coming soon. In the meantime, hacky estimation
         let token_count = request
             .messages
@@ -309,7 +430,7 @@ impl LanguageModel for LmStudioLanguageModel {
             .map(|msg| msg.string_contents().split_whitespace().count())
             .sum::<usize>();
 
-        let estimated_tokens = (token_count as f64 * 0.75) as usize;
+        let estimated_tokens = (token_count as f64 * 0.75) as u64;
         async move { Ok(estimated_tokens) }.boxed()
     }
 
@@ -321,85 +442,177 @@ impl LanguageModel for LmStudioLanguageModel {
         'static,
         Result<
             BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
+            LanguageModelCompletionError,
         >,
     > {
         let request = self.to_lmstudio_request(request);
-
-        let http_client = self.http_client.clone();
-        let Ok(api_url) = cx.update(|cx| {
-            let settings = &AllLanguageModelSettings::get_global(cx).lmstudio;
-            settings.api_url.clone()
-        }) else {
-            return futures::future::ready(Err(anyhow!("App state dropped"))).boxed();
-        };
-
-        let future = self.request_limiter.stream(async move {
-            let response = stream_chat_completion(http_client.as_ref(), &api_url, request).await?;
-
-            // Create a stream mapper to handle content across multiple deltas
-            let stream_mapper = LmStudioStreamMapper::new();
-
-            let stream = response
-                .map(move |response| {
-                    response.and_then(|fragment| stream_mapper.process_fragment(fragment))
-                })
-                .filter_map(|result| async move {
-                    match result {
-                        Ok(Some(content)) => Some(Ok(content)),
-                        Ok(None) => None,
-                        Err(error) => Some(Err(error)),
-                    }
-                })
-                .boxed();
-
-            Ok(stream)
-        });
-
+        let completions = self.stream_completion(request, cx);
         async move {
-            Ok(future
-                .await?
-                .map(|result| {
-                    result
-                        .map(LanguageModelCompletionEvent::Text)
-                        .map_err(LanguageModelCompletionError::Other)
-                })
-                .boxed())
+            let mapper = LmStudioEventMapper::new();
+            Ok(mapper.map_stream(completions.await?).boxed())
         }
         .boxed()
     }
 }
 
-// This will be more useful when we implement tool calling. Currently keeping it empty.
-struct LmStudioStreamMapper {}
+struct LmStudioEventMapper {
+    tool_calls_by_index: HashMap<usize, RawToolCall>,
+}
 
-impl LmStudioStreamMapper {
+impl LmStudioEventMapper {
     fn new() -> Self {
-        Self {}
+        Self {
+            tool_calls_by_index: HashMap::default(),
+        }
+    }
+
+    pub fn map_stream(
+        mut self,
+        events: Pin<Box<dyn Send + Stream<Item = Result<lmstudio::ResponseStreamEvent>>>>,
+    ) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
+    {
+        events.flat_map(move |event| {
+            futures::stream::iter(match event {
+                Ok(event) => self.map_event(event),
+                Err(error) => vec![Err(LanguageModelCompletionError::Other(anyhow!(error)))],
+            })
+        })
     }
 
-    fn process_fragment(&self, fragment: lmstudio::ChatResponse) -> Result<Option<String>> {
-        // Most of the time, there will be only one choice
-        let Some(choice) = fragment.choices.first() else {
-            return Ok(None);
+    pub fn map_event(
+        &mut self,
+        event: lmstudio::ResponseStreamEvent,
+    ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
+        let Some(choice) = event.choices.into_iter().next() else {
+            return vec![Err(LanguageModelCompletionError::Other(anyhow!(
+                "Response contained no choices"
+            )))];
         };
 
-        // Extract the delta content
-        if let Ok(delta) =
-            serde_json::from_value::<lmstudio::ResponseMessageDelta>(choice.delta.clone())
-        {
-            if let Some(content) = delta.content {
-                if !content.is_empty() {
-                    return Ok(Some(content));
+        let mut events = Vec::new();
+        if let Some(content) = choice.delta.content {
+            events.push(Ok(LanguageModelCompletionEvent::Text(content)));
+        }
+
+        if let Some(reasoning_content) = choice.delta.reasoning_content {
+            events.push(Ok(LanguageModelCompletionEvent::Thinking {
+                text: reasoning_content,
+                signature: None,
+            }));
+        }
+
+        if let Some(tool_calls) = choice.delta.tool_calls {
+            for tool_call in tool_calls {
+                let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
+
+                if let Some(tool_id) = tool_call.id {
+                    entry.id = tool_id;
+                }
+
+                if let Some(function) = tool_call.function {
+                    if let Some(name) = function.name {
+                        // At the time of writing this code LM Studio (0.3.15) is incompatible with the OpenAI API:
+                        // 1. It sends function name in the first chunk
+                        // 2. It sends empty string in the function name field in all subsequent chunks for arguments
+                        // According to https://platform.openai.com/docs/guides/function-calling?api-mode=responses#streaming
+                        // function name field should be sent only inside the first chunk.
+                        if !name.is_empty() {
+                            entry.name = name;
+                        }
+                    }
+
+                    if let Some(arguments) = function.arguments {
+                        entry.arguments.push_str(&arguments);
+                    }
                 }
             }
         }
 
-        // If there's a finish_reason, we're done
-        if choice.finish_reason.is_some() {
-            return Ok(None);
+        if let Some(usage) = event.usage {
+            events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
+                input_tokens: usage.prompt_tokens,
+                output_tokens: usage.completion_tokens,
+                cache_creation_input_tokens: 0,
+                cache_read_input_tokens: 0,
+            })));
         }
 
-        Ok(None)
+        match choice.finish_reason.as_deref() {
+            Some("stop") => {
+                events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
+            }
+            Some("tool_calls") => {
+                events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
+                    match serde_json::Value::from_str(&tool_call.arguments) {
+                        Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
+                            LanguageModelToolUse {
+                                id: tool_call.id.into(),
+                                name: tool_call.name.into(),
+                                is_input_complete: true,
+                                input,
+                                raw_input: tool_call.arguments,
+                            },
+                        )),
+                        Err(error) => Err(LanguageModelCompletionError::BadInputJson {
+                            id: tool_call.id.into(),
+                            tool_name: tool_call.name.into(),
+                            raw_input: tool_call.arguments.into(),
+                            json_parse_error: error.to_string(),
+                        }),
+                    }
+                }));
+
+                events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
+            }
+            Some(stop_reason) => {
+                log::error!("Unexpected LMStudio stop_reason: {stop_reason:?}",);
+                events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
+            }
+            None => {}
+        }
+
+        events
+    }
+}
+
+#[derive(Default)]
+struct RawToolCall {
+    id: String,
+    name: String,
+    arguments: String,
+}
+
+fn add_message_content_part(
+    new_part: lmstudio::MessagePart,
+    role: Role,
+    messages: &mut Vec<lmstudio::ChatMessage>,
+) {
+    match (role, messages.last_mut()) {
+        (Role::User, Some(lmstudio::ChatMessage::User { content }))
+        | (
+            Role::Assistant,
+            Some(lmstudio::ChatMessage::Assistant {
+                content: Some(content),
+                ..
+            }),
+        )
+        | (Role::System, Some(lmstudio::ChatMessage::System { content })) => {
+            content.push_part(new_part);
+        }
+        _ => {
+            messages.push(match role {
+                Role::User => lmstudio::ChatMessage::User {
+                    content: lmstudio::MessageContent::from(vec![new_part]),
+                },
+                Role::Assistant => lmstudio::ChatMessage::Assistant {
+                    content: Some(lmstudio::MessageContent::from(vec![new_part])),
+                    tool_calls: Vec::new(),
+                },
+                Role::System => lmstudio::ChatMessage::System {
+                    content: lmstudio::MessageContent::from(vec![new_part]),
+                },
+            });
+        }
     }
 }
 

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

@@ -2,6 +2,7 @@ use anyhow::{Context as _, Result, anyhow};
 use collections::BTreeMap;
 use credentials_provider::CredentialsProvider;
 use editor::{Editor, EditorElement, EditorStyle};
+use futures::stream::BoxStream;
 use futures::{FutureExt, StreamExt, future::BoxFuture};
 use gpui::{
     AnyView, App, AsyncApp, Context, Entity, FontStyle, Subscription, Task, TextStyle, WhiteSpace,
@@ -11,13 +12,15 @@ use language_model::{
     AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
     LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
     LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, RateLimiter, Role,
+    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
+    RateLimiter, Role, StopReason, TokenUsage,
 };
-
-use futures::stream::BoxStream;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
+use std::collections::HashMap;
+use std::pin::Pin;
+use std::str::FromStr;
 use std::sync::Arc;
 use strum::IntoEnumIterator;
 use theme::ThemeSettings;
@@ -33,16 +36,17 @@ const PROVIDER_NAME: &str = "Mistral";
 pub struct MistralSettings {
     pub api_url: String,
     pub available_models: Vec<AvailableModel>,
-    pub needs_setting_migration: bool,
 }
 
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub struct AvailableModel {
     pub name: String,
     pub display_name: Option<String>,
-    pub max_tokens: usize,
-    pub max_output_tokens: Option<u32>,
-    pub max_completion_tokens: Option<u32>,
+    pub max_tokens: u64,
+    pub max_output_tokens: Option<u64>,
+    pub max_completion_tokens: Option<u64>,
+    pub supports_tools: Option<bool>,
+    pub supports_images: Option<bool>,
 }
 
 pub struct MistralLanguageModelProvider {
@@ -209,6 +213,8 @@ impl LanguageModelProvider for MistralLanguageModelProvider {
                     max_tokens: model.max_tokens,
                     max_output_tokens: model.max_output_tokens,
                     max_completion_tokens: model.max_completion_tokens,
+                    supports_tools: model.supports_tools,
+                    supports_images: model.supports_images,
                 },
             );
         }
@@ -271,7 +277,7 @@ impl MistralLanguageModel {
         };
 
         let future = self.request_limiter.stream(async move {
-            let api_key = api_key.ok_or_else(|| anyhow!("Missing Mistral API Key"))?;
+            let api_key = api_key.context("Missing Mistral API Key")?;
             let request =
                 mistral::stream_completion(http_client.as_ref(), &api_url, &api_key, request);
             let response = request.await?;
@@ -300,22 +306,26 @@ impl LanguageModel for MistralLanguageModel {
     }
 
     fn supports_tools(&self) -> bool {
-        false
+        self.model.supports_tools()
     }
 
     fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool {
-        false
+        self.model.supports_tools()
+    }
+
+    fn supports_images(&self) -> bool {
+        self.model.supports_images()
     }
 
     fn telemetry_id(&self) -> String {
         format!("mistral/{}", self.model.id())
     }
 
-    fn max_token_count(&self) -> usize {
+    fn max_token_count(&self) -> u64 {
         self.model.max_token_count()
     }
 
-    fn max_output_tokens(&self) -> Option<u32> {
+    fn max_output_tokens(&self) -> Option<u64> {
         self.model.max_output_tokens()
     }
 
@@ -323,7 +333,7 @@ impl LanguageModel for MistralLanguageModel {
         &self,
         request: LanguageModelRequest,
         cx: &App,
-    ) -> BoxFuture<'static, Result<usize>> {
+    ) -> BoxFuture<'static, Result<u64>> {
         cx.background_spawn(async move {
             let messages = request
                 .messages
@@ -340,7 +350,7 @@ impl LanguageModel for MistralLanguageModel {
                 })
                 .collect::<Vec<_>>();
 
-            tiktoken_rs::num_tokens_from_messages("gpt-4", &messages)
+            tiktoken_rs::num_tokens_from_messages("gpt-4", &messages).map(|tokens| tokens as u64)
         })
         .boxed()
     }
@@ -353,6 +363,7 @@ impl LanguageModel for MistralLanguageModel {
         'static,
         Result<
             BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
+            LanguageModelCompletionError,
         >,
     > {
         let request = into_mistral(
@@ -364,26 +375,8 @@ impl LanguageModel for MistralLanguageModel {
 
         async move {
             let stream = stream.await?;
-            Ok(stream
-                .map(|result| {
-                    result
-                        .and_then(|response| {
-                            response
-                                .choices
-                                .first()
-                                .ok_or_else(|| anyhow!("Empty response"))
-                                .map(|choice| {
-                                    choice
-                                        .delta
-                                        .content
-                                        .clone()
-                                        .unwrap_or_default()
-                                        .map(LanguageModelCompletionEvent::Text)
-                                })
-                        })
-                        .map_err(LanguageModelCompletionError::Other)
-                })
-                .boxed())
+            let mapper = MistralEventMapper::new();
+            Ok(mapper.map_stream(stream).boxed())
         }
         .boxed()
     }
@@ -392,35 +385,173 @@ impl LanguageModel for MistralLanguageModel {
 pub fn into_mistral(
     request: LanguageModelRequest,
     model: String,
-    max_output_tokens: Option<u32>,
+    max_output_tokens: Option<u64>,
 ) -> mistral::Request {
-    let len = request.messages.len();
-    let merged_messages =
-        request
-            .messages
-            .into_iter()
-            .fold(Vec::with_capacity(len), |mut acc, msg| {
-                let role = msg.role;
-                let content = msg.string_contents();
-
-                acc.push(match role {
-                    Role::User => mistral::RequestMessage::User { content },
-                    Role::Assistant => mistral::RequestMessage::Assistant {
-                        content: Some(content),
-                        tool_calls: Vec::new(),
-                    },
-                    Role::System => mistral::RequestMessage::System { content },
+    let stream = true;
+
+    let mut messages = Vec::new();
+    for message in &request.messages {
+        match message.role {
+            Role::User => {
+                let mut message_content = mistral::MessageContent::empty();
+                for content in &message.content {
+                    match content {
+                        MessageContent::Text(text) => {
+                            message_content
+                                .push_part(mistral::MessagePart::Text { text: text.clone() });
+                        }
+                        MessageContent::Image(image_content) => {
+                            message_content.push_part(mistral::MessagePart::ImageUrl {
+                                image_url: image_content.to_base64_url(),
+                            });
+                        }
+                        MessageContent::Thinking { text, .. } => {
+                            message_content
+                                .push_part(mistral::MessagePart::Text { text: text.clone() });
+                        }
+                        MessageContent::RedactedThinking(_) => {}
+                        MessageContent::ToolUse(_) | MessageContent::ToolResult(_) => {
+                            // Tool content is not supported in User messages for Mistral
+                        }
+                    }
+                }
+                if !matches!(message_content, mistral::MessageContent::Plain { ref content } if content.is_empty())
+                {
+                    messages.push(mistral::RequestMessage::User {
+                        content: message_content,
+                    });
+                }
+            }
+            Role::Assistant => {
+                for content in &message.content {
+                    match content {
+                        MessageContent::Text(text) | MessageContent::Thinking { text, .. } => {
+                            messages.push(mistral::RequestMessage::Assistant {
+                                content: Some(text.clone()),
+                                tool_calls: Vec::new(),
+                            });
+                        }
+                        MessageContent::RedactedThinking(_) => {}
+                        MessageContent::Image(_) => {}
+                        MessageContent::ToolUse(tool_use) => {
+                            let tool_call = mistral::ToolCall {
+                                id: tool_use.id.to_string(),
+                                content: mistral::ToolCallContent::Function {
+                                    function: mistral::FunctionContent {
+                                        name: tool_use.name.to_string(),
+                                        arguments: serde_json::to_string(&tool_use.input)
+                                            .unwrap_or_default(),
+                                    },
+                                },
+                            };
+
+                            if let Some(mistral::RequestMessage::Assistant { tool_calls, .. }) =
+                                messages.last_mut()
+                            {
+                                tool_calls.push(tool_call);
+                            } else {
+                                messages.push(mistral::RequestMessage::Assistant {
+                                    content: None,
+                                    tool_calls: vec![tool_call],
+                                });
+                            }
+                        }
+                        MessageContent::ToolResult(_) => {
+                            // Tool results are not supported in Assistant messages
+                        }
+                    }
+                }
+            }
+            Role::System => {
+                for content in &message.content {
+                    match content {
+                        MessageContent::Text(text) | MessageContent::Thinking { text, .. } => {
+                            messages.push(mistral::RequestMessage::System {
+                                content: text.clone(),
+                            });
+                        }
+                        MessageContent::RedactedThinking(_) => {}
+                        MessageContent::Image(_)
+                        | MessageContent::ToolUse(_)
+                        | MessageContent::ToolResult(_) => {
+                            // Images and tools are not supported in System messages
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    for message in &request.messages {
+        for content in &message.content {
+            if let MessageContent::ToolResult(tool_result) = content {
+                let content = match &tool_result.content {
+                    LanguageModelToolResultContent::Text(text) => text.to_string(),
+                    LanguageModelToolResultContent::Image(_) => {
+                        "[Tool responded with an image, but Zed doesn't support these in Mistral models yet]".to_string()
+                    }
+                };
+
+                messages.push(mistral::RequestMessage::Tool {
+                    content,
+                    tool_call_id: tool_result.tool_use_id.to_string(),
                 });
-                acc
-            });
+            }
+        }
+    }
+
+    // The Mistral API requires that tool messages be followed by assistant messages,
+    // not user messages. When we have a tool->user sequence in the conversation,
+    // we need to insert a placeholder assistant message to maintain proper conversation
+    // flow and prevent API errors. This is a Mistral-specific requirement that differs
+    // from other language model APIs.
+    let messages = {
+        let mut fixed_messages = Vec::with_capacity(messages.len());
+        let mut messages_iter = messages.into_iter().peekable();
+
+        while let Some(message) = messages_iter.next() {
+            let is_tool_message = matches!(message, mistral::RequestMessage::Tool { .. });
+            fixed_messages.push(message);
+
+            // Insert assistant message between tool and user messages
+            if is_tool_message {
+                if let Some(next_msg) = messages_iter.peek() {
+                    if matches!(next_msg, mistral::RequestMessage::User { .. }) {
+                        fixed_messages.push(mistral::RequestMessage::Assistant {
+                            content: Some(" ".to_string()),
+                            tool_calls: Vec::new(),
+                        });
+                    }
+                }
+            }
+        }
+
+        fixed_messages
+    };
 
     mistral::Request {
         model,
-        messages: merged_messages,
-        stream: true,
+        messages,
+        stream,
         max_tokens: max_output_tokens,
         temperature: request.temperature,
         response_format: None,
+        tool_choice: match request.tool_choice {
+            Some(LanguageModelToolChoice::Auto) if !request.tools.is_empty() => {
+                Some(mistral::ToolChoice::Auto)
+            }
+            Some(LanguageModelToolChoice::Any) if !request.tools.is_empty() => {
+                Some(mistral::ToolChoice::Any)
+            }
+            Some(LanguageModelToolChoice::None) => Some(mistral::ToolChoice::None),
+            _ if !request.tools.is_empty() => Some(mistral::ToolChoice::Auto),
+            _ => None,
+        },
+        parallel_tool_calls: if !request.tools.is_empty() {
+            Some(false)
+        } else {
+            None
+        },
         tools: request
             .tools
             .into_iter()
@@ -435,6 +566,136 @@ pub fn into_mistral(
     }
 }
 
+pub struct MistralEventMapper {
+    tool_calls_by_index: HashMap<usize, RawToolCall>,
+}
+
+impl MistralEventMapper {
+    pub fn new() -> Self {
+        Self {
+            tool_calls_by_index: HashMap::default(),
+        }
+    }
+
+    pub fn map_stream(
+        mut self,
+        events: Pin<Box<dyn Send + futures::Stream<Item = Result<mistral::StreamResponse>>>>,
+    ) -> impl futures::Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
+    {
+        events.flat_map(move |event| {
+            futures::stream::iter(match event {
+                Ok(event) => self.map_event(event),
+                Err(error) => vec![Err(LanguageModelCompletionError::Other(anyhow!(error)))],
+            })
+        })
+    }
+
+    pub fn map_event(
+        &mut self,
+        event: mistral::StreamResponse,
+    ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
+        let Some(choice) = event.choices.first() else {
+            return vec![Err(LanguageModelCompletionError::Other(anyhow!(
+                "Response contained no choices"
+            )))];
+        };
+
+        let mut events = Vec::new();
+        if let Some(content) = choice.delta.content.clone() {
+            events.push(Ok(LanguageModelCompletionEvent::Text(content)));
+        }
+
+        if let Some(tool_calls) = choice.delta.tool_calls.as_ref() {
+            for tool_call in tool_calls {
+                let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
+
+                if let Some(tool_id) = tool_call.id.clone() {
+                    entry.id = tool_id;
+                }
+
+                if let Some(function) = tool_call.function.as_ref() {
+                    if let Some(name) = function.name.clone() {
+                        entry.name = name;
+                    }
+
+                    if let Some(arguments) = function.arguments.clone() {
+                        entry.arguments.push_str(&arguments);
+                    }
+                }
+            }
+        }
+
+        if let Some(usage) = event.usage {
+            events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
+                input_tokens: usage.prompt_tokens,
+                output_tokens: usage.completion_tokens,
+                cache_creation_input_tokens: 0,
+                cache_read_input_tokens: 0,
+            })));
+        }
+
+        if let Some(finish_reason) = choice.finish_reason.as_deref() {
+            match finish_reason {
+                "stop" => {
+                    events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
+                }
+                "tool_calls" => {
+                    events.extend(self.process_tool_calls());
+                    events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
+                }
+                unexpected => {
+                    log::error!("Unexpected Mistral stop_reason: {unexpected:?}");
+                    events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
+                }
+            }
+        }
+
+        events
+    }
+
+    fn process_tool_calls(
+        &mut self,
+    ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
+        let mut results = Vec::new();
+
+        for (_, tool_call) in self.tool_calls_by_index.drain() {
+            if tool_call.id.is_empty() || tool_call.name.is_empty() {
+                results.push(Err(LanguageModelCompletionError::Other(anyhow!(
+                    "Received incomplete tool call: missing id or name"
+                ))));
+                continue;
+            }
+
+            match serde_json::Value::from_str(&tool_call.arguments) {
+                Ok(input) => results.push(Ok(LanguageModelCompletionEvent::ToolUse(
+                    LanguageModelToolUse {
+                        id: tool_call.id.into(),
+                        name: tool_call.name.into(),
+                        is_input_complete: true,
+                        input,
+                        raw_input: tool_call.arguments,
+                    },
+                ))),
+                Err(error) => results.push(Err(LanguageModelCompletionError::BadInputJson {
+                    id: tool_call.id.into(),
+                    tool_name: tool_call.name.into(),
+                    raw_input: tool_call.arguments.into(),
+                    json_parse_error: error.to_string(),
+                })),
+            }
+        }
+
+        results
+    }
+}
+
+#[derive(Default)]
+struct RawToolCall {
+    id: String,
+    name: String,
+    arguments: String,
+}
+
 struct ConfigurationView {
     api_key_editor: Entity<Editor>,
     state: gpui::Entity<State>,
@@ -619,3 +880,92 @@ impl Render for ConfigurationView {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use language_model::{LanguageModelImage, LanguageModelRequestMessage, MessageContent};
+
+    #[test]
+    fn test_into_mistral_basic_conversion() {
+        let request = LanguageModelRequest {
+            messages: vec![
+                LanguageModelRequestMessage {
+                    role: Role::System,
+                    content: vec![MessageContent::Text("System prompt".into())],
+                    cache: false,
+                },
+                LanguageModelRequestMessage {
+                    role: Role::User,
+                    content: vec![MessageContent::Text("Hello".into())],
+                    cache: false,
+                },
+            ],
+            temperature: Some(0.5),
+            tools: vec![],
+            tool_choice: None,
+            thread_id: None,
+            prompt_id: None,
+            intent: None,
+            mode: None,
+            stop: vec![],
+        };
+
+        let mistral_request = into_mistral(request, "mistral-small-latest".into(), None);
+
+        assert_eq!(mistral_request.model, "mistral-small-latest");
+        assert_eq!(mistral_request.temperature, Some(0.5));
+        assert_eq!(mistral_request.messages.len(), 2);
+        assert!(mistral_request.stream);
+    }
+
+    #[test]
+    fn test_into_mistral_with_image() {
+        let request = LanguageModelRequest {
+            messages: vec![LanguageModelRequestMessage {
+                role: Role::User,
+                content: vec![
+                    MessageContent::Text("What's in this image?".into()),
+                    MessageContent::Image(LanguageModelImage {
+                        source: "base64data".into(),
+                        size: Default::default(),
+                    }),
+                ],
+                cache: false,
+            }],
+            tools: vec![],
+            tool_choice: None,
+            temperature: None,
+            thread_id: None,
+            prompt_id: None,
+            intent: None,
+            mode: None,
+            stop: vec![],
+        };
+
+        let mistral_request = into_mistral(request, "pixtral-12b-latest".into(), None);
+
+        assert_eq!(mistral_request.messages.len(), 1);
+        assert!(matches!(
+            &mistral_request.messages[0],
+            mistral::RequestMessage::User {
+                content: mistral::MessageContent::Multipart { .. }
+            }
+        ));
+
+        if let mistral::RequestMessage::User {
+            content: mistral::MessageContent::Multipart { content },
+        } = &mistral_request.messages[0]
+        {
+            assert_eq!(content.len(), 2);
+            assert!(matches!(
+                &content[0],
+                mistral::MessagePart::Text { text } if text == "What's in this image?"
+            ));
+            assert!(matches!(
+                &content[1],
+                mistral::MessagePart::ImageUrl { image_url } if image_url.starts_with("data:image/png;base64,")
+            ));
+        }
+    }
+}

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

@@ -4,25 +4,22 @@ use futures::{Stream, TryFutureExt, stream};
 use gpui::{AnyView, App, AsyncApp, Context, Subscription, Task};
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, LanguageModelCompletionError, LanguageModelCompletionEvent,
+    AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
+    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
+    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
     LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse,
-    LanguageModelToolUseId, StopReason,
-};
-use language_model::{
-    LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider,
-    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
-    LanguageModelRequest, RateLimiter, Role,
+    LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage,
 };
 use ollama::{
     ChatMessage, ChatOptions, ChatRequest, ChatResponseDelta, KeepAlive, OllamaFunctionTool,
-    OllamaToolCall, get_models, preload_model, show_model, stream_chat_completion,
+    OllamaToolCall, get_models, show_model, stream_chat_completion,
 };
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
 use std::pin::Pin;
 use std::sync::atomic::{AtomicU64, Ordering};
-use std::{collections::BTreeMap, sync::Arc};
+use std::{collections::HashMap, sync::Arc};
 use ui::{ButtonLike, Indicator, List, prelude::*};
 use util::ResultExt;
 
@@ -49,11 +46,15 @@ pub struct AvailableModel {
     /// The model's name in Zed's UI, such as in the model selector dropdown menu in the assistant panel.
     pub display_name: Option<String>,
     /// The Context Length parameter to the model (aka num_ctx or n_ctx)
-    pub max_tokens: usize,
+    pub max_tokens: u64,
     /// The number of seconds to keep the connection open after the last request
     pub keep_alive: Option<KeepAlive>,
     /// Whether the model supports tools
     pub supports_tools: Option<bool>,
+    /// Whether the model supports vision
+    pub supports_images: Option<bool>,
+    /// Whether to enable think mode
+    pub supports_thinking: Option<bool>,
 }
 
 pub struct OllamaLanguageModelProvider {
@@ -99,6 +100,8 @@ impl State {
                             None,
                             None,
                             Some(capabilities.supports_tools()),
+                            Some(capabilities.supports_vision()),
+                            Some(capabilities.supports_thinking()),
                         );
                         Ok(ollama_model)
                     }
@@ -198,7 +201,7 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
     }
 
     fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
-        let mut models: BTreeMap<String, ollama::Model> = BTreeMap::default();
+        let mut models: HashMap<String, ollama::Model> = HashMap::new();
 
         // Add models from the Ollama API
         for model in self.state.read(cx).available_models.iter() {
@@ -219,11 +222,13 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
                     max_tokens: model.max_tokens,
                     keep_alive: model.keep_alive.clone(),
                     supports_tools: model.supports_tools,
+                    supports_vision: model.supports_images,
+                    supports_thinking: model.supports_thinking,
                 },
             );
         }
 
-        models
+        let mut models = models
             .into_values()
             .map(|model| {
                 Arc::new(OllamaLanguageModel {
@@ -233,16 +238,9 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
                     request_limiter: RateLimiter::new(4),
                 }) as Arc<dyn LanguageModel>
             })
-            .collect()
-    }
-
-    fn load_model(&self, model: Arc<dyn LanguageModel>, cx: &App) {
-        let settings = &AllLanguageModelSettings::get_global(cx).ollama;
-        let http_client = self.http_client.clone();
-        let api_url = settings.api_url.clone();
-        let id = model.id().0.to_string();
-        cx.spawn(async move |_| preload_model(http_client, &api_url, &id).await)
-            .detach_and_log_err(cx);
+            .collect::<Vec<_>>();
+        models.sort_by_key(|model| model.name());
+        models
     }
 
     fn is_authenticated(&self, cx: &App) -> bool {
@@ -273,22 +271,59 @@ pub struct OllamaLanguageModel {
 
 impl OllamaLanguageModel {
     fn to_ollama_request(&self, request: LanguageModelRequest) -> ChatRequest {
+        let supports_vision = self.model.supports_vision.unwrap_or(false);
+
         ChatRequest {
             model: self.model.name.clone(),
             messages: request
                 .messages
                 .into_iter()
-                .map(|msg| match msg.role {
-                    Role::User => ChatMessage::User {
-                        content: msg.string_contents(),
-                    },
-                    Role::Assistant => ChatMessage::Assistant {
-                        content: msg.string_contents(),
-                        tool_calls: None,
-                    },
-                    Role::System => ChatMessage::System {
-                        content: msg.string_contents(),
-                    },
+                .map(|msg| {
+                    let images = if supports_vision {
+                        msg.content
+                            .iter()
+                            .filter_map(|content| match content {
+                                MessageContent::Image(image) => Some(image.source.to_string()),
+                                _ => None,
+                            })
+                            .collect::<Vec<String>>()
+                    } else {
+                        vec![]
+                    };
+
+                    match msg.role {
+                        Role::User => ChatMessage::User {
+                            content: msg.string_contents(),
+                            images: if images.is_empty() {
+                                None
+                            } else {
+                                Some(images)
+                            },
+                        },
+                        Role::Assistant => {
+                            let content = msg.string_contents();
+                            let thinking =
+                                msg.content.into_iter().find_map(|content| match content {
+                                    MessageContent::Thinking { text, .. } if !text.is_empty() => {
+                                        Some(text)
+                                    }
+                                    _ => None,
+                                });
+                            ChatMessage::Assistant {
+                                content,
+                                tool_calls: None,
+                                images: if images.is_empty() {
+                                    None
+                                } else {
+                                    Some(images)
+                                },
+                                thinking,
+                            }
+                        }
+                        Role::System => ChatMessage::System {
+                            content: msg.string_contents(),
+                        },
+                    }
                 })
                 .collect(),
             keep_alive: self.model.keep_alive.clone().unwrap_or_default(),
@@ -299,6 +334,7 @@ impl OllamaLanguageModel {
                 temperature: request.temperature.or(Some(1.0)),
                 ..Default::default()
             }),
+            think: self.model.supports_thinking,
             tools: request.tools.into_iter().map(tool_into_ollama).collect(),
         }
     }
@@ -325,6 +361,10 @@ impl LanguageModel for OllamaLanguageModel {
         self.model.supports_tools.unwrap_or(false)
     }
 
+    fn supports_images(&self) -> bool {
+        self.model.supports_vision.unwrap_or(false)
+    }
+
     fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
         match choice {
             LanguageModelToolChoice::Auto => false,
@@ -337,7 +377,7 @@ impl LanguageModel for OllamaLanguageModel {
         format!("ollama/{}", self.model.id())
     }
 
-    fn max_token_count(&self) -> usize {
+    fn max_token_count(&self) -> u64 {
         self.model.max_token_count()
     }
 
@@ -345,7 +385,7 @@ impl LanguageModel for OllamaLanguageModel {
         &self,
         request: LanguageModelRequest,
         _cx: &App,
-    ) -> BoxFuture<'static, Result<usize>> {
+    ) -> BoxFuture<'static, Result<u64>> {
         // There is no endpoint for this _yet_ in Ollama
         // see: https://github.com/ollama/ollama/issues/1716 and https://github.com/ollama/ollama/issues/3582
         let token_count = request
@@ -355,7 +395,7 @@ impl LanguageModel for OllamaLanguageModel {
             .sum::<usize>()
             / 4;
 
-        async move { Ok(token_count) }.boxed()
+        async move { Ok(token_count as u64) }.boxed()
     }
 
     fn stream_completion(
@@ -366,6 +406,7 @@ impl LanguageModel for OllamaLanguageModel {
         'static,
         Result<
             BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
+            LanguageModelCompletionError,
         >,
     > {
         let request = self.to_ollama_request(request);
@@ -375,7 +416,7 @@ impl LanguageModel for OllamaLanguageModel {
             let settings = &AllLanguageModelSettings::get_global(cx).ollama;
             settings.api_url.clone()
         }) else {
-            return futures::future::ready(Err(anyhow!("App state dropped"))).boxed();
+            return futures::future::ready(Err(anyhow!("App state dropped").into())).boxed();
         };
 
         let future = self.request_limiter.stream(async move {
@@ -420,7 +461,7 @@ fn map_to_language_model_completion_events(
             let mut events = Vec::new();
 
             match delta.message {
-                ChatMessage::User { content } => {
+                ChatMessage::User { content, images: _ } => {
                     events.push(Ok(LanguageModelCompletionEvent::Text(content)));
                 }
                 ChatMessage::System { content } => {
@@ -429,8 +470,16 @@ fn map_to_language_model_completion_events(
                 ChatMessage::Assistant {
                     content,
                     tool_calls,
+                    images: _,
+                    thinking,
                 } => {
-                    // Check for tool calls
+                    if let Some(text) = thinking {
+                        events.push(Ok(LanguageModelCompletionEvent::Thinking {
+                            text,
+                            signature: None,
+                        }));
+                    }
+
                     if let Some(tool_call) = tool_calls.and_then(|v| v.into_iter().next()) {
                         match tool_call {
                             OllamaToolCall::Function(function) => {
@@ -451,13 +500,19 @@ fn map_to_language_model_completion_events(
                                 state.used_tools = true;
                             }
                         }
-                    } else {
+                    } else if !content.is_empty() {
                         events.push(Ok(LanguageModelCompletionEvent::Text(content)));
                     }
                 }
             };
 
             if delta.done {
+                events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
+                    input_tokens: delta.prompt_eval_count.unwrap_or(0),
+                    output_tokens: delta.eval_count.unwrap_or(0),
+                    cache_creation_input_tokens: 0,
+                    cache_read_input_tokens: 0,
+                })));
                 if state.used_tools {
                     state.used_tools = false;
                     events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));

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

@@ -1,31 +1,34 @@
 use anyhow::{Context as _, Result, anyhow};
 use collections::{BTreeMap, HashMap};
 use credentials_provider::CredentialsProvider;
-use editor::{Editor, EditorElement, EditorStyle};
+
+use fs::Fs;
 use futures::Stream;
 use futures::{FutureExt, StreamExt, future::BoxFuture};
-use gpui::{
-    AnyView, App, AsyncApp, Context, Entity, FontStyle, Subscription, Task, TextStyle, WhiteSpace,
-};
+use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task, Window};
 use http_client::HttpClient;
 use language_model::{
     AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
     LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
     LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason,
+    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
+    RateLimiter, Role, StopReason, TokenUsage,
 };
-use open_ai::{Model, ResponseStreamEvent, stream_completion};
+use menu;
+use open_ai::{ImageUrl, Model, ResponseStreamEvent, stream_completion};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsStore};
+use settings::{Settings, SettingsStore, update_settings_file};
 use std::pin::Pin;
 use std::str::FromStr as _;
 use std::sync::Arc;
 use strum::IntoEnumIterator;
-use theme::ThemeSettings;
-use ui::{Icon, IconName, List, Tooltip, prelude::*};
+
+use ui::{ElevationIndex, List, Tooltip, prelude::*};
+use ui_input::SingleLineInput;
 use util::ResultExt;
 
+use crate::OpenAiSettingsContent;
 use crate::{AllLanguageModelSettings, ui::InstructionListItem};
 
 const PROVIDER_ID: &str = "openai";
@@ -35,16 +38,15 @@ const PROVIDER_NAME: &str = "OpenAI";
 pub struct OpenAiSettings {
     pub api_url: String,
     pub available_models: Vec<AvailableModel>,
-    pub needs_setting_migration: bool,
 }
 
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub struct AvailableModel {
     pub name: String,
     pub display_name: Option<String>,
-    pub max_tokens: usize,
-    pub max_output_tokens: Option<u32>,
-    pub max_completion_tokens: Option<u32>,
+    pub max_tokens: u64,
+    pub max_output_tokens: Option<u64>,
+    pub max_completion_tokens: Option<u64>,
 }
 
 pub struct OpenAiLanguageModelProvider {
@@ -61,6 +63,7 @@ pub struct State {
 const OPENAI_API_KEY_VAR: &str = "OPENAI_API_KEY";
 
 impl State {
+    //
     fn is_authenticated(&self) -> bool {
         self.api_key.is_some()
     }
@@ -264,7 +267,7 @@ impl OpenAiLanguageModel {
         };
 
         let future = self.request_limiter.stream(async move {
-            let api_key = api_key.ok_or_else(|| anyhow!("Missing OpenAI API Key"))?;
+            let api_key = api_key.context("Missing OpenAI API Key")?;
             let request = stream_completion(http_client.as_ref(), &api_url, &api_key, request);
             let response = request.await?;
             Ok(response)
@@ -295,6 +298,10 @@ impl LanguageModel for OpenAiLanguageModel {
         true
     }
 
+    fn supports_images(&self) -> bool {
+        false
+    }
+
     fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
         match choice {
             LanguageModelToolChoice::Auto => true,
@@ -307,11 +314,11 @@ impl LanguageModel for OpenAiLanguageModel {
         format!("openai/{}", self.model.id())
     }
 
-    fn max_token_count(&self) -> usize {
+    fn max_token_count(&self) -> u64 {
         self.model.max_token_count()
     }
 
-    fn max_output_tokens(&self) -> Option<u32> {
+    fn max_output_tokens(&self) -> Option<u64> {
         self.model.max_output_tokens()
     }
 
@@ -319,7 +326,7 @@ impl LanguageModel for OpenAiLanguageModel {
         &self,
         request: LanguageModelRequest,
         cx: &App,
-    ) -> BoxFuture<'static, Result<usize>> {
+    ) -> BoxFuture<'static, Result<u64>> {
         count_open_ai_tokens(request, self.model.clone(), cx)
     }
 
@@ -334,9 +341,15 @@ impl LanguageModel for OpenAiLanguageModel {
                 'static,
                 Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
             >,
+            LanguageModelCompletionError,
         >,
     > {
-        let request = into_open_ai(request, &self.model, self.max_output_tokens());
+        let request = into_open_ai(
+            request,
+            self.model.id(),
+            self.model.supports_parallel_tool_calls(),
+            self.max_output_tokens(),
+        );
         let completions = self.stream_completion(request, cx);
         async move {
             let mapper = OpenAiEventMapper::new();
@@ -348,26 +361,36 @@ impl LanguageModel for OpenAiLanguageModel {
 
 pub fn into_open_ai(
     request: LanguageModelRequest,
-    model: &Model,
-    max_output_tokens: Option<u32>,
+    model_id: &str,
+    supports_parallel_tool_calls: bool,
+    max_output_tokens: Option<u64>,
 ) -> open_ai::Request {
-    let stream = !model.id().starts_with("o1-");
+    let stream = !model_id.starts_with("o1-");
 
     let mut messages = Vec::new();
     for message in request.messages {
         for content in message.content {
             match content {
-                MessageContent::Text(text) | MessageContent::Thinking { text, .. } => messages
-                    .push(match message.role {
-                        Role::User => open_ai::RequestMessage::User { content: text },
-                        Role::Assistant => open_ai::RequestMessage::Assistant {
-                            content: Some(text),
-                            tool_calls: Vec::new(),
-                        },
-                        Role::System => open_ai::RequestMessage::System { content: text },
-                    }),
+                MessageContent::Text(text) | MessageContent::Thinking { text, .. } => {
+                    add_message_content_part(
+                        open_ai::MessagePart::Text { text: text },
+                        message.role,
+                        &mut messages,
+                    )
+                }
                 MessageContent::RedactedThinking(_) => {}
-                MessageContent::Image(_) => {}
+                MessageContent::Image(image) => {
+                    add_message_content_part(
+                        open_ai::MessagePart::Image {
+                            image_url: ImageUrl {
+                                url: image.to_base64_url(),
+                                detail: None,
+                            },
+                        },
+                        message.role,
+                        &mut messages,
+                    );
+                }
                 MessageContent::ToolUse(tool_use) => {
                     let tool_call = open_ai::ToolCall {
                         id: tool_use.id.to_string(),
@@ -392,8 +415,24 @@ pub fn into_open_ai(
                     }
                 }
                 MessageContent::ToolResult(tool_result) => {
+                    let content = match &tool_result.content {
+                        LanguageModelToolResultContent::Text(text) => {
+                            vec![open_ai::MessagePart::Text {
+                                text: text.to_string(),
+                            }]
+                        }
+                        LanguageModelToolResultContent::Image(image) => {
+                            vec![open_ai::MessagePart::Image {
+                                image_url: ImageUrl {
+                                    url: image.to_base64_url(),
+                                    detail: None,
+                                },
+                            }]
+                        }
+                    };
+
                     messages.push(open_ai::RequestMessage::Tool {
-                        content: tool_result.content.to_string(),
+                        content: content.into(),
                         tool_call_id: tool_result.tool_use_id.to_string(),
                     });
                 }
@@ -402,13 +441,13 @@ pub fn into_open_ai(
     }
 
     open_ai::Request {
-        model: model.id().into(),
+        model: model_id.into(),
         messages,
         stream,
         stop: request.stop,
         temperature: request.temperature.unwrap_or(1.0),
-        max_tokens: max_output_tokens,
-        parallel_tool_calls: if model.supports_parallel_tool_calls() && !request.tools.is_empty() {
+        max_completion_tokens: max_output_tokens,
+        parallel_tool_calls: if supports_parallel_tool_calls && !request.tools.is_empty() {
             // Disable parallel tool calls, as the Agent currently expects a maximum of one per turn.
             Some(false)
         } else {
@@ -433,6 +472,40 @@ pub fn into_open_ai(
     }
 }
 
+fn add_message_content_part(
+    new_part: open_ai::MessagePart,
+    role: Role,
+    messages: &mut Vec<open_ai::RequestMessage>,
+) {
+    match (role, messages.last_mut()) {
+        (Role::User, Some(open_ai::RequestMessage::User { content }))
+        | (
+            Role::Assistant,
+            Some(open_ai::RequestMessage::Assistant {
+                content: Some(content),
+                ..
+            }),
+        )
+        | (Role::System, Some(open_ai::RequestMessage::System { content, .. })) => {
+            content.push_part(new_part);
+        }
+        _ => {
+            messages.push(match role {
+                Role::User => open_ai::RequestMessage::User {
+                    content: open_ai::MessageContent::from(vec![new_part]),
+                },
+                Role::Assistant => open_ai::RequestMessage::Assistant {
+                    content: Some(open_ai::MessageContent::from(vec![new_part])),
+                    tool_calls: Vec::new(),
+                },
+                Role::System => open_ai::RequestMessage::System {
+                    content: open_ai::MessageContent::from(vec![new_part]),
+                },
+            });
+        }
+    }
+}
+
 pub struct OpenAiEventMapper {
     tool_calls_by_index: HashMap<usize, RawToolCall>,
 }
@@ -461,13 +534,20 @@ impl OpenAiEventMapper {
         &mut self,
         event: ResponseStreamEvent,
     ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
+        let mut events = Vec::new();
+        if let Some(usage) = event.usage {
+            events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
+                input_tokens: usage.prompt_tokens,
+                output_tokens: usage.completion_tokens,
+                cache_creation_input_tokens: 0,
+                cache_read_input_tokens: 0,
+            })));
+        }
+
         let Some(choice) = event.choices.first() else {
-            return vec![Err(LanguageModelCompletionError::Other(anyhow!(
-                "Response contained no choices"
-            )))];
+            return events;
         };
 
-        let mut events = Vec::new();
         if let Some(content) = choice.delta.content.clone() {
             events.push(Ok(LanguageModelCompletionEvent::Text(content)));
         }
@@ -541,7 +621,7 @@ pub fn count_open_ai_tokens(
     request: LanguageModelRequest,
     model: Model,
     cx: &App,
-) -> BoxFuture<'static, Result<usize>> {
+) -> BoxFuture<'static, Result<u64>> {
     cx.background_spawn(async move {
         let messages = request
             .messages
@@ -570,29 +650,31 @@ pub fn count_open_ai_tokens(
                 };
                 tiktoken_rs::num_tokens_from_messages(model, &messages)
             }
-            // Not currently supported by tiktoken_rs. All use the same tokenizer as gpt-4o (o200k_base)
-            Model::O1
-            | Model::FourPointOne
-            | Model::FourPointOneMini
-            | Model::FourPointOneNano
-            | Model::O3Mini
-            | Model::O3
-            | Model::O4Mini => tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages),
             // Currently supported by tiktoken_rs
+            // Sometimes tiktoken-rs is behind on model support. If that is the case, make a new branch
+            // arm with an override. We enumerate all supported models here so that we can check if new
+            // models are supported yet or not.
             Model::ThreePointFiveTurbo
             | Model::Four
             | Model::FourTurbo
             | Model::FourOmni
             | Model::FourOmniMini
-            | Model::O1Preview
-            | Model::O1Mini => tiktoken_rs::num_tokens_from_messages(model.id(), &messages),
+            | Model::FourPointOne
+            | Model::FourPointOneMini
+            | Model::FourPointOneNano
+            | Model::O1
+            | Model::O3
+            | Model::O3Mini
+            | Model::O4Mini => tiktoken_rs::num_tokens_from_messages(model.id(), &messages),
         }
+        .map(|tokens| tokens as u64)
     })
     .boxed()
 }
 
 struct ConfigurationView {
-    api_key_editor: Entity<Editor>,
+    api_key_editor: Entity<SingleLineInput>,
+    api_url_editor: Entity<SingleLineInput>,
     state: gpui::Entity<State>,
     load_credentials_task: Option<Task<()>>,
 }
@@ -600,9 +682,28 @@ struct ConfigurationView {
 impl ConfigurationView {
     fn new(state: gpui::Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
         let api_key_editor = cx.new(|cx| {
-            let mut editor = Editor::single_line(window, cx);
-            editor.set_placeholder_text("sk-000000000000000000000000000000000000000000000000", cx);
-            editor
+            SingleLineInput::new(
+                window,
+                cx,
+                "sk-000000000000000000000000000000000000000000000000",
+            )
+            .label("API key")
+        });
+
+        let api_url = AllLanguageModelSettings::get_global(cx)
+            .openai
+            .api_url
+            .clone();
+
+        let api_url_editor = cx.new(|cx| {
+            let input = SingleLineInput::new(window, cx, open_ai::OPEN_AI_API_URL).label("API URL");
+
+            if !api_url.is_empty() {
+                input.editor.update(cx, |editor, cx| {
+                    editor.set_text(&*api_url, window, cx);
+                });
+            }
+            input
         });
 
         cx.observe(&state, |_, _, cx| {
@@ -620,7 +721,6 @@ impl ConfigurationView {
                     // We don't log an error, because "not signed in" is also an error.
                     let _ = task.await;
                 }
-
                 this.update(cx, |this, cx| {
                     this.load_credentials_task = None;
                     cx.notify();
@@ -631,14 +731,24 @@ impl ConfigurationView {
 
         Self {
             api_key_editor,
+            api_url_editor,
             state,
             load_credentials_task,
         }
     }
 
     fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
-        let api_key = self.api_key_editor.read(cx).text(cx);
-        if api_key.is_empty() {
+        let api_key = self
+            .api_key_editor
+            .read(cx)
+            .editor()
+            .read(cx)
+            .text(cx)
+            .trim()
+            .to_string();
+
+        // Don't proceed if no API key is provided and we're not authenticated
+        if api_key.is_empty() && !self.state.read(cx).is_authenticated() {
             return;
         }
 
@@ -654,8 +764,11 @@ impl ConfigurationView {
     }
 
     fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        self.api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
+        self.api_key_editor.update(cx, |input, cx| {
+            input.editor.update(cx, |editor, cx| {
+                editor.set_text("", window, cx);
+            });
+        });
 
         let state = self.state.clone();
         cx.spawn_in(window, async move |_, cx| {
@@ -666,29 +779,55 @@ impl ConfigurationView {
         cx.notify();
     }
 
-    fn render_api_key_editor(&self, cx: &mut Context<Self>) -> impl IntoElement {
-        let settings = ThemeSettings::get_global(cx);
-        let text_style = TextStyle {
-            color: cx.theme().colors().text,
-            font_family: settings.ui_font.family.clone(),
-            font_features: settings.ui_font.features.clone(),
-            font_fallbacks: settings.ui_font.fallbacks.clone(),
-            font_size: rems(0.875).into(),
-            font_weight: settings.ui_font.weight,
-            font_style: FontStyle::Normal,
-            line_height: relative(1.3),
-            white_space: WhiteSpace::Normal,
-            ..Default::default()
+    fn save_api_url(&mut self, cx: &mut Context<Self>) {
+        let api_url = self
+            .api_url_editor
+            .read(cx)
+            .editor()
+            .read(cx)
+            .text(cx)
+            .trim()
+            .to_string();
+
+        let current_url = AllLanguageModelSettings::get_global(cx)
+            .openai
+            .api_url
+            .clone();
+
+        let effective_current_url = if current_url.is_empty() {
+            open_ai::OPEN_AI_API_URL
+        } else {
+            &current_url
         };
-        EditorElement::new(
-            &self.api_key_editor,
-            EditorStyle {
-                background: cx.theme().colors().editor_background,
-                local_player: cx.theme().players().local(),
-                text: text_style,
-                ..Default::default()
-            },
-        )
+
+        if !api_url.is_empty() && api_url != effective_current_url {
+            let fs = <dyn Fs>::global(cx);
+            update_settings_file::<AllLanguageModelSettings>(fs, cx, move |settings, _| {
+                if let Some(settings) = settings.openai.as_mut() {
+                    settings.api_url = Some(api_url.clone());
+                } else {
+                    settings.openai = Some(OpenAiSettingsContent {
+                        api_url: Some(api_url.clone()),
+                        available_models: None,
+                    });
+                }
+            });
+        }
+    }
+
+    fn reset_api_url(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.api_url_editor.update(cx, |input, cx| {
+            input.editor.update(cx, |editor, cx| {
+                editor.set_text("", window, cx);
+            });
+        });
+        let fs = <dyn Fs>::global(cx);
+        update_settings_file::<AllLanguageModelSettings>(fs, cx, |settings, _cx| {
+            if let Some(settings) = settings.openai.as_mut() {
+                settings.api_url = None;
+            }
+        });
+        cx.notify();
     }
 
     fn should_render_editor(&self, cx: &mut Context<Self>) -> bool {
@@ -700,12 +839,10 @@ impl Render for ConfigurationView {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let env_var_set = self.state.read(cx).api_key_from_env;
 
-        if self.load_credentials_task.is_some() {
-            div().child(Label::new("Loading credentials...")).into_any()
-        } else if self.should_render_editor(cx) {
+        let api_key_section = if self.should_render_editor(cx) {
             v_flex()
-                .size_full()
                 .on_action(cx.listener(Self::save_api_key))
+
                 .child(Label::new("To use Zed's assistant with OpenAI, you need to add an API key. Follow these steps:"))
                 .child(
                     List::new()
@@ -721,18 +858,7 @@ impl Render for ConfigurationView {
                             "Paste your API key below and hit enter to start using the assistant",
                         )),
                 )
-                .child(
-                    h_flex()
-                        .w_full()
-                        .my_2()
-                        .px_2()
-                        .py_1()
-                        .bg(cx.theme().colors().editor_background)
-                        .border_1()
-                        .border_color(cx.theme().colors().border)
-                        .rounded_sm()
-                        .child(self.render_api_key_editor(cx)),
-                )
+                .child(self.api_key_editor.clone())
                 .child(
                     Label::new(
                         format!("You can also assign the {OPENAI_API_KEY_VAR} environment variable and restart Zed."),
@@ -741,7 +867,7 @@ impl Render for ConfigurationView {
                 )
                 .child(
                     Label::new(
-                        "Note that having a subscription for another service like GitHub Copilot won't work.".to_string(),
+                        "Note that having a subscription for another service like GitHub Copilot won't work.",
                     )
                     .size(LabelSize::Small).color(Color::Muted),
                 )
@@ -766,18 +892,122 @@ impl Render for ConfigurationView {
                         })),
                 )
                 .child(
-                    Button::new("reset-key", "Reset Key")
+                    Button::new("reset-api-key", "Reset API Key")
                         .label_size(LabelSize::Small)
-                        .icon(Some(IconName::Trash))
+                        .icon(IconName::Undo)
                         .icon_size(IconSize::Small)
                         .icon_position(IconPosition::Start)
-                        .disabled(env_var_set)
+                        .layer(ElevationIndex::ModalSurface)
                         .when(env_var_set, |this| {
                             this.tooltip(Tooltip::text(format!("To reset your API key, unset the {OPENAI_API_KEY_VAR} environment variable.")))
                         })
                         .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))),
                 )
                 .into_any()
+        };
+
+        let custom_api_url_set =
+            AllLanguageModelSettings::get_global(cx).openai.api_url != open_ai::OPEN_AI_API_URL;
+
+        let api_url_section = if custom_api_url_set {
+            h_flex()
+                .mt_1()
+                .p_1()
+                .justify_between()
+                .rounded_md()
+                .border_1()
+                .border_color(cx.theme().colors().border)
+                .bg(cx.theme().colors().background)
+                .child(
+                    h_flex()
+                        .gap_1()
+                        .child(Icon::new(IconName::Check).color(Color::Success))
+                        .child(Label::new("Custom API URL configured.")),
+                )
+                .child(
+                    Button::new("reset-api-url", "Reset API URL")
+                        .label_size(LabelSize::Small)
+                        .icon(IconName::Undo)
+                        .icon_size(IconSize::Small)
+                        .icon_position(IconPosition::Start)
+                        .layer(ElevationIndex::ModalSurface)
+                        .on_click(
+                            cx.listener(|this, _, window, cx| this.reset_api_url(window, cx)),
+                        ),
+                )
+                .into_any()
+        } else {
+            v_flex()
+                .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| {
+                    this.save_api_url(cx);
+                    cx.notify();
+                }))
+                .mt_2()
+                .pt_2()
+                .border_t_1()
+                .border_color(cx.theme().colors().border_variant)
+                .gap_1()
+                .child(
+                    List::new()
+                        .child(InstructionListItem::text_only(
+                            "Optionally, you can change the base URL for the OpenAI API request.",
+                        ))
+                        .child(InstructionListItem::text_only(
+                            "Paste the new API endpoint below and hit enter",
+                        )),
+                )
+                .child(self.api_url_editor.clone())
+                .into_any()
+        };
+
+        if self.load_credentials_task.is_some() {
+            div().child(Label::new("Loading credentials…")).into_any()
+        } else {
+            v_flex()
+                .size_full()
+                .child(api_key_section)
+                .child(api_url_section)
+                .into_any()
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use gpui::TestAppContext;
+    use language_model::LanguageModelRequestMessage;
+
+    use super::*;
+
+    #[gpui::test]
+    fn tiktoken_rs_support(cx: &TestAppContext) {
+        let request = LanguageModelRequest {
+            thread_id: None,
+            prompt_id: None,
+            intent: None,
+            mode: None,
+            messages: vec![LanguageModelRequestMessage {
+                role: Role::User,
+                content: vec![MessageContent::Text("message".into())],
+                cache: false,
+            }],
+            tools: vec![],
+            tool_choice: None,
+            stop: vec![],
+            temperature: None,
+        };
+
+        // Validate that all models are supported by tiktoken-rs
+        for model in Model::iter() {
+            let count = cx
+                .executor()
+                .block(count_open_ai_tokens(
+                    request.clone(),
+                    model,
+                    &cx.app.borrow(),
+                ))
+                .unwrap();
+            assert!(count > 0);
         }
     }
 }

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

@@ -0,0 +1,924 @@
+use anyhow::{Context as _, Result, anyhow};
+use collections::HashMap;
+use credentials_provider::CredentialsProvider;
+use editor::{Editor, EditorElement, EditorStyle};
+use futures::{FutureExt, Stream, StreamExt, future::BoxFuture};
+use gpui::{
+    AnyView, App, AsyncApp, Context, Entity, FontStyle, Subscription, Task, TextStyle, WhiteSpace,
+};
+use http_client::HttpClient;
+use language_model::{
+    AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
+    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
+    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
+    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat,
+    LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage,
+};
+use open_router::{
+    Model, ModelMode as OpenRouterModelMode, ResponseStreamEvent, list_models, stream_completion,
+};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::{Settings, SettingsStore};
+use std::pin::Pin;
+use std::str::FromStr as _;
+use std::sync::Arc;
+use theme::ThemeSettings;
+use ui::{Icon, IconName, List, Tooltip, prelude::*};
+use util::ResultExt;
+
+use crate::{AllLanguageModelSettings, ui::InstructionListItem};
+
+const PROVIDER_ID: &str = "openrouter";
+const PROVIDER_NAME: &str = "OpenRouter";
+
+#[derive(Default, Clone, Debug, PartialEq)]
+pub struct OpenRouterSettings {
+    pub api_url: String,
+    pub available_models: Vec<AvailableModel>,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
+pub struct AvailableModel {
+    pub name: String,
+    pub display_name: Option<String>,
+    pub max_tokens: u64,
+    pub max_output_tokens: Option<u64>,
+    pub max_completion_tokens: Option<u64>,
+    pub supports_tools: Option<bool>,
+    pub supports_images: Option<bool>,
+    pub mode: Option<ModelMode>,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(tag = "type", rename_all = "lowercase")]
+pub enum ModelMode {
+    #[default]
+    Default,
+    Thinking {
+        budget_tokens: Option<u32>,
+    },
+}
+
+impl From<ModelMode> for OpenRouterModelMode {
+    fn from(value: ModelMode) -> Self {
+        match value {
+            ModelMode::Default => OpenRouterModelMode::Default,
+            ModelMode::Thinking { budget_tokens } => {
+                OpenRouterModelMode::Thinking { budget_tokens }
+            }
+        }
+    }
+}
+
+impl From<OpenRouterModelMode> for ModelMode {
+    fn from(value: OpenRouterModelMode) -> Self {
+        match value {
+            OpenRouterModelMode::Default => ModelMode::Default,
+            OpenRouterModelMode::Thinking { budget_tokens } => {
+                ModelMode::Thinking { budget_tokens }
+            }
+        }
+    }
+}
+
+pub struct OpenRouterLanguageModelProvider {
+    http_client: Arc<dyn HttpClient>,
+    state: gpui::Entity<State>,
+}
+
+pub struct State {
+    api_key: Option<String>,
+    api_key_from_env: bool,
+    http_client: Arc<dyn HttpClient>,
+    available_models: Vec<open_router::Model>,
+    fetch_models_task: Option<Task<Result<()>>>,
+    settings: OpenRouterSettings,
+    _subscription: Subscription,
+}
+
+const OPENROUTER_API_KEY_VAR: &str = "OPENROUTER_API_KEY";
+
+impl State {
+    fn is_authenticated(&self) -> bool {
+        self.api_key.is_some()
+    }
+
+    fn reset_api_key(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = <dyn CredentialsProvider>::global(cx);
+        let api_url = AllLanguageModelSettings::get_global(cx)
+            .open_router
+            .api_url
+            .clone();
+        cx.spawn(async move |this, cx| {
+            credentials_provider
+                .delete_credentials(&api_url, &cx)
+                .await
+                .log_err();
+            this.update(cx, |this, cx| {
+                this.api_key = None;
+                this.api_key_from_env = false;
+                cx.notify();
+            })
+        })
+    }
+
+    fn set_api_key(&mut self, api_key: String, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = <dyn CredentialsProvider>::global(cx);
+        let api_url = AllLanguageModelSettings::get_global(cx)
+            .open_router
+            .api_url
+            .clone();
+        cx.spawn(async move |this, cx| {
+            credentials_provider
+                .write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx)
+                .await
+                .log_err();
+            this.update(cx, |this, cx| {
+                this.api_key = Some(api_key);
+                this.restart_fetch_models_task(cx);
+                cx.notify();
+            })
+        })
+    }
+
+    fn authenticate(&self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        if self.is_authenticated() {
+            return Task::ready(Ok(()));
+        }
+
+        let credentials_provider = <dyn CredentialsProvider>::global(cx);
+        let api_url = AllLanguageModelSettings::get_global(cx)
+            .open_router
+            .api_url
+            .clone();
+        cx.spawn(async move |this, cx| {
+            let (api_key, from_env) = if let Ok(api_key) = std::env::var(OPENROUTER_API_KEY_VAR) {
+                (api_key, true)
+            } else {
+                let (_, api_key) = credentials_provider
+                    .read_credentials(&api_url, &cx)
+                    .await?
+                    .ok_or(AuthenticateError::CredentialsNotFound)?;
+                (
+                    String::from_utf8(api_key)
+                        .context(format!("invalid {} API key", PROVIDER_NAME))?,
+                    false,
+                )
+            };
+            this.update(cx, |this, cx| {
+                this.api_key = Some(api_key);
+                this.api_key_from_env = from_env;
+                this.restart_fetch_models_task(cx);
+                cx.notify();
+            })?;
+
+            Ok(())
+        })
+    }
+
+    fn fetch_models(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let settings = &AllLanguageModelSettings::get_global(cx).open_router;
+        let http_client = self.http_client.clone();
+        let api_url = settings.api_url.clone();
+
+        cx.spawn(async move |this, cx| {
+            let models = list_models(http_client.as_ref(), &api_url).await?;
+
+            this.update(cx, |this, cx| {
+                this.available_models = models;
+                cx.notify();
+            })
+        })
+    }
+
+    fn restart_fetch_models_task(&mut self, cx: &mut Context<Self>) {
+        if self.is_authenticated() {
+            let task = self.fetch_models(cx);
+            self.fetch_models_task.replace(task);
+        }
+    }
+}
+
+impl OpenRouterLanguageModelProvider {
+    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+        let state = cx.new(|cx| State {
+            api_key: None,
+            api_key_from_env: false,
+            http_client: http_client.clone(),
+            available_models: Vec::new(),
+            fetch_models_task: None,
+            settings: OpenRouterSettings::default(),
+            _subscription: cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
+                let current_settings = &AllLanguageModelSettings::get_global(cx).open_router;
+                let settings_changed = current_settings != &this.settings;
+                if settings_changed {
+                    this.settings = current_settings.clone();
+                    this.restart_fetch_models_task(cx);
+                }
+                cx.notify();
+            }),
+        });
+
+        Self { http_client, state }
+    }
+
+    fn create_language_model(&self, model: open_router::Model) -> Arc<dyn LanguageModel> {
+        Arc::new(OpenRouterLanguageModel {
+            id: LanguageModelId::from(model.id().to_string()),
+            model,
+            state: self.state.clone(),
+            http_client: self.http_client.clone(),
+            request_limiter: RateLimiter::new(4),
+        })
+    }
+}
+
+impl LanguageModelProviderState for OpenRouterLanguageModelProvider {
+    type ObservableEntity = State;
+
+    fn observable_entity(&self) -> Option<gpui::Entity<Self::ObservableEntity>> {
+        Some(self.state.clone())
+    }
+}
+
+impl LanguageModelProvider for OpenRouterLanguageModelProvider {
+    fn id(&self) -> LanguageModelProviderId {
+        LanguageModelProviderId(PROVIDER_ID.into())
+    }
+
+    fn name(&self) -> LanguageModelProviderName {
+        LanguageModelProviderName(PROVIDER_NAME.into())
+    }
+
+    fn icon(&self) -> IconName {
+        IconName::AiOpenRouter
+    }
+
+    fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
+        Some(self.create_language_model(open_router::Model::default()))
+    }
+
+    fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
+        Some(self.create_language_model(open_router::Model::default_fast()))
+    }
+
+    fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
+        let mut models_from_api = self.state.read(cx).available_models.clone();
+        let mut settings_models = Vec::new();
+
+        for model in &AllLanguageModelSettings::get_global(cx)
+            .open_router
+            .available_models
+        {
+            settings_models.push(open_router::Model {
+                name: model.name.clone(),
+                display_name: model.display_name.clone(),
+                max_tokens: model.max_tokens,
+                supports_tools: model.supports_tools,
+                supports_images: model.supports_images,
+                mode: model.mode.clone().unwrap_or_default().into(),
+            });
+        }
+
+        for settings_model in &settings_models {
+            if let Some(pos) = models_from_api
+                .iter()
+                .position(|m| m.name == settings_model.name)
+            {
+                models_from_api[pos] = settings_model.clone();
+            } else {
+                models_from_api.push(settings_model.clone());
+            }
+        }
+
+        models_from_api
+            .into_iter()
+            .map(|model| self.create_language_model(model))
+            .collect()
+    }
+
+    fn is_authenticated(&self, cx: &App) -> bool {
+        self.state.read(cx).is_authenticated()
+    }
+
+    fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>> {
+        self.state.update(cx, |state, cx| state.authenticate(cx))
+    }
+
+    fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView {
+        cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
+            .into()
+    }
+
+    fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>> {
+        self.state.update(cx, |state, cx| state.reset_api_key(cx))
+    }
+}
+
+pub struct OpenRouterLanguageModel {
+    id: LanguageModelId,
+    model: open_router::Model,
+    state: gpui::Entity<State>,
+    http_client: Arc<dyn HttpClient>,
+    request_limiter: RateLimiter,
+}
+
+impl OpenRouterLanguageModel {
+    fn stream_completion(
+        &self,
+        request: open_router::Request,
+        cx: &AsyncApp,
+    ) -> BoxFuture<'static, Result<futures::stream::BoxStream<'static, Result<ResponseStreamEvent>>>>
+    {
+        let http_client = self.http_client.clone();
+        let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| {
+            let settings = &AllLanguageModelSettings::get_global(cx).open_router;
+            (state.api_key.clone(), settings.api_url.clone())
+        }) else {
+            return futures::future::ready(Err(anyhow!(
+                "App state dropped: Unable to read API key or API URL from the application state"
+            )))
+            .boxed();
+        };
+
+        let future = self.request_limiter.stream(async move {
+            let api_key = api_key.ok_or_else(|| anyhow!("Missing OpenRouter API Key"))?;
+            let request = stream_completion(http_client.as_ref(), &api_url, &api_key, request);
+            let response = request.await?;
+            Ok(response)
+        });
+
+        async move { Ok(future.await?.boxed()) }.boxed()
+    }
+}
+
+impl LanguageModel for OpenRouterLanguageModel {
+    fn id(&self) -> LanguageModelId {
+        self.id.clone()
+    }
+
+    fn name(&self) -> LanguageModelName {
+        LanguageModelName::from(self.model.display_name().to_string())
+    }
+
+    fn provider_id(&self) -> LanguageModelProviderId {
+        LanguageModelProviderId(PROVIDER_ID.into())
+    }
+
+    fn provider_name(&self) -> LanguageModelProviderName {
+        LanguageModelProviderName(PROVIDER_NAME.into())
+    }
+
+    fn supports_tools(&self) -> bool {
+        self.model.supports_tool_calls()
+    }
+
+    fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
+        let model_id = self.model.id().trim().to_lowercase();
+        if model_id.contains("gemini") {
+            LanguageModelToolSchemaFormat::JsonSchemaSubset
+        } else {
+            LanguageModelToolSchemaFormat::JsonSchema
+        }
+    }
+
+    fn telemetry_id(&self) -> String {
+        format!("openrouter/{}", self.model.id())
+    }
+
+    fn max_token_count(&self) -> u64 {
+        self.model.max_token_count()
+    }
+
+    fn max_output_tokens(&self) -> Option<u64> {
+        self.model.max_output_tokens()
+    }
+
+    fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
+        match choice {
+            LanguageModelToolChoice::Auto => true,
+            LanguageModelToolChoice::Any => true,
+            LanguageModelToolChoice::None => true,
+        }
+    }
+
+    fn supports_images(&self) -> bool {
+        self.model.supports_images.unwrap_or(false)
+    }
+
+    fn count_tokens(
+        &self,
+        request: LanguageModelRequest,
+        cx: &App,
+    ) -> BoxFuture<'static, Result<u64>> {
+        count_open_router_tokens(request, self.model.clone(), cx)
+    }
+
+    fn stream_completion(
+        &self,
+        request: LanguageModelRequest,
+        cx: &AsyncApp,
+    ) -> BoxFuture<
+        'static,
+        Result<
+            futures::stream::BoxStream<
+                'static,
+                Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
+            >,
+            LanguageModelCompletionError,
+        >,
+    > {
+        let request = into_open_router(request, &self.model, self.max_output_tokens());
+        let completions = self.stream_completion(request, cx);
+        async move {
+            let mapper = OpenRouterEventMapper::new();
+            Ok(mapper.map_stream(completions.await?).boxed())
+        }
+        .boxed()
+    }
+}
+
+pub fn into_open_router(
+    request: LanguageModelRequest,
+    model: &Model,
+    max_output_tokens: Option<u64>,
+) -> open_router::Request {
+    let mut messages = Vec::new();
+    for message in request.messages {
+        for content in message.content {
+            match content {
+                MessageContent::Text(text) => add_message_content_part(
+                    open_router::MessagePart::Text { text },
+                    message.role,
+                    &mut messages,
+                ),
+                MessageContent::Thinking { .. } => {}
+                MessageContent::RedactedThinking(_) => {}
+                MessageContent::Image(image) => {
+                    add_message_content_part(
+                        open_router::MessagePart::Image {
+                            image_url: image.to_base64_url(),
+                        },
+                        message.role,
+                        &mut messages,
+                    );
+                }
+                MessageContent::ToolUse(tool_use) => {
+                    let tool_call = open_router::ToolCall {
+                        id: tool_use.id.to_string(),
+                        content: open_router::ToolCallContent::Function {
+                            function: open_router::FunctionContent {
+                                name: tool_use.name.to_string(),
+                                arguments: serde_json::to_string(&tool_use.input)
+                                    .unwrap_or_default(),
+                            },
+                        },
+                    };
+
+                    if let Some(open_router::RequestMessage::Assistant { tool_calls, .. }) =
+                        messages.last_mut()
+                    {
+                        tool_calls.push(tool_call);
+                    } else {
+                        messages.push(open_router::RequestMessage::Assistant {
+                            content: None,
+                            tool_calls: vec![tool_call],
+                        });
+                    }
+                }
+                MessageContent::ToolResult(tool_result) => {
+                    let content = match &tool_result.content {
+                        LanguageModelToolResultContent::Text(text) => {
+                            vec![open_router::MessagePart::Text {
+                                text: text.to_string(),
+                            }]
+                        }
+                        LanguageModelToolResultContent::Image(image) => {
+                            vec![open_router::MessagePart::Image {
+                                image_url: image.to_base64_url(),
+                            }]
+                        }
+                    };
+
+                    messages.push(open_router::RequestMessage::Tool {
+                        content: content.into(),
+                        tool_call_id: tool_result.tool_use_id.to_string(),
+                    });
+                }
+            }
+        }
+    }
+
+    open_router::Request {
+        model: model.id().into(),
+        messages,
+        stream: true,
+        stop: request.stop,
+        temperature: request.temperature.unwrap_or(0.4),
+        max_tokens: max_output_tokens,
+        parallel_tool_calls: if model.supports_parallel_tool_calls() && !request.tools.is_empty() {
+            Some(false)
+        } else {
+            None
+        },
+        usage: open_router::RequestUsage { include: true },
+        reasoning: if let OpenRouterModelMode::Thinking { budget_tokens } = model.mode {
+            Some(open_router::Reasoning {
+                effort: None,
+                max_tokens: budget_tokens,
+                exclude: Some(false),
+                enabled: Some(true),
+            })
+        } else {
+            None
+        },
+        tools: request
+            .tools
+            .into_iter()
+            .map(|tool| open_router::ToolDefinition::Function {
+                function: open_router::FunctionDefinition {
+                    name: tool.name,
+                    description: Some(tool.description),
+                    parameters: Some(tool.input_schema),
+                },
+            })
+            .collect(),
+        tool_choice: request.tool_choice.map(|choice| match choice {
+            LanguageModelToolChoice::Auto => open_router::ToolChoice::Auto,
+            LanguageModelToolChoice::Any => open_router::ToolChoice::Required,
+            LanguageModelToolChoice::None => open_router::ToolChoice::None,
+        }),
+    }
+}
+
+fn add_message_content_part(
+    new_part: open_router::MessagePart,
+    role: Role,
+    messages: &mut Vec<open_router::RequestMessage>,
+) {
+    match (role, messages.last_mut()) {
+        (Role::User, Some(open_router::RequestMessage::User { content }))
+        | (Role::System, Some(open_router::RequestMessage::System { content })) => {
+            content.push_part(new_part);
+        }
+        (
+            Role::Assistant,
+            Some(open_router::RequestMessage::Assistant {
+                content: Some(content),
+                ..
+            }),
+        ) => {
+            content.push_part(new_part);
+        }
+        _ => {
+            messages.push(match role {
+                Role::User => open_router::RequestMessage::User {
+                    content: open_router::MessageContent::from(vec![new_part]),
+                },
+                Role::Assistant => open_router::RequestMessage::Assistant {
+                    content: Some(open_router::MessageContent::from(vec![new_part])),
+                    tool_calls: Vec::new(),
+                },
+                Role::System => open_router::RequestMessage::System {
+                    content: open_router::MessageContent::from(vec![new_part]),
+                },
+            });
+        }
+    }
+}
+
+pub struct OpenRouterEventMapper {
+    tool_calls_by_index: HashMap<usize, RawToolCall>,
+}
+
+impl OpenRouterEventMapper {
+    pub fn new() -> Self {
+        Self {
+            tool_calls_by_index: HashMap::default(),
+        }
+    }
+
+    pub fn map_stream(
+        mut self,
+        events: Pin<Box<dyn Send + Stream<Item = Result<ResponseStreamEvent>>>>,
+    ) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
+    {
+        events.flat_map(move |event| {
+            futures::stream::iter(match event {
+                Ok(event) => self.map_event(event),
+                Err(error) => vec![Err(LanguageModelCompletionError::Other(anyhow!(error)))],
+            })
+        })
+    }
+
+    pub fn map_event(
+        &mut self,
+        event: ResponseStreamEvent,
+    ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
+        let Some(choice) = event.choices.first() else {
+            return vec![Err(LanguageModelCompletionError::Other(anyhow!(
+                "Response contained no choices"
+            )))];
+        };
+
+        let mut events = Vec::new();
+        if let Some(reasoning) = choice.delta.reasoning.clone() {
+            events.push(Ok(LanguageModelCompletionEvent::Thinking {
+                text: reasoning,
+                signature: None,
+            }));
+        }
+
+        if let Some(content) = choice.delta.content.clone() {
+            // OpenRouter send empty content string with the reasoning content
+            // This is a workaround for the OpenRouter API bug
+            if !content.is_empty() {
+                events.push(Ok(LanguageModelCompletionEvent::Text(content)));
+            }
+        }
+
+        if let Some(tool_calls) = choice.delta.tool_calls.as_ref() {
+            for tool_call in tool_calls {
+                let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
+
+                if let Some(tool_id) = tool_call.id.clone() {
+                    entry.id = tool_id;
+                }
+
+                if let Some(function) = tool_call.function.as_ref() {
+                    if let Some(name) = function.name.clone() {
+                        entry.name = name;
+                    }
+
+                    if let Some(arguments) = function.arguments.clone() {
+                        entry.arguments.push_str(&arguments);
+                    }
+                }
+            }
+        }
+
+        if let Some(usage) = event.usage {
+            events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
+                input_tokens: usage.prompt_tokens,
+                output_tokens: usage.completion_tokens,
+                cache_creation_input_tokens: 0,
+                cache_read_input_tokens: 0,
+            })));
+        }
+
+        match choice.finish_reason.as_deref() {
+            Some("stop") => {
+                events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
+            }
+            Some("tool_calls") => {
+                events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
+                    match serde_json::Value::from_str(&tool_call.arguments) {
+                        Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
+                            LanguageModelToolUse {
+                                id: tool_call.id.clone().into(),
+                                name: tool_call.name.as_str().into(),
+                                is_input_complete: true,
+                                input,
+                                raw_input: tool_call.arguments.clone(),
+                            },
+                        )),
+                        Err(error) => Err(LanguageModelCompletionError::BadInputJson {
+                            id: tool_call.id.into(),
+                            tool_name: tool_call.name.as_str().into(),
+                            raw_input: tool_call.arguments.into(),
+                            json_parse_error: error.to_string(),
+                        }),
+                    }
+                }));
+
+                events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
+            }
+            Some(stop_reason) => {
+                log::error!("Unexpected OpenRouter stop_reason: {stop_reason:?}",);
+                events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
+            }
+            None => {}
+        }
+
+        events
+    }
+}
+
+#[derive(Default)]
+struct RawToolCall {
+    id: String,
+    name: String,
+    arguments: String,
+}
+
+pub fn count_open_router_tokens(
+    request: LanguageModelRequest,
+    _model: open_router::Model,
+    cx: &App,
+) -> BoxFuture<'static, Result<u64>> {
+    cx.background_spawn(async move {
+        let messages = request
+            .messages
+            .into_iter()
+            .map(|message| tiktoken_rs::ChatCompletionRequestMessage {
+                role: match message.role {
+                    Role::User => "user".into(),
+                    Role::Assistant => "assistant".into(),
+                    Role::System => "system".into(),
+                },
+                content: Some(message.string_contents()),
+                name: None,
+                function_call: None,
+            })
+            .collect::<Vec<_>>();
+
+        tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages).map(|tokens| tokens as u64)
+    })
+    .boxed()
+}
+
+struct ConfigurationView {
+    api_key_editor: Entity<Editor>,
+    state: gpui::Entity<State>,
+    load_credentials_task: Option<Task<()>>,
+}
+
+impl ConfigurationView {
+    fn new(state: gpui::Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+        let api_key_editor = cx.new(|cx| {
+            let mut editor = Editor::single_line(window, cx);
+            editor
+                .set_placeholder_text("sk_or_000000000000000000000000000000000000000000000000", cx);
+            editor
+        });
+
+        cx.observe(&state, |_, _, cx| {
+            cx.notify();
+        })
+        .detach();
+
+        let load_credentials_task = Some(cx.spawn_in(window, {
+            let state = state.clone();
+            async move |this, cx| {
+                if let Some(task) = state
+                    .update(cx, |state, cx| state.authenticate(cx))
+                    .log_err()
+                {
+                    let _ = task.await;
+                }
+
+                this.update(cx, |this, cx| {
+                    this.load_credentials_task = None;
+                    cx.notify();
+                })
+                .log_err();
+            }
+        }));
+
+        Self {
+            api_key_editor,
+            state,
+            load_credentials_task,
+        }
+    }
+
+    fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        let api_key = self.api_key_editor.read(cx).text(cx);
+        if api_key.is_empty() {
+            return;
+        }
+
+        let state = self.state.clone();
+        cx.spawn_in(window, async move |_, cx| {
+            state
+                .update(cx, |state, cx| state.set_api_key(api_key, cx))?
+                .await
+        })
+        .detach_and_log_err(cx);
+
+        cx.notify();
+    }
+
+    fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.api_key_editor
+            .update(cx, |editor, cx| editor.set_text("", window, cx));
+
+        let state = self.state.clone();
+        cx.spawn_in(window, async move |_, cx| {
+            state.update(cx, |state, cx| state.reset_api_key(cx))?.await
+        })
+        .detach_and_log_err(cx);
+
+        cx.notify();
+    }
+
+    fn render_api_key_editor(&self, cx: &mut Context<Self>) -> impl IntoElement {
+        let settings = ThemeSettings::get_global(cx);
+        let text_style = TextStyle {
+            color: cx.theme().colors().text,
+            font_family: settings.ui_font.family.clone(),
+            font_features: settings.ui_font.features.clone(),
+            font_fallbacks: settings.ui_font.fallbacks.clone(),
+            font_size: rems(0.875).into(),
+            font_weight: settings.ui_font.weight,
+            font_style: FontStyle::Normal,
+            line_height: relative(1.3),
+            white_space: WhiteSpace::Normal,
+            ..Default::default()
+        };
+        EditorElement::new(
+            &self.api_key_editor,
+            EditorStyle {
+                background: cx.theme().colors().editor_background,
+                local_player: cx.theme().players().local(),
+                text: text_style,
+                ..Default::default()
+            },
+        )
+    }
+
+    fn should_render_editor(&self, cx: &mut Context<Self>) -> bool {
+        !self.state.read(cx).is_authenticated()
+    }
+}
+
+impl Render for ConfigurationView {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let env_var_set = self.state.read(cx).api_key_from_env;
+
+        if self.load_credentials_task.is_some() {
+            div().child(Label::new("Loading credentials...")).into_any()
+        } else if self.should_render_editor(cx) {
+            v_flex()
+                .size_full()
+                .on_action(cx.listener(Self::save_api_key))
+                .child(Label::new("To use Zed's assistant with OpenRouter, you need to add an API key. Follow these steps:"))
+                .child(
+                    List::new()
+                        .child(InstructionListItem::new(
+                            "Create an API key by visiting",
+                            Some("OpenRouter's console"),
+                            Some("https://openrouter.ai/keys"),
+                        ))
+                        .child(InstructionListItem::text_only(
+                            "Ensure your OpenRouter account has credits",
+                        ))
+                        .child(InstructionListItem::text_only(
+                            "Paste your API key below and hit enter to start using the assistant",
+                        )),
+                )
+                .child(
+                    h_flex()
+                        .w_full()
+                        .my_2()
+                        .px_2()
+                        .py_1()
+                        .bg(cx.theme().colors().editor_background)
+                        .border_1()
+                        .border_color(cx.theme().colors().border)
+                        .rounded_sm()
+                        .child(self.render_api_key_editor(cx)),
+                )
+                .child(
+                    Label::new(
+                        format!("You can also assign the {OPENROUTER_API_KEY_VAR} environment variable and restart Zed."),
+                    )
+                    .size(LabelSize::Small).color(Color::Muted),
+                )
+                .into_any()
+        } else {
+            h_flex()
+                .mt_1()
+                .p_1()
+                .justify_between()
+                .rounded_md()
+                .border_1()
+                .border_color(cx.theme().colors().border)
+                .bg(cx.theme().colors().background)
+                .child(
+                    h_flex()
+                        .gap_1()
+                        .child(Icon::new(IconName::Check).color(Color::Success))
+                        .child(Label::new(if env_var_set {
+                            format!("API key set in {OPENROUTER_API_KEY_VAR} environment variable.")
+                        } else {
+                            "API key configured.".to_string()
+                        })),
+                )
+                .child(
+                    Button::new("reset-key", "Reset Key")
+                        .label_size(LabelSize::Small)
+                        .icon(Some(IconName::Trash))
+                        .icon_size(IconSize::Small)
+                        .icon_position(IconPosition::Start)
+                        .disabled(env_var_set)
+                        .when(env_var_set, |this| {
+                            this.tooltip(Tooltip::text(format!("To reset your API key, unset the {OPENROUTER_API_KEY_VAR} environment variable.")))
+                        })
+                        .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))),
+                )
+                .into_any()
+        }
+    }
+}

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

@@ -0,0 +1,577 @@
+use anyhow::{Context as _, Result, anyhow};
+use collections::BTreeMap;
+use credentials_provider::CredentialsProvider;
+use futures::{FutureExt, StreamExt, future::BoxFuture};
+use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task, Window};
+use http_client::HttpClient;
+use language_model::{
+    AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
+    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
+    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
+    LanguageModelToolChoice, RateLimiter, Role,
+};
+use menu;
+use open_ai::ResponseStreamEvent;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::{Settings, SettingsStore};
+use std::sync::Arc;
+use strum::IntoEnumIterator;
+use vercel::Model;
+
+use ui::{ElevationIndex, List, Tooltip, prelude::*};
+use ui_input::SingleLineInput;
+use util::ResultExt;
+
+use crate::{AllLanguageModelSettings, ui::InstructionListItem};
+
+const PROVIDER_ID: &str = "vercel";
+const PROVIDER_NAME: &str = "Vercel";
+
+#[derive(Default, Clone, Debug, PartialEq)]
+pub struct VercelSettings {
+    pub api_url: String,
+    pub available_models: Vec<AvailableModel>,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
+pub struct AvailableModel {
+    pub name: String,
+    pub display_name: Option<String>,
+    pub max_tokens: u64,
+    pub max_output_tokens: Option<u64>,
+    pub max_completion_tokens: Option<u64>,
+}
+
+pub struct VercelLanguageModelProvider {
+    http_client: Arc<dyn HttpClient>,
+    state: gpui::Entity<State>,
+}
+
+pub struct State {
+    api_key: Option<String>,
+    api_key_from_env: bool,
+    _subscription: Subscription,
+}
+
+const VERCEL_API_KEY_VAR: &str = "VERCEL_API_KEY";
+
+impl State {
+    fn is_authenticated(&self) -> bool {
+        self.api_key.is_some()
+    }
+
+    fn reset_api_key(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = <dyn CredentialsProvider>::global(cx);
+        let settings = &AllLanguageModelSettings::get_global(cx).vercel;
+        let api_url = if settings.api_url.is_empty() {
+            vercel::VERCEL_API_URL.to_string()
+        } else {
+            settings.api_url.clone()
+        };
+        cx.spawn(async move |this, cx| {
+            credentials_provider
+                .delete_credentials(&api_url, &cx)
+                .await
+                .log_err();
+            this.update(cx, |this, cx| {
+                this.api_key = None;
+                this.api_key_from_env = false;
+                cx.notify();
+            })
+        })
+    }
+
+    fn set_api_key(&mut self, api_key: String, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = <dyn CredentialsProvider>::global(cx);
+        let settings = &AllLanguageModelSettings::get_global(cx).vercel;
+        let api_url = if settings.api_url.is_empty() {
+            vercel::VERCEL_API_URL.to_string()
+        } else {
+            settings.api_url.clone()
+        };
+        cx.spawn(async move |this, cx| {
+            credentials_provider
+                .write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx)
+                .await
+                .log_err();
+            this.update(cx, |this, cx| {
+                this.api_key = Some(api_key);
+                cx.notify();
+            })
+        })
+    }
+
+    fn authenticate(&self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        if self.is_authenticated() {
+            return Task::ready(Ok(()));
+        }
+
+        let credentials_provider = <dyn CredentialsProvider>::global(cx);
+        let settings = &AllLanguageModelSettings::get_global(cx).vercel;
+        let api_url = if settings.api_url.is_empty() {
+            vercel::VERCEL_API_URL.to_string()
+        } else {
+            settings.api_url.clone()
+        };
+        cx.spawn(async move |this, cx| {
+            let (api_key, from_env) = if let Ok(api_key) = std::env::var(VERCEL_API_KEY_VAR) {
+                (api_key, true)
+            } else {
+                let (_, api_key) = credentials_provider
+                    .read_credentials(&api_url, &cx)
+                    .await?
+                    .ok_or(AuthenticateError::CredentialsNotFound)?;
+                (
+                    String::from_utf8(api_key).context("invalid {PROVIDER_NAME} API key")?,
+                    false,
+                )
+            };
+            this.update(cx, |this, cx| {
+                this.api_key = Some(api_key);
+                this.api_key_from_env = from_env;
+                cx.notify();
+            })?;
+
+            Ok(())
+        })
+    }
+}
+
+impl VercelLanguageModelProvider {
+    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+        let state = cx.new(|cx| State {
+            api_key: None,
+            api_key_from_env: false,
+            _subscription: cx.observe_global::<SettingsStore>(|_this: &mut State, cx| {
+                cx.notify();
+            }),
+        });
+
+        Self { http_client, state }
+    }
+
+    fn create_language_model(&self, model: vercel::Model) -> Arc<dyn LanguageModel> {
+        Arc::new(VercelLanguageModel {
+            id: LanguageModelId::from(model.id().to_string()),
+            model,
+            state: self.state.clone(),
+            http_client: self.http_client.clone(),
+            request_limiter: RateLimiter::new(4),
+        })
+    }
+}
+
+impl LanguageModelProviderState for VercelLanguageModelProvider {
+    type ObservableEntity = State;
+
+    fn observable_entity(&self) -> Option<gpui::Entity<Self::ObservableEntity>> {
+        Some(self.state.clone())
+    }
+}
+
+impl LanguageModelProvider for VercelLanguageModelProvider {
+    fn id(&self) -> LanguageModelProviderId {
+        LanguageModelProviderId(PROVIDER_ID.into())
+    }
+
+    fn name(&self) -> LanguageModelProviderName {
+        LanguageModelProviderName(PROVIDER_NAME.into())
+    }
+
+    fn icon(&self) -> IconName {
+        IconName::AiVZero
+    }
+
+    fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
+        Some(self.create_language_model(vercel::Model::default()))
+    }
+
+    fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
+        Some(self.create_language_model(vercel::Model::default_fast()))
+    }
+
+    fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
+        let mut models = BTreeMap::default();
+
+        for model in vercel::Model::iter() {
+            if !matches!(model, vercel::Model::Custom { .. }) {
+                models.insert(model.id().to_string(), model);
+            }
+        }
+
+        for model in &AllLanguageModelSettings::get_global(cx)
+            .vercel
+            .available_models
+        {
+            models.insert(
+                model.name.clone(),
+                vercel::Model::Custom {
+                    name: model.name.clone(),
+                    display_name: model.display_name.clone(),
+                    max_tokens: model.max_tokens,
+                    max_output_tokens: model.max_output_tokens,
+                    max_completion_tokens: model.max_completion_tokens,
+                },
+            );
+        }
+
+        models
+            .into_values()
+            .map(|model| self.create_language_model(model))
+            .collect()
+    }
+
+    fn is_authenticated(&self, cx: &App) -> bool {
+        self.state.read(cx).is_authenticated()
+    }
+
+    fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>> {
+        self.state.update(cx, |state, cx| state.authenticate(cx))
+    }
+
+    fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView {
+        cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx))
+            .into()
+    }
+
+    fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>> {
+        self.state.update(cx, |state, cx| state.reset_api_key(cx))
+    }
+}
+
+pub struct VercelLanguageModel {
+    id: LanguageModelId,
+    model: vercel::Model,
+    state: gpui::Entity<State>,
+    http_client: Arc<dyn HttpClient>,
+    request_limiter: RateLimiter,
+}
+
+impl VercelLanguageModel {
+    fn stream_completion(
+        &self,
+        request: open_ai::Request,
+        cx: &AsyncApp,
+    ) -> BoxFuture<'static, Result<futures::stream::BoxStream<'static, Result<ResponseStreamEvent>>>>
+    {
+        let http_client = self.http_client.clone();
+        let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| {
+            let settings = &AllLanguageModelSettings::get_global(cx).vercel;
+            let api_url = if settings.api_url.is_empty() {
+                vercel::VERCEL_API_URL.to_string()
+            } else {
+                settings.api_url.clone()
+            };
+            (state.api_key.clone(), api_url)
+        }) else {
+            return futures::future::ready(Err(anyhow!("App state dropped"))).boxed();
+        };
+
+        let future = self.request_limiter.stream(async move {
+            let api_key = api_key.context("Missing Vercel API Key")?;
+            let request =
+                open_ai::stream_completion(http_client.as_ref(), &api_url, &api_key, request);
+            let response = request.await?;
+            Ok(response)
+        });
+
+        async move { Ok(future.await?.boxed()) }.boxed()
+    }
+}
+
+impl LanguageModel for VercelLanguageModel {
+    fn id(&self) -> LanguageModelId {
+        self.id.clone()
+    }
+
+    fn name(&self) -> LanguageModelName {
+        LanguageModelName::from(self.model.display_name().to_string())
+    }
+
+    fn provider_id(&self) -> LanguageModelProviderId {
+        LanguageModelProviderId(PROVIDER_ID.into())
+    }
+
+    fn provider_name(&self) -> LanguageModelProviderName {
+        LanguageModelProviderName(PROVIDER_NAME.into())
+    }
+
+    fn supports_tools(&self) -> bool {
+        true
+    }
+
+    fn supports_images(&self) -> bool {
+        true
+    }
+
+    fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
+        match choice {
+            LanguageModelToolChoice::Auto
+            | LanguageModelToolChoice::Any
+            | LanguageModelToolChoice::None => true,
+        }
+    }
+
+    fn telemetry_id(&self) -> String {
+        format!("vercel/{}", self.model.id())
+    }
+
+    fn max_token_count(&self) -> u64 {
+        self.model.max_token_count()
+    }
+
+    fn max_output_tokens(&self) -> Option<u64> {
+        self.model.max_output_tokens()
+    }
+
+    fn count_tokens(
+        &self,
+        request: LanguageModelRequest,
+        cx: &App,
+    ) -> BoxFuture<'static, Result<u64>> {
+        count_vercel_tokens(request, self.model.clone(), cx)
+    }
+
+    fn stream_completion(
+        &self,
+        request: LanguageModelRequest,
+        cx: &AsyncApp,
+    ) -> BoxFuture<
+        'static,
+        Result<
+            futures::stream::BoxStream<
+                'static,
+                Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
+            >,
+            LanguageModelCompletionError,
+        >,
+    > {
+        let request = crate::provider::open_ai::into_open_ai(
+            request,
+            self.model.id(),
+            self.model.supports_parallel_tool_calls(),
+            self.max_output_tokens(),
+        );
+        let completions = self.stream_completion(request, cx);
+        async move {
+            let mapper = crate::provider::open_ai::OpenAiEventMapper::new();
+            Ok(mapper.map_stream(completions.await?).boxed())
+        }
+        .boxed()
+    }
+}
+
+pub fn count_vercel_tokens(
+    request: LanguageModelRequest,
+    model: Model,
+    cx: &App,
+) -> BoxFuture<'static, Result<u64>> {
+    cx.background_spawn(async move {
+        let messages = request
+            .messages
+            .into_iter()
+            .map(|message| tiktoken_rs::ChatCompletionRequestMessage {
+                role: match message.role {
+                    Role::User => "user".into(),
+                    Role::Assistant => "assistant".into(),
+                    Role::System => "system".into(),
+                },
+                content: Some(message.string_contents()),
+                name: None,
+                function_call: None,
+            })
+            .collect::<Vec<_>>();
+
+        match model {
+            Model::Custom { max_tokens, .. } => {
+                let model = if max_tokens >= 100_000 {
+                    // If the max tokens is 100k or more, it is likely the o200k_base tokenizer from gpt4o
+                    "gpt-4o"
+                } else {
+                    // Otherwise fallback to gpt-4, since only cl100k_base and o200k_base are
+                    // supported with this tiktoken method
+                    "gpt-4"
+                };
+                tiktoken_rs::num_tokens_from_messages(model, &messages)
+            }
+            // Map Vercel models to appropriate OpenAI models for token counting
+            // since Vercel uses OpenAI-compatible API
+            Model::VZeroOnePointFiveMedium => {
+                // Vercel v0 is similar to GPT-4o, so use gpt-4o for token counting
+                tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages)
+            }
+        }
+        .map(|tokens| tokens as u64)
+    })
+    .boxed()
+}
+
+struct ConfigurationView {
+    api_key_editor: Entity<SingleLineInput>,
+    state: gpui::Entity<State>,
+    load_credentials_task: Option<Task<()>>,
+}
+
+impl ConfigurationView {
+    fn new(state: gpui::Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+        let api_key_editor = cx.new(|cx| {
+            SingleLineInput::new(
+                window,
+                cx,
+                "v1:0000000000000000000000000000000000000000000000000",
+            )
+            .label("API key")
+        });
+
+        cx.observe(&state, |_, _, cx| {
+            cx.notify();
+        })
+        .detach();
+
+        let load_credentials_task = Some(cx.spawn_in(window, {
+            let state = state.clone();
+            async move |this, cx| {
+                if let Some(task) = state
+                    .update(cx, |state, cx| state.authenticate(cx))
+                    .log_err()
+                {
+                    // We don't log an error, because "not signed in" is also an error.
+                    let _ = task.await;
+                }
+                this.update(cx, |this, cx| {
+                    this.load_credentials_task = None;
+                    cx.notify();
+                })
+                .log_err();
+            }
+        }));
+
+        Self {
+            api_key_editor,
+            state,
+            load_credentials_task,
+        }
+    }
+
+    fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        let api_key = self
+            .api_key_editor
+            .read(cx)
+            .editor()
+            .read(cx)
+            .text(cx)
+            .trim()
+            .to_string();
+
+        // Don't proceed if no API key is provided and we're not authenticated
+        if api_key.is_empty() && !self.state.read(cx).is_authenticated() {
+            return;
+        }
+
+        let state = self.state.clone();
+        cx.spawn_in(window, async move |_, cx| {
+            state
+                .update(cx, |state, cx| state.set_api_key(api_key, cx))?
+                .await
+        })
+        .detach_and_log_err(cx);
+
+        cx.notify();
+    }
+
+    fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.api_key_editor.update(cx, |input, cx| {
+            input.editor.update(cx, |editor, cx| {
+                editor.set_text("", window, cx);
+            });
+        });
+
+        let state = self.state.clone();
+        cx.spawn_in(window, async move |_, cx| {
+            state.update(cx, |state, cx| state.reset_api_key(cx))?.await
+        })
+        .detach_and_log_err(cx);
+
+        cx.notify();
+    }
+
+    fn should_render_editor(&self, cx: &mut Context<Self>) -> bool {
+        !self.state.read(cx).is_authenticated()
+    }
+}
+
+impl Render for ConfigurationView {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let env_var_set = self.state.read(cx).api_key_from_env;
+
+        let api_key_section = if self.should_render_editor(cx) {
+            v_flex()
+                .on_action(cx.listener(Self::save_api_key))
+                .child(Label::new("To use Zed's agent with Vercel v0, you need to add an API key. Follow these steps:"))
+                .child(
+                    List::new()
+                        .child(InstructionListItem::new(
+                            "Create one by visiting",
+                            Some("Vercel v0's console"),
+                            Some("https://v0.dev/chat/settings/keys"),
+                        ))
+                        .child(InstructionListItem::text_only(
+                            "Paste your API key below and hit enter to start using the agent",
+                        )),
+                )
+                .child(self.api_key_editor.clone())
+                .child(
+                    Label::new(format!(
+                        "You can also assign the {VERCEL_API_KEY_VAR} environment variable and restart Zed."
+                    ))
+                    .size(LabelSize::Small)
+                    .color(Color::Muted),
+                )
+                .child(
+                    Label::new("Note that Vercel v0 is a custom OpenAI-compatible provider.")
+                        .size(LabelSize::Small)
+                        .color(Color::Muted),
+                )
+                .into_any()
+        } else {
+            h_flex()
+                .mt_1()
+                .p_1()
+                .justify_between()
+                .rounded_md()
+                .border_1()
+                .border_color(cx.theme().colors().border)
+                .bg(cx.theme().colors().background)
+                .child(
+                    h_flex()
+                        .gap_1()
+                        .child(Icon::new(IconName::Check).color(Color::Success))
+                        .child(Label::new(if env_var_set {
+                            format!("API key set in {VERCEL_API_KEY_VAR} environment variable.")
+                        } else {
+                            "API key configured.".to_string()
+                        })),
+                )
+                .child(
+                    Button::new("reset-api-key", "Reset API Key")
+                        .label_size(LabelSize::Small)
+                        .icon(IconName::Undo)
+                        .icon_size(IconSize::Small)
+                        .icon_position(IconPosition::Start)
+                        .layer(ElevationIndex::ModalSurface)
+                        .when(env_var_set, |this| {
+                            this.tooltip(Tooltip::text(format!("To reset your API key, unset the {VERCEL_API_KEY_VAR} environment variable.")))
+                        })
+                        .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))),
+                )
+                .into_any()
+        };
+
+        if self.load_credentials_task.is_some() {
+            div().child(Label::new("Loading credentials…")).into_any()
+        } else {
+            v_flex().size_full().child(api_key_section).into_any()
+        }
+    }
+}

crates/language_models/src/settings.rs 🔗

@@ -1,58 +1,27 @@
-use std::sync::Arc;
-
 use anyhow::Result;
 use gpui::App;
-use language_model::LanguageModelCacheConfiguration;
-use project::Fs;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources, update_settings_file};
+use settings::{Settings, SettingsSources};
 
 use crate::provider::{
     self,
     anthropic::AnthropicSettings,
     bedrock::AmazonBedrockSettings,
     cloud::{self, ZedDotDevSettings},
-    copilot_chat::CopilotChatSettings,
     deepseek::DeepSeekSettings,
     google::GoogleSettings,
     lmstudio::LmStudioSettings,
     mistral::MistralSettings,
     ollama::OllamaSettings,
     open_ai::OpenAiSettings,
+    open_router::OpenRouterSettings,
+    vercel::VercelSettings,
 };
 
 /// Initializes the language model settings.
-pub fn init(fs: Arc<dyn Fs>, cx: &mut App) {
+pub fn init(cx: &mut App) {
     AllLanguageModelSettings::register(cx);
-
-    if AllLanguageModelSettings::get_global(cx)
-        .openai
-        .needs_setting_migration
-    {
-        update_settings_file::<AllLanguageModelSettings>(fs.clone(), cx, move |setting, _| {
-            if let Some(settings) = setting.openai.clone() {
-                let (newest_version, _) = settings.upgrade();
-                setting.openai = Some(OpenAiSettingsContent::Versioned(
-                    VersionedOpenAiSettingsContent::V1(newest_version),
-                ));
-            }
-        });
-    }
-
-    if AllLanguageModelSettings::get_global(cx)
-        .anthropic
-        .needs_setting_migration
-    {
-        update_settings_file::<AllLanguageModelSettings>(fs, cx, move |setting, _| {
-            if let Some(settings) = setting.anthropic.clone() {
-                let (newest_version, _) = settings.upgrade();
-                setting.anthropic = Some(AnthropicSettingsContent::Versioned(
-                    VersionedAnthropicSettingsContent::V1(newest_version),
-                ));
-            }
-        });
-    }
 }
 
 #[derive(Default)]
@@ -61,9 +30,11 @@ pub struct AllLanguageModelSettings {
     pub bedrock: AmazonBedrockSettings,
     pub ollama: OllamaSettings,
     pub openai: OpenAiSettings,
+    pub open_router: OpenRouterSettings,
     pub zed_dot_dev: ZedDotDevSettings,
     pub google: GoogleSettings,
-    pub copilot_chat: CopilotChatSettings,
+    pub vercel: VercelSettings,
+
     pub lmstudio: LmStudioSettings,
     pub deepseek: DeepSeekSettings,
     pub mistral: MistralSettings,
@@ -76,87 +47,18 @@ pub struct AllLanguageModelSettingsContent {
     pub ollama: Option<OllamaSettingsContent>,
     pub lmstudio: Option<LmStudioSettingsContent>,
     pub openai: Option<OpenAiSettingsContent>,
+    pub open_router: Option<OpenRouterSettingsContent>,
     #[serde(rename = "zed.dev")]
     pub zed_dot_dev: Option<ZedDotDevSettingsContent>,
     pub google: Option<GoogleSettingsContent>,
     pub deepseek: Option<DeepseekSettingsContent>,
-    pub copilot_chat: Option<CopilotChatSettingsContent>,
-    pub mistral: Option<MistralSettingsContent>,
-}
+    pub vercel: Option<VercelSettingsContent>,
 
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
-#[serde(untagged)]
-pub enum AnthropicSettingsContent {
-    Versioned(VersionedAnthropicSettingsContent),
-    Legacy(LegacyAnthropicSettingsContent),
-}
-
-impl AnthropicSettingsContent {
-    pub fn upgrade(self) -> (AnthropicSettingsContentV1, bool) {
-        match self {
-            AnthropicSettingsContent::Legacy(content) => (
-                AnthropicSettingsContentV1 {
-                    api_url: content.api_url,
-                    available_models: content.available_models.map(|models| {
-                        models
-                            .into_iter()
-                            .filter_map(|model| match model {
-                                anthropic::Model::Custom {
-                                    name,
-                                    display_name,
-                                    max_tokens,
-                                    tool_override,
-                                    cache_configuration,
-                                    max_output_tokens,
-                                    default_temperature,
-                                    extra_beta_headers,
-                                    mode,
-                                } => Some(provider::anthropic::AvailableModel {
-                                    name,
-                                    display_name,
-                                    max_tokens,
-                                    tool_override,
-                                    cache_configuration: cache_configuration.as_ref().map(
-                                        |config| LanguageModelCacheConfiguration {
-                                            max_cache_anchors: config.max_cache_anchors,
-                                            should_speculate: config.should_speculate,
-                                            min_total_token: config.min_total_token,
-                                        },
-                                    ),
-                                    max_output_tokens,
-                                    default_temperature,
-                                    extra_beta_headers,
-                                    mode: Some(mode.into()),
-                                }),
-                                _ => None,
-                            })
-                            .collect()
-                    }),
-                },
-                true,
-            ),
-            AnthropicSettingsContent::Versioned(content) => match content {
-                VersionedAnthropicSettingsContent::V1(content) => (content, false),
-            },
-        }
-    }
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
-pub struct LegacyAnthropicSettingsContent {
-    pub api_url: Option<String>,
-    pub available_models: Option<Vec<anthropic::Model>>,
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
-#[serde(tag = "version")]
-pub enum VersionedAnthropicSettingsContent {
-    #[serde(rename = "1")]
-    V1(AnthropicSettingsContentV1),
+    pub mistral: Option<MistralSettingsContent>,
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
-pub struct AnthropicSettingsContentV1 {
+pub struct AnthropicSettingsContent {
     pub api_url: Option<String>,
     pub available_models: Option<Vec<provider::anthropic::AvailableModel>>,
 }
@@ -195,66 +97,15 @@ pub struct MistralSettingsContent {
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
-#[serde(untagged)]
-pub enum OpenAiSettingsContent {
-    Versioned(VersionedOpenAiSettingsContent),
-    Legacy(LegacyOpenAiSettingsContent),
-}
-
-impl OpenAiSettingsContent {
-    pub fn upgrade(self) -> (OpenAiSettingsContentV1, bool) {
-        match self {
-            OpenAiSettingsContent::Legacy(content) => (
-                OpenAiSettingsContentV1 {
-                    api_url: content.api_url,
-                    available_models: content.available_models.map(|models| {
-                        models
-                            .into_iter()
-                            .filter_map(|model| match model {
-                                open_ai::Model::Custom {
-                                    name,
-                                    display_name,
-                                    max_tokens,
-                                    max_output_tokens,
-                                    max_completion_tokens,
-                                } => Some(provider::open_ai::AvailableModel {
-                                    name,
-                                    max_tokens,
-                                    max_output_tokens,
-                                    display_name,
-                                    max_completion_tokens,
-                                }),
-                                _ => None,
-                            })
-                            .collect()
-                    }),
-                },
-                true,
-            ),
-            OpenAiSettingsContent::Versioned(content) => match content {
-                VersionedOpenAiSettingsContent::V1(content) => (content, false),
-            },
-        }
-    }
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
-pub struct LegacyOpenAiSettingsContent {
+pub struct OpenAiSettingsContent {
     pub api_url: Option<String>,
-    pub available_models: Option<Vec<open_ai::Model>>,
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
-#[serde(tag = "version")]
-pub enum VersionedOpenAiSettingsContent {
-    #[serde(rename = "1")]
-    V1(OpenAiSettingsContentV1),
+    pub available_models: Option<Vec<provider::open_ai::AvailableModel>>,
 }
 
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
-pub struct OpenAiSettingsContentV1 {
+#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
+pub struct VercelSettingsContent {
     pub api_url: Option<String>,
-    pub available_models: Option<Vec<provider::open_ai::AvailableModel>>,
+    pub available_models: Option<Vec<provider::vercel::AvailableModel>>,
 }
 
 #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
@@ -269,7 +120,10 @@ pub struct ZedDotDevSettingsContent {
 }
 
 #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
-pub struct CopilotChatSettingsContent {}
+pub struct OpenRouterSettingsContent {
+    pub api_url: Option<String>,
+    pub available_models: Option<Vec<provider::open_router::AvailableModel>>,
+}
 
 impl settings::Settings for AllLanguageModelSettings {
     const KEY: Option<&'static str> = Some("language_models");
@@ -289,15 +143,7 @@ impl settings::Settings for AllLanguageModelSettings {
 
         for value in sources.defaults_and_customizations() {
             // Anthropic
-            let (anthropic, upgraded) = match value.anthropic.clone().map(|s| s.upgrade()) {
-                Some((content, upgraded)) => (Some(content), upgraded),
-                None => (None, false),
-            };
-
-            if upgraded {
-                settings.anthropic.needs_setting_migration = true;
-            }
-
+            let anthropic = value.anthropic.clone();
             merge(
                 &mut settings.anthropic.api_url,
                 anthropic.as_ref().and_then(|s| s.api_url.clone()),
@@ -363,15 +209,7 @@ impl settings::Settings for AllLanguageModelSettings {
             );
 
             // OpenAI
-            let (openai, upgraded) = match value.openai.clone().map(|s| s.upgrade()) {
-                Some((content, upgraded)) => (Some(content), upgraded),
-                None => (None, false),
-            };
-
-            if upgraded {
-                settings.openai.needs_setting_migration = true;
-            }
-
+            let openai = value.openai.clone();
             merge(
                 &mut settings.openai.api_url,
                 openai.as_ref().and_then(|s| s.api_url.clone()),
@@ -380,6 +218,18 @@ impl settings::Settings for AllLanguageModelSettings {
                 &mut settings.openai.available_models,
                 openai.as_ref().and_then(|s| s.available_models.clone()),
             );
+
+            // Vercel
+            let vercel = value.vercel.clone();
+            merge(
+                &mut settings.vercel.api_url,
+                vercel.as_ref().and_then(|s| s.api_url.clone()),
+            );
+            merge(
+                &mut settings.vercel.available_models,
+                vercel.as_ref().and_then(|s| s.available_models.clone()),
+            );
+
             merge(
                 &mut settings.zed_dot_dev.available_models,
                 value
@@ -409,6 +259,19 @@ impl settings::Settings for AllLanguageModelSettings {
                 &mut settings.mistral.available_models,
                 mistral.as_ref().and_then(|s| s.available_models.clone()),
             );
+
+            // OpenRouter
+            let open_router = value.open_router.clone();
+            merge(
+                &mut settings.open_router.api_url,
+                open_router.as_ref().and_then(|s| s.api_url.clone()),
+            );
+            merge(
+                &mut settings.open_router.available_models,
+                open_router
+                    .as_ref()
+                    .and_then(|s| s.available_models.clone()),
+            );
         }
 
         Ok(settings)

crates/language_models/src/ui/instruction_list_item.rs 🔗

@@ -38,29 +38,32 @@ impl IntoElement for InstructionListItem {
             (self.button_label, self.button_link)
         {
             let link = button_link.clone();
-            h_flex().flex_wrap().child(Label::new(self.label)).child(
-                Button::new("link-button", button_label)
-                    .style(ButtonStyle::Subtle)
-                    .icon(IconName::ArrowUpRight)
-                    .icon_size(IconSize::XSmall)
-                    .icon_color(Color::Muted)
-                    .on_click(move |_, _window, cx| cx.open_url(&link)),
-            )
+            let unique_id = SharedString::from(format!("{}-button", self.label));
+
+            h_flex()
+                .flex_wrap()
+                .child(Label::new(self.label))
+                .child(
+                    Button::new(unique_id, button_label)
+                        .style(ButtonStyle::Subtle)
+                        .icon(IconName::ArrowUpRight)
+                        .icon_size(IconSize::XSmall)
+                        .icon_color(Color::Muted)
+                        .on_click(move |_, _window, cx| cx.open_url(&link)),
+                )
+                .into_any_element()
         } else {
-            div().child(Label::new(self.label))
+            Label::new(self.label).into_any_element()
         };
 
-        div()
-            .child(
-                ListItem::new("list-item")
-                    .selectable(false)
-                    .start_slot(
-                        Icon::new(IconName::Dash)
-                            .size(IconSize::XSmall)
-                            .color(Color::Hidden),
-                    )
-                    .child(item_content),
+        ListItem::new("list-item")
+            .selectable(false)
+            .start_slot(
+                Icon::new(IconName::Dash)
+                    .size(IconSize::XSmall)
+                    .color(Color::Hidden),
             )
-            .into_any()
+            .child(div().w_full().child(item_content))
+            .into_any_element()
     }
 }

crates/language_selector/src/language_selector.rs 🔗

@@ -1,7 +1,7 @@
 mod active_buffer_language;
 
 pub use active_buffer_language::ActiveBufferLanguage;
-use anyhow::anyhow;
+use anyhow::Context as _;
 use editor::Editor;
 use file_finder::file_finder_settings::FileFinderSettings;
 use file_icons::FileIcons;
@@ -192,12 +192,8 @@ impl PickerDelegate for LanguageSelectorDelegate {
             let buffer = self.buffer.downgrade();
             cx.spawn_in(window, async move |_, cx| {
                 let language = language.await?;
-                let project = project
-                    .upgrade()
-                    .ok_or_else(|| anyhow!("project was dropped"))?;
-                let buffer = buffer
-                    .upgrade()
-                    .ok_or_else(|| anyhow!("buffer was dropped"))?;
+                let project = project.upgrade().context("project was dropped")?;
+                let buffer = buffer.upgrade().context("buffer was dropped")?;
                 project.update(cx, |project, cx| {
                     project.set_language_for_buffer(&buffer, language, cx);
                 })
@@ -251,6 +247,7 @@ impl PickerDelegate for LanguageSelectorDelegate {
                     &candidates,
                     &query,
                     false,
+                    true,
                     100,
                     &Default::default(),
                     background,

crates/language_tools/Cargo.toml 🔗

@@ -14,28 +14,31 @@ doctest = false
 
 [dependencies]
 anyhow.workspace = true
+client.workspace = true
 collections.workspace = true
 copilot.workspace = true
 editor.workspace = true
+feature_flags.workspace = true
 futures.workspace = true
 gpui.workspace = true
 itertools.workspace = true
 language.workspace = true
 lsp.workspace = true
+picker.workspace = true
 project.workspace = true
 serde_json.workspace = true
 settings.workspace = true
 theme.workspace = true
 tree-sitter.workspace = true
 ui.workspace = true
+util.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
 workspace-hack.workspace = true
 
 [dev-dependencies]
-client = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
 release_channel.workspace = true
-env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 util = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/language_tools/src/key_context_view.rs 🔗

@@ -13,7 +13,7 @@ use ui::{
 };
 use workspace::{Item, SplitDirection, Workspace};
 
-actions!(debug, [OpenKeyContextView]);
+actions!(dev, [OpenKeyContextView]);
 
 pub fn init(cx: &mut App) {
     cx.observe_new(|workspace: &mut Workspace, _, _| {

crates/language_tools/src/language_tools.rs 🔗

@@ -1,17 +1,54 @@
 mod key_context_view;
 mod lsp_log;
+pub mod lsp_tool;
 mod syntax_tree_view;
 
 #[cfg(test)]
 mod lsp_log_tests;
 
-use gpui::App;
+use gpui::{App, AppContext, Entity};
 
 pub use lsp_log::{LogStore, LspLogToolbarItemView, LspLogView};
 pub use syntax_tree_view::{SyntaxTreeToolbarItemView, SyntaxTreeView};
+use ui::{Context, Window};
+use workspace::{Item, ItemHandle, SplitDirection, Workspace};
 
 pub fn init(cx: &mut App) {
     lsp_log::init(cx);
     syntax_tree_view::init(cx);
     key_context_view::init(cx);
 }
+
+fn get_or_create_tool<T>(
+    workspace: &mut Workspace,
+    destination: SplitDirection,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+    new_tool: impl FnOnce(&mut Window, &mut Context<T>) -> T,
+) -> Entity<T>
+where
+    T: Item,
+{
+    if let Some(item) = workspace.item_of_type::<T>(cx) {
+        return item;
+    }
+
+    let new_tool = cx.new(|cx| new_tool(window, cx));
+    match workspace.find_pane_in_direction(destination, cx) {
+        Some(right_pane) => {
+            workspace.add_item(
+                right_pane,
+                new_tool.boxed_clone(),
+                None,
+                true,
+                true,
+                window,
+                cx,
+            );
+        }
+        None => {
+            workspace.split_item(destination, new_tool.boxed_clone(), window, cx);
+        }
+    }
+    new_tool
+}

crates/language_tools/src/lsp_log.rs 🔗

@@ -3,13 +3,14 @@ use copilot::Copilot;
 use editor::{Editor, EditorEvent, actions::MoveToEnd, scroll::Autoscroll};
 use futures::{StreamExt, channel::mpsc};
 use gpui::{
-    AnyView, App, Context, Corner, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
-    ParentElement, Render, Styled, Subscription, WeakEntity, Window, actions, div,
+    AnyView, App, Context, Corner, Entity, EventEmitter, FocusHandle, Focusable, Global,
+    IntoElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window, actions, div,
 };
+use itertools::Itertools;
 use language::{LanguageServerId, language_settings::SoftWrap};
 use lsp::{
-    IoKind, LanguageServer, LanguageServerName, MessageType, SetTraceParams, TraceValue,
-    notification::SetTrace,
+    IoKind, LanguageServer, LanguageServerName, LanguageServerSelector, MessageType,
+    SetTraceParams, TraceValue, notification::SetTrace,
 };
 use project::{Project, WorktreeId, search::SearchQuery};
 use std::{any::TypeId, borrow::Cow, sync::Arc};
@@ -20,8 +21,10 @@ use workspace::{
     searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
 };
 
-const SEND_LINE: &str = "// Send:";
-const RECEIVE_LINE: &str = "// Receive:";
+use crate::get_or_create_tool;
+
+const SEND_LINE: &str = "\n// Send:";
+const RECEIVE_LINE: &str = "\n// Receive:";
 const MAX_STORED_LOG_ENTRIES: usize = 2000;
 
 pub struct LogStore {
@@ -43,7 +46,7 @@ trait Message: AsRef<str> {
     }
 }
 
-struct LogMessage {
+pub(super) struct LogMessage {
     message: String,
     typ: MessageType,
 }
@@ -70,7 +73,7 @@ impl Message for LogMessage {
     }
 }
 
-struct TraceMessage {
+pub(super) struct TraceMessage {
     message: String,
 }
 
@@ -98,7 +101,7 @@ impl Message for RpcMessage {
     type Level = ();
 }
 
-struct LanguageServerState {
+pub(super) struct LanguageServerState {
     name: Option<LanguageServerName>,
     worktree_id: Option<WorktreeId>,
     kind: LanguageServerKind,
@@ -201,10 +204,15 @@ pub(crate) struct LogMenuItem {
     pub server_kind: LanguageServerKind,
 }
 
-actions!(debug, [OpenLanguageServerLogs]);
+actions!(dev, [OpenLanguageServerLogs]);
+
+pub(super) struct GlobalLogStore(pub WeakEntity<LogStore>);
+
+impl Global for GlobalLogStore {}
 
 pub fn init(cx: &mut App) {
     let log_store = cx.new(LogStore::new);
+    cx.set_global(GlobalLogStore(log_store.downgrade()));
 
     cx.observe_new(move |workspace: &mut Workspace, _, cx| {
         let project = workspace.project();
@@ -218,13 +226,14 @@ pub fn init(cx: &mut App) {
         workspace.register_action(move |workspace, _: &OpenLanguageServerLogs, window, cx| {
             let project = workspace.project().read(cx);
             if project.is_local() || project.is_via_ssh() {
-                workspace.split_item(
+                let project = workspace.project().clone();
+                let log_store = log_store.clone();
+                get_or_create_tool(
+                    workspace,
                     SplitDirection::Right,
-                    Box::new(cx.new(|cx| {
-                        LspLogView::new(workspace.project().clone(), log_store.clone(), window, cx)
-                    })),
                     window,
                     cx,
+                    move |window, cx| LspLogView::new(project, log_store, window, cx),
                 );
             }
         });
@@ -353,7 +362,7 @@ impl LogStore {
         );
     }
 
-    fn get_language_server_state(
+    pub(super) fn get_language_server_state(
         &mut self,
         id: LanguageServerId,
     ) -> Option<&mut LanguageServerState> {
@@ -444,7 +453,7 @@ impl LogStore {
             log_lines,
             id,
             TraceMessage {
-                message: message.trim_end().to_string(),
+                message: message.trim().to_string(),
             },
             (),
             LogKind::Trace,
@@ -461,16 +470,15 @@ impl LogStore {
         kind: LogKind,
         cx: &mut Context<Self>,
     ) {
-        while log_lines.len() >= MAX_STORED_LOG_ENTRIES {
+        while log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES {
             log_lines.pop_front();
         }
-        let entry: &str = message.as_ref();
-        let entry = entry.to_string();
+        let text = message.as_ref().to_string();
         let visible = message.should_include(current_severity);
         log_lines.push_back(message);
 
         if visible {
-            cx.emit(Event::NewServerLogEntry { id, entry, kind });
+            cx.emit(Event::NewServerLogEntry { id, kind, text });
             cx.notify();
         }
     }
@@ -480,11 +488,14 @@ impl LogStore {
         cx.notify();
     }
 
-    fn server_logs(&self, server_id: LanguageServerId) -> Option<&VecDeque<LogMessage>> {
+    pub(super) fn server_logs(&self, server_id: LanguageServerId) -> Option<&VecDeque<LogMessage>> {
         Some(&self.language_servers.get(&server_id)?.log_messages)
     }
 
-    fn server_trace(&self, server_id: LanguageServerId) -> Option<&VecDeque<TraceMessage>> {
+    pub(super) fn server_trace(
+        &self,
+        server_id: LanguageServerId,
+    ) -> Option<&VecDeque<TraceMessage>> {
         Some(&self.language_servers.get(&server_id)?.trace_messages)
     }
 
@@ -529,6 +540,110 @@ impl LogStore {
         Some(())
     }
 
+    pub fn has_server_logs(&self, server: &LanguageServerSelector) -> bool {
+        match server {
+            LanguageServerSelector::Id(id) => self.language_servers.contains_key(id),
+            LanguageServerSelector::Name(name) => self
+                .language_servers
+                .iter()
+                .any(|(_, state)| state.name.as_ref() == Some(name)),
+        }
+    }
+
+    pub fn open_server_log(
+        &mut self,
+        workspace: WeakEntity<Workspace>,
+        server: LanguageServerSelector,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        cx.spawn_in(window, async move |log_store, cx| {
+            let Some(log_store) = log_store.upgrade() else {
+                return;
+            };
+            workspace
+                .update_in(cx, |workspace, window, cx| {
+                    let project = workspace.project().clone();
+                    let tool_log_store = log_store.clone();
+                    let log_view = get_or_create_tool(
+                        workspace,
+                        SplitDirection::Right,
+                        window,
+                        cx,
+                        move |window, cx| LspLogView::new(project, tool_log_store, window, cx),
+                    );
+                    log_view.update(cx, |log_view, cx| {
+                        let server_id = match server {
+                            LanguageServerSelector::Id(id) => Some(id),
+                            LanguageServerSelector::Name(name) => {
+                                log_store.read(cx).language_servers.iter().find_map(
+                                    |(id, state)| {
+                                        if state.name.as_ref() == Some(&name) {
+                                            Some(*id)
+                                        } else {
+                                            None
+                                        }
+                                    },
+                                )
+                            }
+                        };
+                        if let Some(server_id) = server_id {
+                            log_view.show_logs_for_server(server_id, window, cx);
+                        }
+                    });
+                })
+                .ok();
+        })
+        .detach();
+    }
+
+    pub fn open_server_trace(
+        &mut self,
+        workspace: WeakEntity<Workspace>,
+        server: LanguageServerSelector,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        cx.spawn_in(window, async move |log_store, cx| {
+            let Some(log_store) = log_store.upgrade() else {
+                return;
+            };
+            workspace
+                .update_in(cx, |workspace, window, cx| {
+                    let project = workspace.project().clone();
+                    let tool_log_store = log_store.clone();
+                    let log_view = get_or_create_tool(
+                        workspace,
+                        SplitDirection::Right,
+                        window,
+                        cx,
+                        move |window, cx| LspLogView::new(project, tool_log_store, window, cx),
+                    );
+                    log_view.update(cx, |log_view, cx| {
+                        let server_id = match server {
+                            LanguageServerSelector::Id(id) => Some(id),
+                            LanguageServerSelector::Name(name) => {
+                                log_store.read(cx).language_servers.iter().find_map(
+                                    |(id, state)| {
+                                        if state.name.as_ref() == Some(&name) {
+                                            Some(*id)
+                                        } else {
+                                            None
+                                        }
+                                    },
+                                )
+                            }
+                        };
+                        if let Some(server_id) = server_id {
+                            log_view.show_rpc_trace_for_server(server_id, window, cx);
+                        }
+                    });
+                })
+                .ok();
+        })
+        .detach();
+    }
+
     fn on_io(
         &mut self,
         language_server_id: LanguageServerId,
@@ -557,6 +672,9 @@ impl LogStore {
 
         let rpc_log_lines = &mut state.rpc_messages;
         if state.last_message_kind != Some(kind) {
+            while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES {
+                rpc_log_lines.pop_front();
+            }
             let line_before_message = match kind {
                 MessageKind::Send => SEND_LINE,
                 MessageKind::Receive => RECEIVE_LINE,
@@ -566,22 +684,23 @@ impl LogStore {
             });
             cx.emit(Event::NewServerLogEntry {
                 id: language_server_id,
-                entry: line_before_message.to_string(),
                 kind: LogKind::Rpc,
+                text: line_before_message.to_string(),
             });
         }
 
-        while rpc_log_lines.len() >= MAX_STORED_LOG_ENTRIES {
+        while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES {
             rpc_log_lines.pop_front();
         }
+
         let message = message.trim();
         rpc_log_lines.push_back(RpcMessage {
             message: message.to_string(),
         });
         cx.emit(Event::NewServerLogEntry {
             id: language_server_id,
-            entry: message.to_string(),
             kind: LogKind::Rpc,
+            text: message.to_string(),
         });
         cx.notify();
         Some(())
@@ -635,30 +754,37 @@ impl LspLogView {
             &log_store,
             window,
             move |log_view, _, e, window, cx| match e {
-                Event::NewServerLogEntry { id, entry, kind } => {
+                Event::NewServerLogEntry { id, kind, text } => {
                     if log_view.current_server_id == Some(*id)
                         && *kind == log_view.active_entry_kind
                     {
                         log_view.editor.update(cx, |editor, cx| {
                             editor.set_read_only(false);
-                            let last_point = editor.buffer().read(cx).len(cx);
+                            let last_offset = editor.buffer().read(cx).len(cx);
                             let newest_cursor_is_at_end =
-                                editor.selections.newest::<usize>(cx).start >= last_point;
+                                editor.selections.newest::<usize>(cx).start >= last_offset;
                             editor.edit(
                                 vec![
-                                    (last_point..last_point, entry.trim()),
-                                    (last_point..last_point, "\n"),
+                                    (last_offset..last_offset, text.as_str()),
+                                    (last_offset..last_offset, "\n"),
                                 ],
                                 cx,
                             );
-                            let entry_length = entry.len();
-                            if entry_length > 1024 {
-                                editor.fold_ranges(
-                                    vec![last_point + 1024..last_point + entry_length],
-                                    false,
-                                    window,
-                                    cx,
-                                );
+                            if text.len() > 1024 {
+                                if let Some((fold_offset, _)) =
+                                    text.char_indices().dropping(1024).next()
+                                {
+                                    if fold_offset < text.len() {
+                                        editor.fold_ranges(
+                                            vec![
+                                                last_offset + fold_offset..last_offset + text.len(),
+                                            ],
+                                            false,
+                                            window,
+                                            cx,
+                                        );
+                                    }
+                                }
                             }
 
                             if newest_cursor_is_at_end {
@@ -702,15 +828,7 @@ impl LspLogView {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> (Entity<Editor>, Vec<Subscription>) {
-        let editor = cx.new(|cx| {
-            let mut editor = Editor::multi_line(window, cx);
-            editor.set_text(log_contents, window, cx);
-            editor.move_to_end(&MoveToEnd, window, cx);
-            editor.set_read_only(true);
-            editor.set_show_edit_predictions(Some(false), window, cx);
-            editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
-            editor
-        });
+        let editor = initialize_new_editor(log_contents, true, window, cx);
         let editor_subscription = cx.subscribe(
             &editor,
             |_, _, event: &EditorEvent, cx: &mut Context<LspLogView>| cx.emit(event.clone()),
@@ -727,10 +845,8 @@ impl LspLogView {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> (Entity<Editor>, Vec<Subscription>) {
-        let editor = cx.new(|cx| {
-            let mut editor = Editor::multi_line(window, cx);
-            let server_info = format!(
-                "* Server: {NAME} (id {ID})
+        let server_info = format!(
+            "* Server: {NAME} (id {ID})
 
 * Binary: {BINARY:#?}
 
@@ -740,29 +856,24 @@ impl LspLogView {
 * Capabilities: {CAPABILITIES}
 
 * Configuration: {CONFIGURATION}",
-                NAME = server.name(),
-                ID = server.server_id(),
-                BINARY = server.binary(),
-                WORKSPACE_FOLDERS = server
-                    .workspace_folders()
-                    .iter()
-                    .filter_map(|path| path
-                        .to_file_path()
-                        .ok()
-                        .map(|path| path.to_string_lossy().into_owned()))
-                    .collect::<Vec<_>>()
-                    .join(", "),
-                CAPABILITIES = serde_json::to_string_pretty(&server.capabilities())
-                    .unwrap_or_else(|e| format!("Failed to serialize capabilities: {e}")),
-                CONFIGURATION = serde_json::to_string_pretty(server.configuration())
-                    .unwrap_or_else(|e| format!("Failed to serialize configuration: {e}")),
-            );
-            editor.set_text(server_info, window, cx);
-            editor.set_read_only(true);
-            editor.set_show_edit_predictions(Some(false), window, cx);
-            editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
-            editor
-        });
+            NAME = server.name(),
+            ID = server.server_id(),
+            BINARY = server.binary(),
+            WORKSPACE_FOLDERS = server
+                .workspace_folders()
+                .iter()
+                .filter_map(|path| path
+                    .to_file_path()
+                    .ok()
+                    .map(|path| path.to_string_lossy().into_owned()))
+                .collect::<Vec<_>>()
+                .join(", "),
+            CAPABILITIES = serde_json::to_string_pretty(&server.capabilities())
+                .unwrap_or_else(|e| format!("Failed to serialize capabilities: {e}")),
+            CONFIGURATION = serde_json::to_string_pretty(server.configuration())
+                .unwrap_or_else(|e| format!("Failed to serialize configuration: {e}")),
+        );
+        let editor = initialize_new_editor(server_info, false, window, cx);
         let editor_subscription = cx.subscribe(
             &editor,
             |_, _, event: &EditorEvent, cx: &mut Context<LspLogView>| cx.emit(event.clone()),
@@ -842,9 +953,10 @@ impl LspLogView {
     ) {
         let typ = self
             .log_store
-            .read_with(cx, |v, _| {
-                v.language_servers.get(&server_id).map(|v| v.log_level)
-            })
+            .read(cx)
+            .language_servers
+            .get(&server_id)
+            .map(|v| v.log_level)
             .unwrap_or(MessageType::LOG);
         let log_contents = self
             .log_store
@@ -859,7 +971,7 @@ impl LspLogView {
             self.editor_subscriptions = editor_subscriptions;
             cx.notify();
         }
-        window.focus(&self.focus_handle);
+        self.editor.read(cx).focus_handle(cx).focus(window);
     }
 
     fn update_log_level(
@@ -885,7 +997,7 @@ impl LspLogView {
             cx.notify();
         }
 
-        window.focus(&self.focus_handle);
+        self.editor.read(cx).focus_handle(cx).focus(window);
     }
 
     fn show_trace_for_server(
@@ -907,7 +1019,7 @@ impl LspLogView {
             self.editor_subscriptions = editor_subscriptions;
             cx.notify();
         }
-        window.focus(&self.focus_handle);
+        self.editor.read(cx).focus_handle(cx).focus(window);
     }
 
     fn show_rpc_trace_for_server(
@@ -950,7 +1062,7 @@ impl LspLogView {
             cx.notify();
         }
 
-        window.focus(&self.focus_handle);
+        self.editor.read(cx).focus_handle(cx).focus(window);
     }
 
     fn toggle_rpc_trace_for_server(
@@ -1014,27 +1126,16 @@ impl LspLogView {
         self.editor = editor;
         self.editor_subscriptions = editor_subscriptions;
         cx.notify();
-        window.focus(&self.focus_handle);
-    }
-}
-
-fn log_filter<T: Message>(line: &T, cmp: <T as Message>::Level) -> Option<&str> {
-    if line.should_include(cmp) {
-        Some(line.as_ref())
-    } else {
-        None
+        self.editor.read(cx).focus_handle(cx).focus(window);
     }
 }
 
-fn log_contents<T: Message>(lines: &VecDeque<T>, cmp: <T as Message>::Level) -> String {
-    let (a, b) = lines.as_slices();
-    let a = a.iter().filter_map(move |v| log_filter(v, cmp));
-    let b = b.iter().filter_map(move |v| log_filter(v, cmp));
-    a.chain(b).fold(String::new(), |mut acc, el| {
-        acc.push_str(el);
-        acc.push('\n');
-        acc
-    })
+fn log_contents<T: Message>(lines: &VecDeque<T>, level: <T as Message>::Level) -> String {
+    lines
+        .iter()
+        .filter(|message| message.should_include(level))
+        .flat_map(|message| [message.as_ref(), "\n"])
+        .collect()
 }
 
 impl Render for LspLogView {
@@ -1238,12 +1339,12 @@ impl Render for LspLogToolbarItemView {
             }
         });
         let available_language_servers: Vec<_> = menu_rows
-            .iter()
+            .into_iter()
             .map(|row| {
                 (
                     row.server_id,
-                    row.server_name.clone(),
-                    row.worktree_root_name.clone(),
+                    row.server_name,
+                    row.worktree_root_name,
                     row.selected_entry,
                 )
             })
@@ -1549,6 +1650,29 @@ impl Render for LspLogToolbarItemView {
     }
 }
 
+fn initialize_new_editor(
+    content: String,
+    move_to_end: bool,
+    window: &mut Window,
+    cx: &mut App,
+) -> Entity<Editor> {
+    cx.new(|cx| {
+        let mut editor = Editor::multi_line(window, cx);
+        editor.hide_minimap_by_default(window, cx);
+        editor.set_text(content, window, cx);
+        editor.set_show_git_diff_gutter(false, cx);
+        editor.set_show_runnables(false, cx);
+        editor.set_show_breakpoints(false, cx);
+        editor.set_read_only(true);
+        editor.set_show_edit_predictions(Some(false), window, cx);
+        editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
+        if move_to_end {
+            editor.move_to_end(&MoveToEnd, window, cx);
+        }
+        editor
+    })
+}
+
 const RPC_MESSAGES: &str = "RPC Messages";
 const SERVER_LOGS: &str = "Server Logs";
 const SERVER_TRACE: &str = "Server Trace";
@@ -1595,8 +1719,8 @@ impl LspLogToolbarItemView {
 pub enum Event {
     NewServerLogEntry {
         id: LanguageServerId,
-        entry: String,
         kind: LogKind,
+        text: String,
     },
 }
 

crates/language_tools/src/lsp_log_tests.rs 🔗

@@ -15,9 +15,7 @@ use util::path;
 
 #[gpui::test]
 async fn test_lsp_logs(cx: &mut TestAppContext) {
-    if std::env::var("RUST_LOG").is_ok() {
-        env_logger::init();
-    }
+    zlog::init_test();
 
     init_test(cx);
 

crates/language_tools/src/lsp_tool.rs 🔗

@@ -0,0 +1,945 @@
+use std::{collections::hash_map, path::PathBuf, sync::Arc, time::Duration};
+
+use client::proto;
+use collections::{HashMap, HashSet};
+use editor::{Editor, EditorEvent};
+use feature_flags::FeatureFlagAppExt as _;
+use gpui::{
+    Corner, DismissEvent, Entity, Focusable as _, MouseButton, Subscription, Task, WeakEntity,
+    actions,
+};
+use language::{BinaryStatus, BufferId, LocalFile, ServerHealth};
+use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector};
+use picker::{Picker, PickerDelegate, popover_menu::PickerPopoverMenu};
+use project::{LspStore, LspStoreEvent, project_settings::ProjectSettings};
+use settings::{Settings as _, SettingsStore};
+use ui::{Context, Indicator, PopoverMenuHandle, Tooltip, Window, prelude::*};
+
+use workspace::{StatusItemView, Workspace};
+
+use crate::lsp_log::GlobalLogStore;
+
+actions!(lsp_tool, [ToggleMenu]);
+
+pub struct LspTool {
+    state: Entity<PickerState>,
+    popover_menu_handle: PopoverMenuHandle<Picker<LspPickerDelegate>>,
+    lsp_picker: Option<Entity<Picker<LspPickerDelegate>>>,
+    _subscriptions: Vec<Subscription>,
+}
+
+struct PickerState {
+    workspace: WeakEntity<Workspace>,
+    lsp_store: WeakEntity<LspStore>,
+    active_editor: Option<ActiveEditor>,
+    language_servers: LanguageServers,
+}
+
+#[derive(Debug)]
+pub struct LspPickerDelegate {
+    state: Entity<PickerState>,
+    selected_index: usize,
+    items: Vec<LspItem>,
+    other_servers_start_index: Option<usize>,
+}
+
+struct ActiveEditor {
+    editor: WeakEntity<Editor>,
+    _editor_subscription: Subscription,
+    editor_buffers: HashSet<BufferId>,
+}
+
+#[derive(Debug, Default, Clone)]
+struct LanguageServers {
+    health_statuses: HashMap<LanguageServerId, LanguageServerHealthStatus>,
+    binary_statuses: HashMap<LanguageServerName, LanguageServerBinaryStatus>,
+    servers_per_buffer_abs_path:
+        HashMap<PathBuf, HashMap<LanguageServerId, Option<LanguageServerName>>>,
+}
+
+#[derive(Debug, Clone)]
+struct LanguageServerHealthStatus {
+    name: LanguageServerName,
+    health: Option<(Option<SharedString>, ServerHealth)>,
+}
+
+#[derive(Debug, Clone)]
+struct LanguageServerBinaryStatus {
+    status: BinaryStatus,
+    message: Option<SharedString>,
+}
+
+#[derive(Debug)]
+struct ServerInfo {
+    name: LanguageServerName,
+    id: Option<LanguageServerId>,
+    health: Option<ServerHealth>,
+    binary_status: Option<LanguageServerBinaryStatus>,
+    message: Option<SharedString>,
+}
+
+impl ServerInfo {
+    fn server_selector(&self) -> LanguageServerSelector {
+        self.id
+            .map(LanguageServerSelector::Id)
+            .unwrap_or_else(|| LanguageServerSelector::Name(self.name.clone()))
+    }
+}
+
+impl LanguageServerHealthStatus {
+    fn health(&self) -> Option<ServerHealth> {
+        self.health.as_ref().map(|(_, health)| *health)
+    }
+
+    fn message(&self) -> Option<SharedString> {
+        self.health
+            .as_ref()
+            .and_then(|(message, _)| message.clone())
+    }
+}
+
+impl LspPickerDelegate {
+    fn regenerate_items(&mut self, cx: &mut Context<Picker<Self>>) {
+        self.state.update(cx, |state, cx| {
+            let editor_buffers = state
+                .active_editor
+                .as_ref()
+                .map(|active_editor| active_editor.editor_buffers.clone())
+                .unwrap_or_default();
+            let editor_buffer_paths = editor_buffers
+                .iter()
+                .filter_map(|buffer_id| {
+                    let buffer_path = state
+                        .lsp_store
+                        .update(cx, |lsp_store, cx| {
+                            Some(
+                                project::File::from_dyn(
+                                    lsp_store
+                                        .buffer_store()
+                                        .read(cx)
+                                        .get(*buffer_id)?
+                                        .read(cx)
+                                        .file(),
+                                )?
+                                .abs_path(cx),
+                            )
+                        })
+                        .ok()??;
+                    Some(buffer_path)
+                })
+                .collect::<Vec<_>>();
+
+            let mut servers_with_health_checks = HashSet::default();
+            let mut server_ids_with_health_checks = HashSet::default();
+            let mut buffer_servers =
+                Vec::with_capacity(state.language_servers.health_statuses.len());
+            let mut other_servers =
+                Vec::with_capacity(state.language_servers.health_statuses.len());
+            let buffer_server_ids = editor_buffer_paths
+                .iter()
+                .filter_map(|buffer_path| {
+                    state
+                        .language_servers
+                        .servers_per_buffer_abs_path
+                        .get(buffer_path)
+                })
+                .flatten()
+                .fold(HashMap::default(), |mut acc, (server_id, name)| {
+                    match acc.entry(*server_id) {
+                        hash_map::Entry::Occupied(mut o) => {
+                            let old_name: &mut Option<&LanguageServerName> = o.get_mut();
+                            if old_name.is_none() {
+                                *old_name = name.as_ref();
+                            }
+                        }
+                        hash_map::Entry::Vacant(v) => {
+                            v.insert(name.as_ref());
+                        }
+                    }
+                    acc
+                });
+            for (server_id, server_state) in &state.language_servers.health_statuses {
+                let binary_status = state
+                    .language_servers
+                    .binary_statuses
+                    .get(&server_state.name);
+                servers_with_health_checks.insert(&server_state.name);
+                server_ids_with_health_checks.insert(*server_id);
+                if buffer_server_ids.contains_key(server_id) {
+                    buffer_servers.push(ServerData::WithHealthCheck(
+                        *server_id,
+                        server_state,
+                        binary_status,
+                    ));
+                } else {
+                    other_servers.push(ServerData::WithHealthCheck(
+                        *server_id,
+                        server_state,
+                        binary_status,
+                    ));
+                }
+            }
+
+            let mut can_stop_all = false;
+            let mut can_restart_all = true;
+
+            for (server_name, status) in state
+                .language_servers
+                .binary_statuses
+                .iter()
+                .filter(|(name, _)| !servers_with_health_checks.contains(name))
+            {
+                match status.status {
+                    BinaryStatus::None => {
+                        can_restart_all = false;
+                        can_stop_all = true;
+                    }
+                    BinaryStatus::CheckingForUpdate => {
+                        can_restart_all = false;
+                    }
+                    BinaryStatus::Downloading => {
+                        can_restart_all = false;
+                    }
+                    BinaryStatus::Starting => {
+                        can_restart_all = false;
+                    }
+                    BinaryStatus::Stopping => {
+                        can_restart_all = false;
+                    }
+                    BinaryStatus::Stopped => {}
+                    BinaryStatus::Failed { .. } => {}
+                }
+
+                let matching_server_id = state
+                    .language_servers
+                    .servers_per_buffer_abs_path
+                    .iter()
+                    .filter(|(path, _)| editor_buffer_paths.contains(path))
+                    .flat_map(|(_, server_associations)| server_associations.iter())
+                    .find_map(|(id, name)| {
+                        if name.as_ref() == Some(server_name) {
+                            Some(*id)
+                        } else {
+                            None
+                        }
+                    });
+                if let Some(server_id) = matching_server_id {
+                    buffer_servers.push(ServerData::WithBinaryStatus(
+                        Some(server_id),
+                        server_name,
+                        status,
+                    ));
+                } else {
+                    other_servers.push(ServerData::WithBinaryStatus(None, server_name, status));
+                }
+            }
+
+            buffer_servers.sort_by_key(|data| data.name().clone());
+            other_servers.sort_by_key(|data| data.name().clone());
+
+            let mut other_servers_start_index = None;
+            let mut new_lsp_items =
+                Vec::with_capacity(buffer_servers.len() + other_servers.len() + 1);
+            new_lsp_items.extend(buffer_servers.into_iter().map(ServerData::into_lsp_item));
+            if !new_lsp_items.is_empty() {
+                other_servers_start_index = Some(new_lsp_items.len());
+            }
+            new_lsp_items.extend(other_servers.into_iter().map(ServerData::into_lsp_item));
+            if !new_lsp_items.is_empty() {
+                if can_stop_all {
+                    new_lsp_items.push(LspItem::ToggleServersButton { restart: false });
+                } else if can_restart_all {
+                    new_lsp_items.push(LspItem::ToggleServersButton { restart: true });
+                }
+            }
+
+            self.items = new_lsp_items;
+            self.other_servers_start_index = other_servers_start_index;
+        });
+    }
+
+    fn server_info(&self, ix: usize) -> Option<ServerInfo> {
+        match self.items.get(ix)? {
+            LspItem::ToggleServersButton { .. } => None,
+            LspItem::WithHealthCheck(
+                language_server_id,
+                language_server_health_status,
+                language_server_binary_status,
+            ) => Some(ServerInfo {
+                name: language_server_health_status.name.clone(),
+                id: Some(*language_server_id),
+                health: language_server_health_status.health(),
+                binary_status: language_server_binary_status.clone(),
+                message: language_server_health_status.message(),
+            }),
+            LspItem::WithBinaryStatus(
+                server_id,
+                language_server_name,
+                language_server_binary_status,
+            ) => Some(ServerInfo {
+                name: language_server_name.clone(),
+                id: *server_id,
+                health: None,
+                binary_status: Some(language_server_binary_status.clone()),
+                message: language_server_binary_status.message.clone(),
+            }),
+        }
+    }
+}
+
+impl LanguageServers {
+    fn update_binary_status(
+        &mut self,
+        binary_status: BinaryStatus,
+        message: Option<&str>,
+        name: LanguageServerName,
+    ) {
+        let binary_status_message = message.map(SharedString::new);
+        if matches!(
+            binary_status,
+            BinaryStatus::Stopped | BinaryStatus::Failed { .. }
+        ) {
+            self.health_statuses.retain(|_, server| server.name != name);
+        }
+        self.binary_statuses.insert(
+            name,
+            LanguageServerBinaryStatus {
+                status: binary_status,
+                message: binary_status_message,
+            },
+        );
+    }
+
+    fn update_server_health(
+        &mut self,
+        id: LanguageServerId,
+        health: ServerHealth,
+        message: Option<&str>,
+        name: Option<LanguageServerName>,
+    ) {
+        if let Some(state) = self.health_statuses.get_mut(&id) {
+            state.health = Some((message.map(SharedString::new), health));
+            if let Some(name) = name {
+                state.name = name;
+            }
+        } else if let Some(name) = name {
+            self.health_statuses.insert(
+                id,
+                LanguageServerHealthStatus {
+                    health: Some((message.map(SharedString::new), health)),
+                    name,
+                },
+            );
+        }
+    }
+
+    fn is_empty(&self) -> bool {
+        self.binary_statuses.is_empty() && self.health_statuses.is_empty()
+    }
+}
+
+#[derive(Debug)]
+enum ServerData<'a> {
+    WithHealthCheck(
+        LanguageServerId,
+        &'a LanguageServerHealthStatus,
+        Option<&'a LanguageServerBinaryStatus>,
+    ),
+    WithBinaryStatus(
+        Option<LanguageServerId>,
+        &'a LanguageServerName,
+        &'a LanguageServerBinaryStatus,
+    ),
+}
+
+#[derive(Debug)]
+enum LspItem {
+    WithHealthCheck(
+        LanguageServerId,
+        LanguageServerHealthStatus,
+        Option<LanguageServerBinaryStatus>,
+    ),
+    WithBinaryStatus(
+        Option<LanguageServerId>,
+        LanguageServerName,
+        LanguageServerBinaryStatus,
+    ),
+    ToggleServersButton {
+        restart: bool,
+    },
+}
+
+impl ServerData<'_> {
+    fn name(&self) -> &LanguageServerName {
+        match self {
+            Self::WithHealthCheck(_, state, _) => &state.name,
+            Self::WithBinaryStatus(_, name, ..) => name,
+        }
+    }
+
+    fn into_lsp_item(self) -> LspItem {
+        match self {
+            Self::WithHealthCheck(id, name, status) => {
+                LspItem::WithHealthCheck(id, name.clone(), status.cloned())
+            }
+            Self::WithBinaryStatus(server_id, name, status) => {
+                LspItem::WithBinaryStatus(server_id, name.clone(), status.clone())
+            }
+        }
+    }
+}
+
+impl PickerDelegate for LspPickerDelegate {
+    type ListItem = AnyElement;
+
+    fn match_count(&self) -> usize {
+        self.items.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
+        self.selected_index = ix;
+        cx.notify();
+    }
+
+    fn update_matches(
+        &mut self,
+        _: String,
+        _: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Task<()> {
+        cx.spawn(async move |lsp_picker, cx| {
+            cx.background_executor()
+                .timer(Duration::from_millis(30))
+                .await;
+            lsp_picker
+                .update(cx, |lsp_picker, cx| {
+                    lsp_picker.delegate.regenerate_items(cx);
+                })
+                .ok();
+        })
+    }
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+        Arc::default()
+    }
+
+    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        if let Some(LspItem::ToggleServersButton { restart }) = self.items.get(self.selected_index)
+        {
+            let lsp_store = self.state.read(cx).lsp_store.clone();
+            lsp_store
+                .update(cx, |lsp_store, cx| {
+                    if *restart {
+                        let Some(workspace) = self.state.read(cx).workspace.upgrade() else {
+                            return;
+                        };
+                        let project = workspace.read(cx).project().clone();
+                        let buffer_store = project.read(cx).buffer_store().clone();
+                        let worktree_store = project.read(cx).worktree_store();
+
+                        let buffers = self
+                            .state
+                            .read(cx)
+                            .language_servers
+                            .servers_per_buffer_abs_path
+                            .keys()
+                            .filter_map(|abs_path| {
+                                worktree_store.read(cx).find_worktree(abs_path, cx)
+                            })
+                            .filter_map(|(worktree, relative_path)| {
+                                let entry = worktree.read(cx).entry_for_path(&relative_path)?;
+                                project.read(cx).path_for_entry(entry.id, cx)
+                            })
+                            .filter_map(|project_path| {
+                                buffer_store.read(cx).get_by_path(&project_path)
+                            })
+                            .collect();
+                        let selectors = self
+                            .items
+                            .iter()
+                            // Do not try to use IDs as we have stopped all servers already, when allowing to restart them all
+                            .flat_map(|item| match item {
+                                LspItem::ToggleServersButton { .. } => None,
+                                LspItem::WithHealthCheck(_, status, ..) => {
+                                    Some(LanguageServerSelector::Name(status.name.clone()))
+                                }
+                                LspItem::WithBinaryStatus(_, server_name, ..) => {
+                                    Some(LanguageServerSelector::Name(server_name.clone()))
+                                }
+                            })
+                            .collect();
+                        lsp_store.restart_language_servers_for_buffers(buffers, selectors, cx);
+                    } else {
+                        lsp_store.stop_all_language_servers(cx);
+                    }
+                })
+                .ok();
+        }
+
+        let Some(server_selector) = self
+            .server_info(self.selected_index)
+            .map(|info| info.server_selector())
+        else {
+            return;
+        };
+        let lsp_logs = cx.global::<GlobalLogStore>().0.clone();
+        let lsp_store = self.state.read(cx).lsp_store.clone();
+        let workspace = self.state.read(cx).workspace.clone();
+        lsp_logs
+            .update(cx, |lsp_logs, cx| {
+                let has_logs = lsp_store
+                    .update(cx, |lsp_store, _| {
+                        lsp_store.as_local().is_some() && lsp_logs.has_server_logs(&server_selector)
+                    })
+                    .unwrap_or(false);
+                if has_logs {
+                    lsp_logs.open_server_trace(workspace, server_selector, window, cx);
+                }
+            })
+            .ok();
+    }
+
+    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
+        cx.emit(DismissEvent);
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let rendered_match = h_flex().px_1().gap_1();
+        let rendered_match_contents = h_flex()
+            .id(("lsp-item", ix))
+            .w_full()
+            .px_2()
+            .gap_2()
+            .when(selected, |server_entry| {
+                server_entry.bg(cx.theme().colors().element_hover)
+            })
+            .hover(|s| s.bg(cx.theme().colors().element_hover));
+
+        if let Some(LspItem::ToggleServersButton { restart }) = self.items.get(ix) {
+            let label = Label::new(if *restart {
+                "Restart All Servers"
+            } else {
+                "Stop All Servers"
+            });
+            return Some(
+                rendered_match
+                    .child(rendered_match_contents.child(label))
+                    .into_any_element(),
+            );
+        }
+
+        let server_info = self.server_info(ix)?;
+        let workspace = self.state.read(cx).workspace.clone();
+        let lsp_logs = cx.global::<GlobalLogStore>().0.upgrade()?;
+        let lsp_store = self.state.read(cx).lsp_store.upgrade()?;
+        let server_selector = server_info.server_selector();
+
+        // TODO currently, Zed remote does not work well with the LSP logs
+        // https://github.com/zed-industries/zed/issues/28557
+        let has_logs = lsp_store.read(cx).as_local().is_some()
+            && lsp_logs.read(cx).has_server_logs(&server_selector);
+
+        let status_color = server_info
+            .binary_status
+            .and_then(|binary_status| match binary_status.status {
+                BinaryStatus::None => None,
+                BinaryStatus::CheckingForUpdate
+                | BinaryStatus::Downloading
+                | BinaryStatus::Starting => Some(Color::Modified),
+                BinaryStatus::Stopping => Some(Color::Disabled),
+                BinaryStatus::Stopped => Some(Color::Disabled),
+                BinaryStatus::Failed { .. } => Some(Color::Error),
+            })
+            .or_else(|| {
+                Some(match server_info.health? {
+                    ServerHealth::Ok => Color::Success,
+                    ServerHealth::Warning => Color::Warning,
+                    ServerHealth::Error => Color::Error,
+                })
+            })
+            .unwrap_or(Color::Success);
+
+        Some(
+            rendered_match
+                .child(
+                    rendered_match_contents
+                        .child(Indicator::dot().color(status_color))
+                        .child(Label::new(server_info.name.0.clone()))
+                        .when_some(
+                            server_info.message.clone(),
+                            |server_entry, server_message| {
+                                server_entry.tooltip(Tooltip::text(server_message.clone()))
+                            },
+                        ),
+                )
+                .when_else(
+                    has_logs,
+                    |server_entry| {
+                        server_entry.on_mouse_down(MouseButton::Left, {
+                            let workspace = workspace.clone();
+                            let lsp_logs = lsp_logs.downgrade();
+                            let server_selector = server_selector.clone();
+                            move |_, window, cx| {
+                                lsp_logs
+                                    .update(cx, |lsp_logs, cx| {
+                                        lsp_logs.open_server_trace(
+                                            workspace.clone(),
+                                            server_selector.clone(),
+                                            window,
+                                            cx,
+                                        );
+                                    })
+                                    .ok();
+                            }
+                        })
+                    },
+                    |div| div.cursor_default(),
+                )
+                .into_any_element(),
+        )
+    }
+
+    fn render_editor(
+        &self,
+        editor: &Entity<Editor>,
+        _: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Div {
+        div().child(div().track_focus(&editor.focus_handle(cx)))
+    }
+
+    fn separators_after_indices(&self) -> Vec<usize> {
+        if self.items.is_empty() {
+            return Vec::new();
+        }
+        let mut indices = vec![self.items.len().saturating_sub(2)];
+        if let Some(other_servers_start_index) = self.other_servers_start_index {
+            if other_servers_start_index > 0 {
+                indices.insert(0, other_servers_start_index - 1);
+                indices.dedup();
+            }
+        }
+        indices
+    }
+}
+
+impl LspTool {
+    pub fn new(
+        workspace: &Workspace,
+        popover_menu_handle: PopoverMenuHandle<Picker<LspPickerDelegate>>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let settings_subscription =
+            cx.observe_global_in::<SettingsStore>(window, move |lsp_tool, window, cx| {
+                if ProjectSettings::get_global(cx).global_lsp_settings.button {
+                    if lsp_tool.lsp_picker.is_none() {
+                        lsp_tool.lsp_picker =
+                            Some(Self::new_lsp_picker(lsp_tool.state.clone(), window, cx));
+                        cx.notify();
+                        return;
+                    }
+                } else if lsp_tool.lsp_picker.take().is_some() {
+                    cx.notify();
+                }
+            });
+
+        let lsp_store = workspace.project().read(cx).lsp_store();
+        let lsp_store_subscription =
+            cx.subscribe_in(&lsp_store, window, |lsp_tool, _, e, window, cx| {
+                lsp_tool.on_lsp_store_event(e, window, cx)
+            });
+
+        let state = cx.new(|_| PickerState {
+            workspace: workspace.weak_handle(),
+            lsp_store: lsp_store.downgrade(),
+            active_editor: None,
+            language_servers: LanguageServers::default(),
+        });
+
+        Self {
+            state,
+            popover_menu_handle,
+            lsp_picker: None,
+            _subscriptions: vec![settings_subscription, lsp_store_subscription],
+        }
+    }
+
+    fn on_lsp_store_event(
+        &mut self,
+        e: &LspStoreEvent,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(lsp_picker) = self.lsp_picker.clone() else {
+            return;
+        };
+        let mut updated = false;
+
+        match e {
+            LspStoreEvent::LanguageServerUpdate {
+                language_server_id,
+                name,
+                message: proto::update_language_server::Variant::StatusUpdate(status_update),
+            } => match &status_update.status {
+                Some(proto::status_update::Status::Binary(binary_status)) => {
+                    let Some(name) = name.as_ref() else {
+                        return;
+                    };
+                    if let Some(binary_status) = proto::ServerBinaryStatus::from_i32(*binary_status)
+                    {
+                        let binary_status = match binary_status {
+                            proto::ServerBinaryStatus::None => BinaryStatus::None,
+                            proto::ServerBinaryStatus::CheckingForUpdate => {
+                                BinaryStatus::CheckingForUpdate
+                            }
+                            proto::ServerBinaryStatus::Downloading => BinaryStatus::Downloading,
+                            proto::ServerBinaryStatus::Starting => BinaryStatus::Starting,
+                            proto::ServerBinaryStatus::Stopping => BinaryStatus::Stopping,
+                            proto::ServerBinaryStatus::Stopped => BinaryStatus::Stopped,
+                            proto::ServerBinaryStatus::Failed => {
+                                let Some(error) = status_update.message.clone() else {
+                                    return;
+                                };
+                                BinaryStatus::Failed { error }
+                            }
+                        };
+                        self.state.update(cx, |state, _| {
+                            state.language_servers.update_binary_status(
+                                binary_status,
+                                status_update.message.as_deref(),
+                                name.clone(),
+                            );
+                        });
+                        updated = true;
+                    };
+                }
+                Some(proto::status_update::Status::Health(health_status)) => {
+                    if let Some(health) = proto::ServerHealth::from_i32(*health_status) {
+                        let health = match health {
+                            proto::ServerHealth::Ok => ServerHealth::Ok,
+                            proto::ServerHealth::Warning => ServerHealth::Warning,
+                            proto::ServerHealth::Error => ServerHealth::Error,
+                        };
+                        self.state.update(cx, |state, _| {
+                            state.language_servers.update_server_health(
+                                *language_server_id,
+                                health,
+                                status_update.message.as_deref(),
+                                name.clone(),
+                            );
+                        });
+                        updated = true;
+                    }
+                }
+                None => {}
+            },
+            LspStoreEvent::LanguageServerUpdate {
+                language_server_id,
+                name,
+                message: proto::update_language_server::Variant::RegisteredForBuffer(update),
+                ..
+            } => {
+                self.state.update(cx, |state, _| {
+                    state
+                        .language_servers
+                        .servers_per_buffer_abs_path
+                        .entry(PathBuf::from(&update.buffer_abs_path))
+                        .or_default()
+                        .insert(*language_server_id, name.clone());
+                });
+                updated = true;
+            }
+            _ => {}
+        };
+
+        if updated {
+            lsp_picker.update(cx, |lsp_picker, cx| {
+                lsp_picker.refresh(window, cx);
+            });
+        }
+    }
+
+    fn new_lsp_picker(
+        state: Entity<PickerState>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Entity<Picker<LspPickerDelegate>> {
+        cx.new(|cx| {
+            let mut delegate = LspPickerDelegate {
+                selected_index: 0,
+                other_servers_start_index: None,
+                items: Vec::new(),
+                state,
+            };
+            delegate.regenerate_items(cx);
+            Picker::list(delegate, window, cx)
+        })
+    }
+}
+
+impl StatusItemView for LspTool {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn workspace::ItemHandle>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if ProjectSettings::get_global(cx).global_lsp_settings.button {
+            if let Some(editor) = active_pane_item.and_then(|item| item.downcast::<Editor>()) {
+                if Some(&editor)
+                    != self
+                        .state
+                        .read(cx)
+                        .active_editor
+                        .as_ref()
+                        .and_then(|active_editor| active_editor.editor.upgrade())
+                        .as_ref()
+                {
+                    let editor_buffers =
+                        HashSet::from_iter(editor.read(cx).buffer().read(cx).excerpt_buffer_ids());
+                    let _editor_subscription = cx.subscribe_in(
+                        &editor,
+                        window,
+                        |lsp_tool, _, e: &EditorEvent, window, cx| match e {
+                            EditorEvent::ExcerptsAdded { buffer, .. } => {
+                                lsp_tool.state.update(cx, |state, cx| {
+                                    if let Some(active_editor) = state.active_editor.as_mut() {
+                                        let buffer_id = buffer.read(cx).remote_id();
+                                        if active_editor.editor_buffers.insert(buffer_id) {
+                                            if let Some(picker) = &lsp_tool.lsp_picker {
+                                                picker.update(cx, |picker, cx| {
+                                                    picker.refresh(window, cx)
+                                                });
+                                            }
+                                        }
+                                    }
+                                });
+                            }
+                            EditorEvent::ExcerptsRemoved {
+                                removed_buffer_ids, ..
+                            } => {
+                                lsp_tool.state.update(cx, |state, cx| {
+                                    if let Some(active_editor) = state.active_editor.as_mut() {
+                                        let mut removed = false;
+                                        for id in removed_buffer_ids {
+                                            active_editor.editor_buffers.retain(|buffer_id| {
+                                                let retain = buffer_id != id;
+                                                removed |= !retain;
+                                                retain
+                                            });
+                                        }
+                                        if removed {
+                                            if let Some(picker) = &lsp_tool.lsp_picker {
+                                                picker.update(cx, |picker, cx| {
+                                                    picker.refresh(window, cx)
+                                                });
+                                            }
+                                        }
+                                    }
+                                });
+                            }
+                            _ => {}
+                        },
+                    );
+                    self.state.update(cx, |state, _| {
+                        state.active_editor = Some(ActiveEditor {
+                            editor: editor.downgrade(),
+                            _editor_subscription,
+                            editor_buffers,
+                        });
+                    });
+
+                    let lsp_picker = Self::new_lsp_picker(self.state.clone(), window, cx);
+                    self.lsp_picker = Some(lsp_picker.clone());
+                    lsp_picker.update(cx, |lsp_picker, cx| lsp_picker.refresh(window, cx));
+                }
+            } else if self.state.read(cx).active_editor.is_some() {
+                self.state.update(cx, |state, _| {
+                    state.active_editor = None;
+                });
+                if let Some(lsp_picker) = self.lsp_picker.as_ref() {
+                    lsp_picker.update(cx, |lsp_picker, cx| {
+                        lsp_picker.refresh(window, cx);
+                    });
+                };
+            }
+        } else if self.state.read(cx).active_editor.is_some() {
+            self.state.update(cx, |state, _| {
+                state.active_editor = None;
+            });
+            if let Some(lsp_picker) = self.lsp_picker.as_ref() {
+                lsp_picker.update(cx, |lsp_picker, cx| {
+                    lsp_picker.refresh(window, cx);
+                });
+            }
+        }
+    }
+}
+
+impl Render for LspTool {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
+        if !cx.is_staff() || self.state.read(cx).language_servers.is_empty() {
+            return div();
+        }
+
+        let Some(lsp_picker) = self.lsp_picker.clone() else {
+            return div();
+        };
+
+        let mut has_errors = false;
+        let mut has_warnings = false;
+        let mut has_other_notifications = false;
+        let state = self.state.read(cx);
+        for server in state.language_servers.health_statuses.values() {
+            if let Some(binary_status) = &state.language_servers.binary_statuses.get(&server.name) {
+                has_errors |= matches!(binary_status.status, BinaryStatus::Failed { .. });
+                has_other_notifications |= binary_status.message.is_some();
+            }
+
+            if let Some((message, health)) = &server.health {
+                has_other_notifications |= message.is_some();
+                match health {
+                    ServerHealth::Ok => {}
+                    ServerHealth::Warning => has_warnings = true,
+                    ServerHealth::Error => has_errors = true,
+                }
+            }
+        }
+
+        let indicator = if has_errors {
+            Some(Indicator::dot().color(Color::Error))
+        } else if has_warnings {
+            Some(Indicator::dot().color(Color::Warning))
+        } else if has_other_notifications {
+            Some(Indicator::dot().color(Color::Modified))
+        } else {
+            None
+        };
+
+        div().child(
+            PickerPopoverMenu::new(
+                lsp_picker.clone(),
+                IconButton::new("zed-lsp-tool-button", IconName::BoltFilledAlt)
+                    .when_some(indicator, IconButton::indicator)
+                    .icon_size(IconSize::Small)
+                    .indicator_border_color(Some(cx.theme().colors().status_bar_background)),
+                move |window, cx| Tooltip::for_action("Language Servers", &ToggleMenu, window, cx),
+                Corner::BottomLeft,
+                cx,
+            )
+            .with_handle(self.popover_menu_handle.clone())
+            .render(window, cx),
+        )
+    }
+}

crates/language_tools/src/syntax_tree_view.rs 🔗

@@ -1,4 +1,4 @@
-use editor::{Anchor, Editor, ExcerptId, scroll::Autoscroll};
+use editor::{Anchor, Editor, ExcerptId, SelectionEffects, scroll::Autoscroll};
 use gpui::{
     App, AppContext as _, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, Hsla,
     InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement,
@@ -15,7 +15,7 @@ use workspace::{
     item::{Item, ItemHandle},
 };
 
-actions!(debug, [OpenSyntaxTreeView]);
+actions!(dev, [OpenSyntaxTreeView]);
 
 pub fn init(cx: &mut App) {
     cx.observe_new(|workspace: &mut Workspace, _, _| {
@@ -303,10 +303,9 @@ impl Render for SyntaxTreeView {
         {
             let layer = layer.clone();
             rendered = rendered.child(uniform_list(
-                cx.entity().clone(),
                 "SyntaxTreeView",
                 layer.node().descendant_count(),
-                move |this, range, _, cx| {
+                cx.processor(move |this, range: Range<usize>, _, cx| {
                     let mut items = Vec::new();
                     let mut cursor = layer.node().walk();
                     let mut descendant_ix = range.start;
@@ -341,7 +340,7 @@ impl Render for SyntaxTreeView {
                                                 mem::swap(&mut range.start, &mut range.end);
 
                                                 editor.change_selections(
-                                                    Some(Autoscroll::newest()),
+                                                    SelectionEffects::scroll(Autoscroll::newest()),
                                                     window, cx,
                                                     |selections| {
                                                         selections.select_ranges(vec![range]);
@@ -359,7 +358,7 @@ impl Render for SyntaxTreeView {
                                                 editor.clear_background_highlights::<Self>( cx);
                                                 editor.highlight_background::<Self>(
                                                     &[range],
-                                                    |theme| theme.editor_document_highlight_write_background,
+                                                    |theme| theme.colors().editor_document_highlight_write_background,
                                                      cx,
                                                 );
                                             });
@@ -377,7 +376,7 @@ impl Render for SyntaxTreeView {
                         }
                     }
                     items
-                },
+                }),
             )
             .size_full()
             .track_scroll(self.list_scroll_handle.clone())

crates/languages/Cargo.toml 🔗

@@ -38,7 +38,9 @@ anyhow.workspace = true
 async-compression.workspace = true
 async-tar.workspace = true
 async-trait.workspace = true
+chrono.workspace = true
 collections.workspace = true
+dap.workspace = true
 futures.workspace = true
 gpui.workspace = true
 http_client.workspace = true
@@ -58,8 +60,10 @@ project.workspace = true
 regex.workspace = true
 rope.workspace = true
 rust-embed.workspace = true
+schemars.workspace = true
 serde.workspace = true
 serde_json.workspace = true
+serde_json_lenient.workspace = true
 settings.workspace = true
 smol.workspace = true
 snippet_provider.workspace = true

crates/languages/src/bash.rs 🔗

@@ -49,6 +49,14 @@ mod tests {
                     assert_eq!(buffer.text(), expected);
                 };
 
+            // Do not indent after shebang
+            expect_indents_to(
+                &mut buffer,
+                cx,
+                "#!/usr/bin/env bash\n#",
+                "#!/usr/bin/env bash\n#",
+            );
+
             // indent function correctly
             expect_indents_to(
                 &mut buffer,

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

@@ -29,6 +29,6 @@ brackets = [
 ###     bar
 ### fi
 ### ```
-increase_indent_pattern = "(\\s*|;)(do|then|in|else|elif)\\b.*$"
-decrease_indent_pattern = "(\\s*|;)\\b(fi|done|esac|else|elif)\\b.*$"
+increase_indent_pattern = "(^|\\s+|;)(do|then|in|else|elif)\\b.*$"
+decrease_indent_pattern = "(^|\\s+|;)(fi|done|esac|else|elif)\\b.*$"
 # make sure to test each line mode & block mode

crates/languages/src/c.rs 🔗

@@ -1,15 +1,15 @@
-use anyhow::{Context, Result, anyhow, bail};
+use anyhow::{Context as _, Result, bail};
 use async_trait::async_trait;
 use futures::StreamExt;
 use gpui::{App, AsyncApp};
 use http_client::github::{GitHubLspBinaryVersion, latest_github_release};
 pub use language::*;
-use lsp::{DiagnosticTag, InitializeParams, LanguageServerBinary, LanguageServerName};
+use lsp::{InitializeParams, LanguageServerBinary, LanguageServerName};
 use project::lsp_store::clangd_ext;
 use serde_json::json;
-use smol::fs::{self, File};
+use smol::fs;
 use std::{any::Any, env::consts, path::PathBuf, sync::Arc};
-use util::{ResultExt, fs::remove_matching, maybe, merge_json_value_into};
+use util::{ResultExt, archive::extract_zip, fs::remove_matching, maybe, merge_json_value_into};
 
 pub struct CLspAdapter;
 
@@ -32,7 +32,7 @@ impl super::LspAdapter for CLspAdapter {
         let path = delegate.which(Self::SERVER_NAME.as_ref()).await?;
         Some(LanguageServerBinary {
             path,
-            arguments: vec![],
+            arguments: Vec::new(),
             env: None,
         })
     }
@@ -54,7 +54,7 @@ impl super::LspAdapter for CLspAdapter {
             .assets
             .iter()
             .find(|asset| asset.name == asset_name)
-            .ok_or_else(|| anyhow!("no asset found matching {:?}", asset_name))?;
+            .with_context(|| format!("no asset found matching {asset_name:?}"))?;
         let version = GitHubLspBinaryVersion {
             name: release.tag_name,
             url: asset.browser_download_url.clone(),
@@ -69,7 +69,6 @@ impl super::LspAdapter for CLspAdapter {
         delegate: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
         let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
-        let zip_path = container_dir.join(format!("clangd_{}.zip", version.name));
         let version_dir = container_dir.join(format!("clangd_{}", version.name));
         let binary_path = version_dir.join("bin/clangd");
 
@@ -79,32 +78,21 @@ impl super::LspAdapter for CLspAdapter {
                 .get(&version.url, Default::default(), true)
                 .await
                 .context("error downloading release")?;
-            let mut file = File::create(&zip_path).await?;
-            if !response.status().is_success() {
-                Err(anyhow!(
-                    "download failed with status {}",
-                    response.status().to_string()
-                ))?;
-            }
-            futures::io::copy(response.body_mut(), &mut file).await?;
-
-            let unzip_status = util::command::new_smol_command("unzip")
-                .current_dir(&container_dir)
-                .arg(&zip_path)
-                .output()
-                .await?
-                .status;
-            if !unzip_status.success() {
-                Err(anyhow!("failed to unzip clangd archive"))?;
-            }
-
+            anyhow::ensure!(
+                response.status().is_success(),
+                "download failed with status {}",
+                response.status().to_string()
+            );
+            extract_zip(&container_dir, response.body_mut())
+                .await
+                .with_context(|| format!("unzipping clangd archive to {container_dir:?}"))?;
             remove_matching(&container_dir, |entry| entry != version_dir).await;
         }
 
         Ok(LanguageServerBinary {
             path: binary_path,
             env: None,
-            arguments: vec![],
+            arguments: Vec::new(),
         })
     }
 
@@ -143,8 +131,16 @@ impl super::LspAdapter for CLspAdapter {
                 let text = format!("{} {}", detail, label);
                 let source = Rope::from(format!("struct S {{ {} }}", text).as_str());
                 let runs = language.highlight_text(&source, 11..11 + text.len());
+                let filter_range = completion
+                    .filter_text
+                    .as_deref()
+                    .and_then(|filter_text| {
+                        text.find(filter_text)
+                            .map(|start| start..start + filter_text.len())
+                    })
+                    .unwrap_or(detail.len() + 1..text.len());
                 return Some(CodeLabel {
-                    filter_range: detail.len() + 1..text.len(),
+                    filter_range,
                     text,
                     runs,
                 });
@@ -155,8 +151,16 @@ impl super::LspAdapter for CLspAdapter {
                 let detail = completion.detail.as_ref().unwrap();
                 let text = format!("{} {}", detail, label);
                 let runs = language.highlight_text(&Rope::from(text.as_str()), 0..text.len());
+                let filter_range = completion
+                    .filter_text
+                    .as_deref()
+                    .and_then(|filter_text| {
+                        text.find(filter_text)
+                            .map(|start| start..start + filter_text.len())
+                    })
+                    .unwrap_or(detail.len() + 1..text.len());
                 return Some(CodeLabel {
-                    filter_range: detail.len() + 1..text.len(),
+                    filter_range,
                     text,
                     runs,
                 });
@@ -167,16 +171,24 @@ impl super::LspAdapter for CLspAdapter {
                 let detail = completion.detail.as_ref().unwrap();
                 let text = format!("{} {}", detail, label);
                 let runs = language.highlight_text(&Rope::from(text.as_str()), 0..text.len());
-                let filter_start = detail.len() + 1;
-                let filter_end =
-                    if let Some(end) = text.rfind('(').filter(|end| *end > filter_start) {
-                        end
-                    } else {
-                        text.len()
-                    };
+                let filter_range = completion
+                    .filter_text
+                    .as_deref()
+                    .and_then(|filter_text| {
+                        text.find(filter_text)
+                            .map(|start| start..start + filter_text.len())
+                    })
+                    .unwrap_or_else(|| {
+                        let filter_start = detail.len() + 1;
+                        let filter_end = text
+                            .rfind('(')
+                            .filter(|end| *end > filter_start)
+                            .unwrap_or(text.len());
+                        filter_start..filter_end
+                    });
 
                 return Some(CodeLabel {
-                    filter_range: filter_start..filter_end,
+                    filter_range,
                     text,
                     runs,
                 });
@@ -198,7 +210,8 @@ impl super::LspAdapter for CLspAdapter {
                     .grammar()
                     .and_then(|g| g.highlight_id_for_name(highlight_name?))
                 {
-                    let mut label = CodeLabel::plain(label.to_string(), None);
+                    let mut label =
+                        CodeLabel::plain(label.to_string(), completion.filter_text.as_deref());
                     label.runs.push((
                         0..label.text.rfind('(').unwrap_or(label.text.len()),
                         highlight_id,
@@ -208,7 +221,10 @@ impl super::LspAdapter for CLspAdapter {
             }
             _ => {}
         }
-        Some(CodeLabel::plain(label.to_string(), None))
+        Some(CodeLabel::plain(
+            label.to_string(),
+            completion.filter_text.as_deref(),
+        ))
     }
 
     async fn label_for_symbol(
@@ -294,38 +310,12 @@ impl super::LspAdapter for CLspAdapter {
         Ok(original)
     }
 
-    fn process_diagnostics(
-        &self,
-        params: &mut lsp::PublishDiagnosticsParams,
-        server_id: LanguageServerId,
-        buffer: Option<&'_ Buffer>,
-    ) {
-        if let Some(buffer) = buffer {
-            let snapshot = buffer.snapshot();
-            let inactive_regions = buffer
-                .get_diagnostics(server_id)
-                .into_iter()
-                .flat_map(|v| v.iter())
-                .filter(|diag| clangd_ext::is_inactive_region(&diag.diagnostic))
-                .map(move |diag| {
-                    let range =
-                        language::range_to_lsp(diag.range.to_point_utf16(&snapshot)).unwrap();
-                    let mut tags = vec![];
-                    if diag.diagnostic.is_unnecessary {
-                        tags.push(DiagnosticTag::UNNECESSARY);
-                    }
-                    lsp::Diagnostic {
-                        range,
-                        severity: Some(diag.diagnostic.severity),
-                        source: diag.diagnostic.source.clone(),
-                        tags: Some(tags),
-                        message: diag.diagnostic.message.clone(),
-                        code: diag.diagnostic.code.clone(),
-                        ..Default::default()
-                    }
-                });
-            params.diagnostics.extend(inactive_regions);
-        }
+    fn retain_old_diagnostic(&self, previous_diagnostic: &Diagnostic, _: &App) -> bool {
+        clangd_ext::is_inactive_region(previous_diagnostic)
+    }
+
+    fn underline_diagnostic(&self, diagnostic: &lsp::Diagnostic) -> bool {
+        !clangd_ext::is_lsp_inactive_region(diagnostic)
     }
 }
 
@@ -339,20 +329,17 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
                 last_clangd_dir = Some(entry.path());
             }
         }
-        let clangd_dir = last_clangd_dir.ok_or_else(|| anyhow!("no cached binary"))?;
+        let clangd_dir = last_clangd_dir.context("no cached binary")?;
         let clangd_bin = clangd_dir.join("bin/clangd");
-        if clangd_bin.exists() {
-            Ok(LanguageServerBinary {
-                path: clangd_bin,
-                env: None,
-                arguments: vec![],
-            })
-        } else {
-            Err(anyhow!(
-                "missing clangd binary in directory {:?}",
-                clangd_dir
-            ))
-        }
+        anyhow::ensure!(
+            clangd_bin.exists(),
+            "missing clangd binary in directory {clangd_dir:?}"
+        );
+        Ok(LanguageServerBinary {
+            path: clangd_bin,
+            env: None,
+            arguments: Vec::new(),
+        })
     })
     .await
     .log_err()

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

@@ -12,3 +12,4 @@ brackets = [
     { start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] },
 ]
 debuggers = ["CodeLLDB", "GDB"]
+documentation = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 }

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

@@ -12,3 +12,4 @@ brackets = [
     { start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] },
 ]
 debuggers = ["CodeLLDB", "GDB"]
+documentation = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 }

crates/languages/src/css.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use async_trait::async_trait;
 use futures::StreamExt;
 use gpui::AsyncApp;
@@ -149,20 +149,17 @@ async fn get_cached_server_binary(
                 last_version_dir = Some(entry.path());
             }
         }
-        let last_version_dir = last_version_dir.ok_or_else(|| anyhow!("no cached binary"))?;
+        let last_version_dir = last_version_dir.context("no cached binary")?;
         let server_path = last_version_dir.join(SERVER_PATH);
-        if server_path.exists() {
-            Ok(LanguageServerBinary {
-                path: node.binary_path().await?,
-                env: None,
-                arguments: server_binary_arguments(&server_path),
-            })
-        } else {
-            Err(anyhow!(
-                "missing executable in directory {:?}",
-                last_version_dir
-            ))
-        }
+        anyhow::ensure!(
+            server_path.exists(),
+            "missing executable in directory {last_version_dir:?}"
+        );
+        Ok(LanguageServerBinary {
+            path: node.binary_path().await?,
+            env: None,
+            arguments: server_binary_arguments(&server_path),
+        })
     })
     .await
     .log_err()
@@ -203,7 +200,7 @@ mod tests {
         .unindent();
 
         let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
-        let outline = buffer.update(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
+        let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
         assert_eq!(
             outline
                 .items

crates/languages/src/css/highlights.scm 🔗

@@ -11,6 +11,7 @@
   ">"
   "+"
   "-"
+  "|"
   "*"
   "/"
   "="
@@ -19,35 +20,50 @@
   "~="
   "$="
   "*="
+] @operator
+
+[
   "and"
   "or"
   "not"
   "only"
-] @operator
+] @keyword.operator
+
+(id_name) @selector.id
+(class_name) @selector.class
 
-(attribute_selector (plain_value) @string)
+(namespace_name) @namespace
+(namespace_selector (tag_name) @namespace "|")
 
 (attribute_name) @attribute
-(pseudo_element_selector (tag_name) @attribute)
-(pseudo_class_selector (class_name) @attribute)
+(pseudo_element_selector "::" (tag_name) @selector.pseudo)
+(pseudo_class_selector ":" (class_name) @selector.pseudo)
 
 [
-  (class_name)
-  (id_name)
-  (namespace_name)
   (feature_name)
+  (property_name)
 ] @property
 
-(property_name) @constant
-
 (function_name) @function
 
+[
+  (plain_value)
+  (keyframes_name)
+  (keyword_query)
+] @constant.builtin
+
+(attribute_selector
+  (plain_value) @string)
+
+(parenthesized_query
+  (keyword_query) @property)
+
 (
   [
     (property_name)
     (plain_value)
-  ] @variable.special
-  (#match? @variable.special "^--")
+  ] @variable
+  (#match? @variable "^--")
 )
 
 [
@@ -61,7 +77,7 @@
   (to)
   (from)
   (important)
-]  @keyword
+] @keyword
 
 (string_value) @string
 (color_value) @string.special
@@ -71,7 +87,7 @@
   (float_value)
 ] @number
 
-(unit) @type
+(unit) @type.unit
 
 [
   ","
@@ -79,9 +95,10 @@
   "."
   "::"
   ";"
-  "#"
 ] @punctuation.delimiter
 
+(id_selector "#" @punctuation.delimiter)
+
 [
   "{"
   ")"

crates/languages/src/go.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use async_trait::async_trait;
 use collections::HashMap;
 use futures::StreamExt;
@@ -107,7 +107,7 @@ impl super::LspAdapter for GoLspAdapter {
                         delegate.show_notification(NOTIFICATION_MESSAGE, cx);
                     })?
                 }
-                return Err(anyhow!("cannot install gopls"));
+                anyhow::bail!("cannot install gopls");
             }
             Ok(())
         }))
@@ -167,10 +167,9 @@ impl super::LspAdapter for GoLspAdapter {
                 String::from_utf8_lossy(&install_output.stdout),
                 String::from_utf8_lossy(&install_output.stderr)
             );
-
-            return Err(anyhow!(
+            anyhow::bail!(
                 "failed to install gopls with `go install`. Is `go` installed and in the PATH? Check logs for more information."
-            ));
+            );
         }
 
         let installed_binary_path = gobin_dir.join(BINARY);
@@ -234,10 +233,18 @@ impl super::LspAdapter for GoLspAdapter {
                 let text = format!("{label} {detail}");
                 let source = Rope::from(format!("import {text}").as_str());
                 let runs = language.highlight_text(&source, 7..7 + text.len());
+                let filter_range = completion
+                    .filter_text
+                    .as_deref()
+                    .and_then(|filter_text| {
+                        text.find(filter_text)
+                            .map(|start| start..start + filter_text.len())
+                    })
+                    .unwrap_or(0..label.len());
                 return Some(CodeLabel {
                     text,
                     runs,
-                    filter_range: 0..label.len(),
+                    filter_range,
                 });
             }
             Some((
@@ -251,10 +258,18 @@ impl super::LspAdapter for GoLspAdapter {
                     name_offset,
                     language.highlight_text(&source, 4..4 + text.len()),
                 );
+                let filter_range = completion
+                    .filter_text
+                    .as_deref()
+                    .and_then(|filter_text| {
+                        text.find(filter_text)
+                            .map(|start| start..start + filter_text.len())
+                    })
+                    .unwrap_or(0..label.len());
                 return Some(CodeLabel {
                     text,
                     runs,
-                    filter_range: 0..label.len(),
+                    filter_range,
                 });
             }
             Some((lsp::CompletionItemKind::STRUCT, _)) => {
@@ -264,10 +279,18 @@ impl super::LspAdapter for GoLspAdapter {
                     name_offset,
                     language.highlight_text(&source, 5..5 + text.len()),
                 );
+                let filter_range = completion
+                    .filter_text
+                    .as_deref()
+                    .and_then(|filter_text| {
+                        text.find(filter_text)
+                            .map(|start| start..start + filter_text.len())
+                    })
+                    .unwrap_or(0..label.len());
                 return Some(CodeLabel {
                     text,
                     runs,
-                    filter_range: 0..label.len(),
+                    filter_range,
                 });
             }
             Some((lsp::CompletionItemKind::INTERFACE, _)) => {
@@ -277,10 +300,18 @@ impl super::LspAdapter for GoLspAdapter {
                     name_offset,
                     language.highlight_text(&source, 5..5 + text.len()),
                 );
+                let filter_range = completion
+                    .filter_text
+                    .as_deref()
+                    .and_then(|filter_text| {
+                        text.find(filter_text)
+                            .map(|start| start..start + filter_text.len())
+                    })
+                    .unwrap_or(0..label.len());
                 return Some(CodeLabel {
                     text,
                     runs,
-                    filter_range: 0..label.len(),
+                    filter_range,
                 });
             }
             Some((lsp::CompletionItemKind::FIELD, detail)) => {
@@ -291,10 +322,18 @@ impl super::LspAdapter for GoLspAdapter {
                     name_offset,
                     language.highlight_text(&source, 16..16 + text.len()),
                 );
+                let filter_range = completion
+                    .filter_text
+                    .as_deref()
+                    .and_then(|filter_text| {
+                        text.find(filter_text)
+                            .map(|start| start..start + filter_text.len())
+                    })
+                    .unwrap_or(0..label.len());
                 return Some(CodeLabel {
                     text,
                     runs,
-                    filter_range: 0..label.len(),
+                    filter_range,
                 });
             }
             Some((lsp::CompletionItemKind::FUNCTION | lsp::CompletionItemKind::METHOD, detail)) => {
@@ -305,8 +344,16 @@ impl super::LspAdapter for GoLspAdapter {
                         name_offset,
                         language.highlight_text(&source, 5..5 + text.len()),
                     );
+                    let filter_range = completion
+                        .filter_text
+                        .as_deref()
+                        .and_then(|filter_text| {
+                            text.find(filter_text)
+                                .map(|start| start..start + filter_text.len())
+                        })
+                        .unwrap_or(0..label.len());
                     return Some(CodeLabel {
-                        filter_range: 0..label.len(),
+                        filter_range,
                         text,
                         runs,
                     });
@@ -375,6 +422,12 @@ impl super::LspAdapter for GoLspAdapter {
             filter_range,
         })
     }
+
+    fn diagnostic_message_to_markdown(&self, message: &str) -> Option<String> {
+        static REGEX: LazyLock<Regex> =
+            LazyLock::new(|| Regex::new(r"(?m)\n\s*").expect("Failed to create REGEX"));
+        Some(REGEX.replace_all(message, "\n\n").to_string())
+    }
 }
 
 fn parse_version_output(output: &Output) -> Result<&str> {
@@ -405,15 +458,12 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
             }
         }
 
-        if let Some(path) = last_binary_path {
-            Ok(LanguageServerBinary {
-                path,
-                arguments: server_binary_arguments(),
-                env: None,
-            })
-        } else {
-            Err(anyhow!("no cached binary"))
-        }
+        let path = last_binary_path.context("no cached binary")?;
+        anyhow::Ok(LanguageServerBinary {
+            path,
+            arguments: server_binary_arguments(),
+            env: None,
+        })
     })
     .await
     .log_err()
@@ -442,12 +492,13 @@ impl ContextProvider for GoContextProvider {
     fn build_context(
         &self,
         variables: &TaskVariables,
-        location: &Location,
+        location: ContextLocation<'_>,
         _: Option<HashMap<String, String>>,
         _: Arc<dyn LanguageToolchainStore>,
         cx: &mut gpui::App,
     ) -> Task<Result<TaskVariables>> {
         let local_abs_path = location
+            .file_location
             .buffer
             .read(cx)
             .file()
@@ -507,9 +558,10 @@ impl ContextProvider for GoContextProvider {
 
     fn associated_tasks(
         &self,
-        _: Option<Arc<dyn language::File>>,
+        _: Arc<dyn Fs>,
+        _: Option<Arc<dyn File>>,
         _: &App,
-    ) -> Option<TaskTemplates> {
+    ) -> Task<Option<TaskTemplates>> {
         let package_cwd = if GO_PACKAGE_TASK_VARIABLE.template_value() == "." {
             None
         } else {
@@ -517,7 +569,7 @@ impl ContextProvider for GoContextProvider {
         };
         let module_cwd = Some(GO_MODULE_ROOT_TASK_VARIABLE.template_value());
 
-        Some(TaskTemplates(vec![
+        Task::ready(Some(TaskTemplates(vec![
             TaskTemplate {
                 label: format!(
                     "go test {} -run {}",
@@ -628,7 +680,7 @@ impl ContextProvider for GoContextProvider {
                 cwd: module_cwd.clone(),
                 ..TaskTemplate::default()
             },
-        ]))
+        ])))
     }
 }
 

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

@@ -15,3 +15,4 @@ brackets = [
 tab_size = 4
 hard_tabs = true
 debuggers = ["Delve"]
+documentation = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 }

crates/languages/src/go/debugger.scm 🔗

@@ -0,0 +1,26 @@
+(parameter_declaration (identifier) @debug-variable)
+
+(short_var_declaration (expression_list (identifier) @debug-variable))
+
+(var_declaration (var_spec (identifier) @debug-variable))
+
+(const_declaration (const_spec (identifier) @debug-variable))
+
+(assignment_statement (expression_list (identifier) @debug-variable))
+
+(binary_expression (identifier) @debug-variable
+  (#not-match? @debug-variable "^[A-Z]"))
+
+(call_expression (argument_list (identifier) @debug-variable
+  (#not-match? @debug-variable "^[A-Z]")))
+
+(return_statement (expression_list (identifier) @debug-variable
+  (#not-match? @debug-variable "^[A-Z]")))
+
+(range_clause (expression_list (identifier) @debug-variable))
+
+(parenthesized_expression (identifier) @debug-variable
+  (#not-match? @debug-variable "^[A-Z]"))
+
+(block) @debug-scope
+(function_declaration) @debug-scope

crates/languages/src/javascript/brackets.scm 🔗

@@ -2,6 +2,8 @@
 ("[" @open "]" @close)
 ("{" @open "}" @close)
 ("<" @open ">" @close)
+("<" @open "/>" @close)
+("</" @open ">" @close)
 ("\"" @open "\"" @close)
 ("'" @open "'" @close)
 ("`" @open "`" @close)

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

@@ -4,6 +4,7 @@ path_suffixes = ["js", "jsx", "mjs", "cjs"]
 # [/ ] is so we match "env node" or "/node" but not "ts-node"
 first_line_pattern = '^#!.*\b(?:[/ ]node|deno run.*--ext[= ]js)\b'
 line_comments = ["// "]
+block_comment = ["/*", "*/"]
 autoclose_before = ";:.,=}])>"
 brackets = [
     { start = "{", end = "}", close = true, newline = true },
@@ -20,6 +21,7 @@ tab_size = 2
 scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-server"]
 prettier_parser_name = "babel"
 debuggers = ["JavaScript"]
+documentation = { start = "/**", end = "*/", prefix = "* ", tab_size = 1 }
 
 [jsx_tag_auto_close]
 open_tag_node_name = "jsx_opening_element"

crates/languages/src/javascript/highlights.scm 🔗

@@ -7,6 +7,7 @@
 (property_identifier) @property
 (shorthand_property_identifier) @property
 (shorthand_property_identifier_pattern) @property
+(private_property_identifier) @property
 
 ; Function and method calls
 
@@ -15,7 +16,7 @@
 
 (call_expression
   function: (member_expression
-    property: (property_identifier) @function.method))
+      property: [(property_identifier) (private_property_identifier)] @function.method))
 
 ; Function and method definitions
 
@@ -24,18 +25,18 @@
 (function_declaration
   name: (identifier) @function)
 (method_definition
-  name: (property_identifier) @function.method)
+  name: [(property_identifier) (private_property_identifier)] @function.method)
 (method_definition
     name: (property_identifier) @constructor
     (#eq? @constructor "constructor"))
 
 (pair
-  key: (property_identifier) @function.method
+  key: [(property_identifier) (private_property_identifier)] @function.method
   value: [(function_expression) (arrow_function)])
 
 (assignment_expression
   left: (member_expression
-    property: (property_identifier) @function.method)
+    property: [(property_identifier) (private_property_identifier)] @function.method)
   right: [(function_expression) (arrow_function)])
 
 (variable_declarator
@@ -77,6 +78,8 @@
 
 (comment) @comment
 
+(hash_bang_line) @comment
+
 [
   (string)
   (template_string)
@@ -246,4 +249,4 @@
 (jsx_closing_element (["</" ">"]) @punctuation.bracket.jsx)
 (jsx_self_closing_element (["<" "/>"]) @punctuation.bracket.jsx)
 (jsx_attribute "=" @punctuation.delimiter.jsx)
-(jsx_text) @text.jsx
+(jsx_text) @text.jsx

crates/languages/src/javascript/outline.scm 🔗

@@ -75,7 +75,30 @@
         ] @context
         (#any-of? @_name "it" "test" "describe" "context" "suite")
         arguments: (
-            arguments . (string (string_fragment) @name)
+            arguments . [
+                (string (string_fragment) @name)
+                (identifier) @name
+            ]
+        )
+    )
+) @item
+
+; Add support for parameterized tests
+(
+    (call_expression
+        function: (call_expression
+            function: (member_expression
+                object: [(identifier) @_name (member_expression object: (identifier) @_name)]
+                property: (property_identifier) @_property
+            )
+            (#any-of? @_name "it" "test" "describe" "context" "suite")
+            (#eq? @_property "each")
+        )
+        arguments: (
+            arguments . [
+                (string (string_fragment) @name)
+                (identifier) @name
+            ]
         )
     )
 ) @item

crates/languages/src/javascript/runnables.scm 🔗

@@ -13,7 +13,32 @@
         ]
         (#any-of? @_name "it" "test" "describe" "context" "suite")
         arguments: (
-            arguments . (string (string_fragment) @run)
+            arguments . [
+                (string (string_fragment) @run)
+                (identifier) @run
+            ]
+        )
+    ) @_js-test
+
+    (#set! tag js-test)
+)
+
+; Add support for parameterized tests
+(
+    (call_expression
+        function: (call_expression
+            function: (member_expression
+                object: [(identifier) @_name (member_expression object: (identifier) @_name)]
+                property: (property_identifier) @_property
+            )
+            (#any-of? @_name "it" "test" "describe" "context" "suite")
+            (#eq? @_property "each")
+        )
+        arguments: (
+            arguments . [
+                (string (string_fragment) @run)
+                (identifier) @run
+            ]
         )
     ) @_js-test
 

crates/languages/src/json.rs 🔗

@@ -1,15 +1,18 @@
-use anyhow::{Context, Result, anyhow, bail};
+use anyhow::{Context as _, Result, bail};
 use async_compression::futures::bufread::GzipDecoder;
 use async_tar::Archive;
 use async_trait::async_trait;
 use collections::HashMap;
+use dap::DapRegistry;
 use futures::StreamExt;
-use gpui::{App, AsyncApp};
+use gpui::{App, AsyncApp, Task};
 use http_client::github::{GitHubLspBinaryVersion, latest_github_release};
-use language::{LanguageRegistry, LanguageToolchainStore, LspAdapter, LspAdapterDelegate};
+use language::{
+    ContextProvider, LanguageRegistry, LanguageToolchainStore, LspAdapter, LspAdapterDelegate,
+};
 use lsp::{LanguageServerBinary, LanguageServerName};
 use node_runtime::NodeRuntime;
-use project::{ContextProviderWithTasks, Fs, lsp_store::language_server_settings};
+use project::{Fs, lsp_store::language_server_settings};
 use serde_json::{Value, json};
 use settings::{KeymapFile, SettingsJsonSchemaParams, SettingsStore};
 use smol::{
@@ -25,8 +28,10 @@ use std::{
     str::FromStr,
     sync::Arc,
 };
-use task::{TaskTemplate, TaskTemplates, VariableName};
-use util::{ResultExt, fs::remove_matching, maybe, merge_json_value_into};
+use task::{AdapterSchemas, TaskTemplate, TaskTemplates, VariableName};
+use util::{ResultExt, archive::extract_zip, fs::remove_matching, maybe, merge_json_value_into};
+
+use crate::PackageJsonData;
 
 const SERVER_PATH: &str =
     "node_modules/vscode-langservers-extracted/bin/vscode-json-language-server";
@@ -35,23 +40,92 @@ const SERVER_PATH: &str =
 const TSCONFIG_SCHEMA: &str = include_str!("json/schemas/tsconfig.json");
 const PACKAGE_JSON_SCHEMA: &str = include_str!("json/schemas/package.json");
 
-pub(super) fn json_task_context() -> ContextProviderWithTasks {
-    ContextProviderWithTasks::new(TaskTemplates(vec![
-        TaskTemplate {
-            label: "package script $ZED_CUSTOM_script".to_owned(),
-            command: "npm --prefix $ZED_DIRNAME run".to_owned(),
-            args: vec![VariableName::Custom("script".into()).template_value()],
-            tags: vec!["package-script".into()],
-            ..TaskTemplate::default()
-        },
-        TaskTemplate {
-            label: "composer script $ZED_CUSTOM_script".to_owned(),
-            command: "composer -d $ZED_DIRNAME".to_owned(),
-            args: vec![VariableName::Custom("script".into()).template_value()],
-            tags: vec!["composer-script".into()],
-            ..TaskTemplate::default()
-        },
-    ]))
+pub(crate) struct JsonTaskProvider;
+
+impl ContextProvider for JsonTaskProvider {
+    fn associated_tasks(
+        &self,
+        _: Arc<dyn Fs>,
+        file: Option<Arc<dyn language::File>>,
+        cx: &App,
+    ) -> gpui::Task<Option<TaskTemplates>> {
+        let Some(file) = project::File::from_dyn(file.as_ref()).cloned() else {
+            return Task::ready(None);
+        };
+        let is_package_json = file.path.ends_with("package.json");
+        let is_composer_json = file.path.ends_with("composer.json");
+        if !is_package_json && !is_composer_json {
+            return Task::ready(None);
+        }
+
+        cx.spawn(async move |cx| {
+            let contents = file
+                .worktree
+                .update(cx, |this, cx| this.load_file(&file.path, cx))
+                .ok()?
+                .await
+                .ok()?;
+
+            let task_templates = if is_package_json {
+                let package_json = serde_json_lenient::from_str::<
+                    HashMap<String, serde_json_lenient::Value>,
+                >(&contents.text)
+                .ok()?;
+                let package_json = PackageJsonData::new(file.path.clone(), package_json);
+                let command = package_json.package_manager.unwrap_or("npm").to_owned();
+                package_json
+                    .scripts
+                    .into_iter()
+                    .map(|(_, key)| TaskTemplate {
+                        label: format!("run {key}"),
+                        command: command.clone(),
+                        args: vec!["run".into(), key],
+                        cwd: Some(VariableName::Dirname.template_value()),
+                        ..TaskTemplate::default()
+                    })
+                    .chain([TaskTemplate {
+                        label: "package script $ZED_CUSTOM_script".to_owned(),
+                        command: command.clone(),
+                        args: vec![
+                            "run".into(),
+                            VariableName::Custom("script".into()).template_value(),
+                        ],
+                        cwd: Some(VariableName::Dirname.template_value()),
+                        tags: vec!["package-script".into()],
+                        ..TaskTemplate::default()
+                    }])
+                    .collect()
+            } else if is_composer_json {
+                serde_json_lenient::Value::from_str(&contents.text)
+                    .ok()?
+                    .get("scripts")?
+                    .as_object()?
+                    .keys()
+                    .map(|key| TaskTemplate {
+                        label: format!("run {key}"),
+                        command: "composer".to_owned(),
+                        args: vec!["-d".into(), "$ZED_DIRNAME".into(), key.into()],
+                        ..TaskTemplate::default()
+                    })
+                    .chain([TaskTemplate {
+                        label: "composer script $ZED_CUSTOM_script".to_owned(),
+                        command: "composer".to_owned(),
+                        args: vec![
+                            "-d".into(),
+                            "$ZED_DIRNAME".into(),
+                            VariableName::Custom("script".into()).template_value(),
+                        ],
+                        tags: vec!["composer-script".into()],
+                        ..TaskTemplate::default()
+                    }])
+                    .collect()
+            } else {
+                vec![]
+            };
+
+            Some(TaskTemplates(task_templates))
+        })
+    }
 }
 
 fn server_binary_arguments(server_path: &Path) -> Vec<OsString> {
@@ -75,7 +149,11 @@ impl JsonLspAdapter {
         }
     }
 
-    fn get_workspace_config(language_names: Vec<String>, cx: &mut App) -> Value {
+    fn get_workspace_config(
+        language_names: Vec<String>,
+        adapter_schemas: AdapterSchemas,
+        cx: &mut App,
+    ) -> Value {
         let keymap_schema = KeymapFile::generate_json_schema_for_registered_actions(cx);
         let font_names = &cx.text_system().all_font_names();
         let settings_schema = cx.global::<SettingsStore>().json_schema(
@@ -85,13 +163,73 @@ impl JsonLspAdapter {
             },
             cx,
         );
+
         let tasks_schema = task::TaskTemplates::generate_json_schema();
-        let debug_schema = task::DebugTaskFile::generate_json_schema();
+        let debug_schema = task::DebugTaskFile::generate_json_schema(&adapter_schemas);
         let snippets_schema = snippet_provider::format::VsSnippetsFile::generate_json_schema();
         let tsconfig_schema = serde_json::Value::from_str(TSCONFIG_SCHEMA).unwrap();
         let package_json_schema = serde_json::Value::from_str(PACKAGE_JSON_SCHEMA).unwrap();
 
-        // This can be viewed via `debug: open language server logs` -> `json-language-server` ->
+        #[allow(unused_mut)]
+        let mut schemas = serde_json::json!([
+            {
+                "fileMatch": ["tsconfig.json"],
+                "schema":tsconfig_schema
+            },
+            {
+                "fileMatch": ["package.json"],
+                "schema":package_json_schema
+            },
+            {
+                "fileMatch": [
+                    schema_file_match(paths::settings_file()),
+                    paths::local_settings_file_relative_path()
+                ],
+                "schema": settings_schema,
+            },
+            {
+                "fileMatch": [schema_file_match(paths::keymap_file())],
+                "schema": keymap_schema,
+            },
+            {
+                "fileMatch": [
+                    schema_file_match(paths::tasks_file()),
+                    paths::local_tasks_file_relative_path()
+                ],
+                "schema": tasks_schema,
+            },
+            {
+                "fileMatch": [
+                    schema_file_match(
+                        paths::snippets_dir()
+                            .join("*.json")
+                            .as_path()
+                    )
+                ],
+                "schema": snippets_schema,
+            },
+            {
+                "fileMatch": [
+                    schema_file_match(paths::debug_scenarios_file()),
+                    paths::local_debug_file_relative_path()
+                ],
+                "schema": debug_schema,
+            },
+        ]);
+
+        #[cfg(debug_assertions)]
+        {
+            schemas.as_array_mut().unwrap().push(serde_json::json!(
+                {
+                    "fileMatch": [
+                        "zed-inspector-style.json"
+                    ],
+                    "schema": generate_inspector_style_schema(),
+                }
+            ))
+        }
+
+        // This can be viewed via `dev: open language server logs` -> `json-language-server` ->
         // `Server Info`
         serde_json::json!({
             "json": {
@@ -102,52 +240,7 @@ impl JsonLspAdapter {
                 {
                     "enable": true,
                 },
-                "schemas": [
-                    {
-                        "fileMatch": ["tsconfig.json"],
-                        "schema":tsconfig_schema
-                    },
-                    {
-                        "fileMatch": ["package.json"],
-                        "schema":package_json_schema
-                    },
-                    {
-                        "fileMatch": [
-                            schema_file_match(paths::settings_file()),
-                            paths::local_settings_file_relative_path()
-                        ],
-                        "schema": settings_schema,
-                    },
-                    {
-                        "fileMatch": [schema_file_match(paths::keymap_file())],
-                        "schema": keymap_schema,
-                    },
-                    {
-                        "fileMatch": [
-                            schema_file_match(paths::tasks_file()),
-                            paths::local_tasks_file_relative_path()
-                        ],
-                        "schema": tasks_schema,
-                    },
-                    {
-                        "fileMatch": [
-                            schema_file_match(
-                                paths::snippets_dir()
-                                    .join("*.json")
-                                    .as_path()
-                            )
-                        ],
-                        "schema": snippets_schema,
-                    },
-                    {
-                        "fileMatch": [
-                            schema_file_match(paths::debug_scenarios_file()),
-                            paths::local_debug_file_relative_path()
-                        ],
-                        "schema": debug_schema,
-
-                    },
-                ]
+                "schemas": schemas
             }
         })
     }
@@ -160,13 +253,30 @@ impl JsonLspAdapter {
             }
         }
         let mut writer = self.workspace_config.write().await;
-        let config =
-            cx.update(|cx| Self::get_workspace_config(self.languages.language_names(), cx))?;
+
+        let adapter_schemas = cx
+            .read_global::<DapRegistry, _>(|dap_registry, _| dap_registry.to_owned())?
+            .adapters_schema()
+            .await;
+
+        let config = cx.update(|cx| {
+            Self::get_workspace_config(self.languages.language_names().clone(), adapter_schemas, cx)
+        })?;
         writer.replace(config.clone());
         return Ok(config);
     }
 }
 
+#[cfg(debug_assertions)]
+fn generate_inspector_style_schema() -> serde_json_lenient::Value {
+    let schema = schemars::r#gen::SchemaSettings::draft07()
+        .with(|settings| settings.option_add_null_type = false)
+        .into_generator()
+        .into_root_schema_for::<gpui::StyleRefinement>();
+
+    serde_json_lenient::to_value(schema).unwrap()
+}
+
 #[async_trait(?Send)]
 impl LspAdapter for JsonLspAdapter {
     fn name(&self) -> LanguageServerName {
@@ -321,20 +431,17 @@ async fn get_cached_server_binary(
             }
         }
 
-        let last_version_dir = last_version_dir.ok_or_else(|| anyhow!("no cached binary"))?;
+        let last_version_dir = last_version_dir.context("no cached binary")?;
         let server_path = last_version_dir.join(SERVER_PATH);
-        if server_path.exists() {
-            Ok(LanguageServerBinary {
-                path: node.binary_path().await?,
-                env: None,
-                arguments: server_binary_arguments(&server_path),
-            })
-        } else {
-            Err(anyhow!(
-                "missing executable in directory {:?}",
-                last_version_dir
-            ))
-        }
+        anyhow::ensure!(
+            server_path.exists(),
+            "missing executable in directory {last_version_dir:?}"
+        );
+        Ok(LanguageServerBinary {
+            path: node.binary_path().await?,
+            env: None,
+            arguments: server_binary_arguments(&server_path),
+        })
     })
     .await
     .log_err()
@@ -430,13 +537,9 @@ impl LspAdapter for NodeVersionAdapter {
                 .http_client()
                 .get(&version.url, Default::default(), true)
                 .await
-                .map_err(|err| anyhow!("error downloading release: {}", err))?;
+                .context("downloading release")?;
             if version.url.ends_with(".zip") {
-                node_runtime::extract_zip(
-                    &destination_container_path,
-                    BufReader::new(response.body_mut()),
-                )
-                .await?;
+                extract_zip(&destination_container_path, response.body_mut()).await?;
             } else if version.url.ends_with(".tar.gz") {
                 let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
                 let archive = Archive::new(decompressed_bytes);
@@ -452,15 +555,6 @@ impl LspAdapter for NodeVersionAdapter {
                 &destination_path,
             )
             .await?;
-            // todo("windows")
-            #[cfg(not(windows))]
-            {
-                fs::set_permissions(
-                    &destination_path,
-                    <fs::Permissions as fs::unix::PermissionsExt>::from_mode(0o755),
-                )
-                .await?;
-            }
             remove_matching(&container_dir, |entry| entry != destination_path).await;
         }
         Ok(LanguageServerBinary {
@@ -488,7 +582,7 @@ async fn get_cached_version_server_binary(container_dir: PathBuf) -> Option<Lang
         }
 
         anyhow::Ok(LanguageServerBinary {
-            path: last.ok_or_else(|| anyhow!("no cached binary"))?,
+            path: last.context("no cached binary")?,
             env: None,
             arguments: Default::default(),
         })

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

@@ -10,6 +10,6 @@ brackets = [
 ]
 tab_size = 2
 prettier_parser_name = "json"
-
+debuggers = ["JavaScript"]
 [overrides.string]
 completion_query_characters = [":", " "]

crates/languages/src/lib.rs 🔗

@@ -1,7 +1,7 @@
 use anyhow::Context as _;
 use gpui::{App, UpdateGlobal};
-use json::json_task_context;
 use node_runtime::NodeRuntime;
+use python::PyprojectTomlManifestProvider;
 use rust::CargoManifestProvider;
 use rust_embed::RustEmbed;
 use settings::SettingsStore;
@@ -11,11 +11,14 @@ use util::{ResultExt, asset_str};
 
 pub use language::*;
 
+use crate::json::JsonTaskProvider;
+
 mod bash;
 mod c;
 mod css;
 mod go;
 mod json;
+mod package_json;
 mod python;
 mod rust;
 mod tailwind;
@@ -23,6 +26,8 @@ mod typescript;
 mod vtsls;
 mod yaml;
 
+pub(crate) use package_json::{PackageJson, PackageJsonData};
+
 #[derive(RustEmbed)]
 #[folder = "src/"]
 #[exclude = "*.rs"]
@@ -77,7 +82,7 @@ pub fn init(languages: Arc<LanguageRegistry>, node: NodeRuntime, cx: &mut App) {
     let eslint_adapter = Arc::new(typescript::EsLintLspAdapter::new(node.clone()));
     let go_context_provider = Arc::new(go::GoContextProvider);
     let go_lsp_adapter = Arc::new(go::GoLspAdapter);
-    let json_context_provider = Arc::new(json_task_context());
+    let json_context_provider = Arc::new(JsonTaskProvider);
     let json_lsp_adapter = Arc::new(json::JsonLspAdapter::new(node.clone(), languages.clone()));
     let node_version_lsp_adapter = Arc::new(json::NodeVersionAdapter);
     let py_lsp_adapter = Arc::new(python::PyLspAdapter::new());
@@ -87,7 +92,7 @@ pub fn init(languages: Arc<LanguageRegistry>, node: NodeRuntime, cx: &mut App) {
     let rust_context_provider = Arc::new(rust::RustContextProvider);
     let rust_lsp_adapter = Arc::new(rust::RustLspAdapter);
     let tailwind_adapter = Arc::new(tailwind::TailwindLspAdapter::new(node.clone()));
-    let typescript_context = Arc::new(typescript::typescript_task_context());
+    let typescript_context = Arc::new(typescript::TypeScriptContextProvider::new());
     let typescript_lsp_adapter = Arc::new(typescript::TypeScriptLspAdapter::new(node.clone()));
     let vtsls_adapter = Arc::new(vtsls::VtslsLspAdapter::new(node.clone()));
     let yaml_lsp_adapter = Arc::new(yaml::YamlLspAdapter::new(node.clone()));
@@ -302,7 +307,13 @@ pub fn init(languages: Arc<LanguageRegistry>, node: NodeRuntime, cx: &mut App) {
         anyhow::Ok(())
     })
     .detach();
-    project::ManifestProviders::global(cx).register(Arc::from(CargoManifestProvider));
+    let manifest_providers: [Arc<dyn ManifestProvider>; 2] = [
+        Arc::from(CargoManifestProvider),
+        Arc::from(PyprojectTomlManifestProvider),
+    ];
+    for provider in manifest_providers {
+        project::ManifestProviders::global(cx).register(provider);
+    }
 }
 
 #[derive(Default)]

crates/languages/src/package_json.rs 🔗

@@ -0,0 +1,106 @@
+use chrono::{DateTime, Local};
+use collections::{BTreeSet, HashMap};
+use serde_json_lenient::Value;
+use std::{path::Path, sync::Arc};
+
+#[derive(Clone, Debug)]
+pub struct PackageJson {
+    pub mtime: DateTime<Local>,
+    pub data: PackageJsonData,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct PackageJsonData {
+    pub jest_package_path: Option<Arc<Path>>,
+    pub mocha_package_path: Option<Arc<Path>>,
+    pub vitest_package_path: Option<Arc<Path>>,
+    pub jasmine_package_path: Option<Arc<Path>>,
+    pub scripts: BTreeSet<(Arc<Path>, String)>,
+    pub package_manager: Option<&'static str>,
+}
+
+impl PackageJsonData {
+    pub fn new(path: Arc<Path>, package_json: HashMap<String, Value>) -> Self {
+        let mut scripts = BTreeSet::new();
+        if let Some(Value::Object(package_json_scripts)) = package_json.get("scripts") {
+            scripts.extend(
+                package_json_scripts
+                    .keys()
+                    .cloned()
+                    .map(|name| (path.clone(), name)),
+            );
+        }
+
+        let mut jest_package_path = None;
+        let mut mocha_package_path = None;
+        let mut vitest_package_path = None;
+        let mut jasmine_package_path = None;
+        if let Some(Value::Object(dependencies)) = package_json.get("devDependencies") {
+            if dependencies.contains_key("jest") {
+                jest_package_path.get_or_insert_with(|| path.clone());
+            }
+            if dependencies.contains_key("mocha") {
+                mocha_package_path.get_or_insert_with(|| path.clone());
+            }
+            if dependencies.contains_key("vitest") {
+                vitest_package_path.get_or_insert_with(|| path.clone());
+            }
+            if dependencies.contains_key("jasmine") {
+                jasmine_package_path.get_or_insert_with(|| path.clone());
+            }
+        }
+        if let Some(Value::Object(dev_dependencies)) = package_json.get("dependencies") {
+            if dev_dependencies.contains_key("jest") {
+                jest_package_path.get_or_insert_with(|| path.clone());
+            }
+            if dev_dependencies.contains_key("mocha") {
+                mocha_package_path.get_or_insert_with(|| path.clone());
+            }
+            if dev_dependencies.contains_key("vitest") {
+                vitest_package_path.get_or_insert_with(|| path.clone());
+            }
+            if dev_dependencies.contains_key("jasmine") {
+                jasmine_package_path.get_or_insert_with(|| path.clone());
+            }
+        }
+
+        let package_manager = package_json
+            .get("packageManager")
+            .and_then(|value| value.as_str())
+            .and_then(|value| {
+                if value.starts_with("pnpm") {
+                    Some("pnpm")
+                } else if value.starts_with("yarn") {
+                    Some("yarn")
+                } else if value.starts_with("npm") {
+                    Some("npm")
+                } else {
+                    None
+                }
+            });
+
+        Self {
+            jest_package_path,
+            mocha_package_path,
+            vitest_package_path,
+            jasmine_package_path,
+            scripts,
+            package_manager,
+        }
+    }
+
+    pub fn merge(&mut self, other: Self) {
+        self.jest_package_path = self.jest_package_path.take().or(other.jest_package_path);
+        self.mocha_package_path = self.mocha_package_path.take().or(other.mocha_package_path);
+        self.vitest_package_path = self
+            .vitest_package_path
+            .take()
+            .or(other.vitest_package_path);
+        self.jasmine_package_path = self
+            .jasmine_package_path
+            .take()
+            .or(other.jasmine_package_path);
+        self.scripts.extend(other.scripts);
+        self.package_manager = self.package_manager.or(other.package_manager);
+    }
+}

crates/languages/src/python.rs 🔗

@@ -1,16 +1,16 @@
-use anyhow::ensure;
+use anyhow::{Context as _, ensure};
 use anyhow::{Result, anyhow};
 use async_trait::async_trait;
 use collections::HashMap;
 use gpui::{App, Task};
 use gpui::{AsyncApp, SharedString};
-use language::LanguageName;
-use language::LanguageToolchainStore;
 use language::Toolchain;
 use language::ToolchainList;
 use language::ToolchainLister;
 use language::language_settings::language_settings;
+use language::{ContextLocation, LanguageToolchainStore};
 use language::{ContextProvider, LspAdapter, LspAdapterDelegate};
+use language::{LanguageName, ManifestName, ManifestProvider, ManifestQuery};
 use lsp::LanguageServerBinary;
 use lsp::LanguageServerName;
 use node_runtime::NodeRuntime;
@@ -38,6 +38,32 @@ use std::{
 use task::{TaskTemplate, TaskTemplates, VariableName};
 use util::ResultExt;
 
+pub(crate) struct PyprojectTomlManifestProvider;
+
+impl ManifestProvider for PyprojectTomlManifestProvider {
+    fn name(&self) -> ManifestName {
+        SharedString::new_static("pyproject.toml").into()
+    }
+
+    fn search(
+        &self,
+        ManifestQuery {
+            path,
+            depth,
+            delegate,
+        }: ManifestQuery,
+    ) -> Option<Arc<Path>> {
+        for path in path.ancestors().take(depth) {
+            let p = path.join("pyproject.toml");
+            if delegate.exists(&p, Some(false)) {
+                return Some(path.into());
+            }
+        }
+
+        None
+    }
+}
+
 const SERVER_PATH: &str = "node_modules/pyright/langserver.index.js";
 const NODE_MODULE_RELATIVE_SERVER_PATH: &str = "pyright/langserver.index.js";
 
@@ -80,6 +106,24 @@ impl LspAdapter for PythonLspAdapter {
         Self::SERVER_NAME.clone()
     }
 
+    async fn initialization_options(
+        self: Arc<Self>,
+        _: &dyn Fs,
+        _: &Arc<dyn LspAdapterDelegate>,
+    ) -> Result<Option<Value>> {
+        // Provide minimal initialization options
+        // Virtual environment configuration will be handled through workspace configuration
+        Ok(Some(json!({
+            "python": {
+                "analysis": {
+                    "autoSearchPaths": true,
+                    "useLibraryCodeForTypes": true,
+                    "autoImportCompletions": true
+                }
+            }
+        })))
+    }
+
     async fn check_if_user_installed(
         &self,
         delegate: &dyn LspAdapterDelegate,
@@ -102,9 +146,10 @@ impl LspAdapter for PythonLspAdapter {
 
             let path = node_modules_path.join(NODE_MODULE_RELATIVE_SERVER_PATH);
 
+            let env = delegate.shell_env().await;
             Some(LanguageServerBinary {
                 path: node,
-                env: None,
+                env: Some(env),
                 arguments: server_binary_arguments(&path),
             })
         }
@@ -125,7 +170,7 @@ impl LspAdapter for PythonLspAdapter {
         &self,
         latest_version: Box<dyn 'static + Send + Any>,
         container_dir: PathBuf,
-        _: &dyn LspAdapterDelegate,
+        delegate: &dyn LspAdapterDelegate,
     ) -> Result<LanguageServerBinary> {
         let latest_version = latest_version.downcast::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
@@ -137,9 +182,10 @@ impl LspAdapter for PythonLspAdapter {
             )
             .await?;
 
+        let env = delegate.shell_env().await;
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
-            env: None,
+            env: Some(env),
             arguments: server_binary_arguments(&server_path),
         })
     }
@@ -148,7 +194,7 @@ impl LspAdapter for PythonLspAdapter {
         &self,
         version: &(dyn 'static + Send + Any),
         container_dir: &PathBuf,
-        _: &dyn LspAdapterDelegate,
+        delegate: &dyn LspAdapterDelegate,
     ) -> Option<LanguageServerBinary> {
         let version = version.downcast_ref::<String>().unwrap();
         let server_path = container_dir.join(SERVER_PATH);
@@ -166,9 +212,10 @@ impl LspAdapter for PythonLspAdapter {
         if should_install_language_server {
             None
         } else {
+            let env = delegate.shell_env().await;
             Some(LanguageServerBinary {
                 path: self.node.binary_path().await.ok()?,
-                env: None,
+                env: Some(env),
                 arguments: server_binary_arguments(&server_path),
             })
         }
@@ -177,9 +224,11 @@ impl LspAdapter for PythonLspAdapter {
     async fn cached_server_binary(
         &self,
         container_dir: PathBuf,
-        _: &dyn LspAdapterDelegate,
+        delegate: &dyn LspAdapterDelegate,
     ) -> Option<LanguageServerBinary> {
-        get_cached_server_binary(container_dir, &self.node).await
+        let mut binary = get_cached_server_binary(container_dir, &self.node).await?;
+        binary.env = Some(delegate.shell_env().await);
+        Some(binary)
     }
 
     async fn process_completions(&self, items: &mut [lsp::CompletionItem]) {
@@ -219,10 +268,15 @@ impl LspAdapter for PythonLspAdapter {
             lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant")?,
             _ => return None,
         };
+        let filter_range = item
+            .filter_text
+            .as_deref()
+            .and_then(|filter| label.find(filter).map(|ix| ix..ix + filter.len()))
+            .unwrap_or(0..label.len());
         Some(language::CodeLabel {
             text: label.clone(),
             runs: vec![(0..label.len(), highlight_id)],
-            filter_range: 0..label.len(),
+            filter_range,
         })
     }
 
@@ -282,25 +336,70 @@ impl LspAdapter for PythonLspAdapter {
                     .and_then(|s| s.settings.clone())
                     .unwrap_or_default();
 
-            // If python.pythonPath is not set in user config, do so using our toolchain picker.
+            // If we have a detected toolchain, configure Pyright to use it
             if let Some(toolchain) = toolchain {
                 if user_settings.is_null() {
                     user_settings = Value::Object(serde_json::Map::default());
                 }
                 let object = user_settings.as_object_mut().unwrap();
-                if let Some(python) = object
+
+                let interpreter_path = toolchain.path.to_string();
+
+                // Detect if this is a virtual environment
+                if let Some(interpreter_dir) = Path::new(&interpreter_path).parent() {
+                    if let Some(venv_dir) = interpreter_dir.parent() {
+                        // Check if this looks like a virtual environment
+                        if venv_dir.join("pyvenv.cfg").exists()
+                            || venv_dir.join("bin/activate").exists()
+                            || venv_dir.join("Scripts/activate.bat").exists()
+                        {
+                            // Set venvPath and venv at the root level
+                            // This matches the format of a pyrightconfig.json file
+                            if let Some(parent) = venv_dir.parent() {
+                                // Use relative path if the venv is inside the workspace
+                                let venv_path = if parent == adapter.worktree_root_path() {
+                                    ".".to_string()
+                                } else {
+                                    parent.to_string_lossy().into_owned()
+                                };
+                                object.insert("venvPath".to_string(), Value::String(venv_path));
+                            }
+
+                            if let Some(venv_name) = venv_dir.file_name() {
+                                object.insert(
+                                    "venv".to_owned(),
+                                    Value::String(venv_name.to_string_lossy().into_owned()),
+                                );
+                            }
+                        }
+                    }
+                }
+
+                // Always set the python interpreter path
+                // Get or create the python section
+                let python = object
                     .entry("python")
                     .or_insert(Value::Object(serde_json::Map::default()))
                     .as_object_mut()
-                {
-                    python
-                        .entry("pythonPath")
-                        .or_insert(Value::String(toolchain.path.into()));
-                }
+                    .unwrap();
+
+                // Set both pythonPath and defaultInterpreterPath for compatibility
+                python.insert(
+                    "pythonPath".to_owned(),
+                    Value::String(interpreter_path.clone()),
+                );
+                python.insert(
+                    "defaultInterpreterPath".to_owned(),
+                    Value::String(interpreter_path),
+                );
             }
+
             user_settings
         })
     }
+    fn manifest_name(&self) -> Option<ManifestName> {
+        Some(SharedString::new_static("pyproject.toml").into())
+    }
 }
 
 async fn get_cached_server_binary(
@@ -328,6 +427,9 @@ const PYTHON_TEST_TARGET_TASK_VARIABLE: VariableName =
 const PYTHON_ACTIVE_TOOLCHAIN_PATH: VariableName =
     VariableName::Custom(Cow::Borrowed("PYTHON_ACTIVE_ZED_TOOLCHAIN"));
 
+const PYTHON_ACTIVE_TOOLCHAIN_PATH_RAW: VariableName =
+    VariableName::Custom(Cow::Borrowed("PYTHON_ACTIVE_ZED_TOOLCHAIN_RAW"));
+
 const PYTHON_MODULE_NAME_TASK_VARIABLE: VariableName =
     VariableName::Custom(Cow::Borrowed("PYTHON_MODULE_NAME"));
 
@@ -335,47 +437,59 @@ impl ContextProvider for PythonContextProvider {
     fn build_context(
         &self,
         variables: &task::TaskVariables,
-        location: &project::Location,
+        location: ContextLocation<'_>,
         _: Option<HashMap<String, String>>,
         toolchains: Arc<dyn LanguageToolchainStore>,
         cx: &mut gpui::App,
     ) -> Task<Result<task::TaskVariables>> {
-        let test_target = match selected_test_runner(location.buffer.read(cx).file(), cx) {
-            TestRunner::UNITTEST => self.build_unittest_target(variables),
-            TestRunner::PYTEST => self.build_pytest_target(variables),
-        };
+        let test_target =
+            match selected_test_runner(location.file_location.buffer.read(cx).file(), cx) {
+                TestRunner::UNITTEST => self.build_unittest_target(variables),
+                TestRunner::PYTEST => self.build_pytest_target(variables),
+            };
 
         let module_target = self.build_module_target(variables);
-        let worktree_id = location.buffer.read(cx).file().map(|f| f.worktree_id(cx));
+        let location_file = location.file_location.buffer.read(cx).file().cloned();
+        let worktree_id = location_file.as_ref().map(|f| f.worktree_id(cx));
 
         cx.spawn(async move |cx| {
-            let active_toolchain = if let Some(worktree_id) = worktree_id {
+            let raw_toolchain = if let Some(worktree_id) = worktree_id {
+                let file_path = location_file
+                    .as_ref()
+                    .and_then(|f| f.path().parent())
+                    .map(Arc::from)
+                    .unwrap_or_else(|| Arc::from("".as_ref()));
+
                 toolchains
-                    .active_toolchain(worktree_id, Arc::from("".as_ref()), "Python".into(), cx)
+                    .active_toolchain(worktree_id, file_path, "Python".into(), cx)
                     .await
                     .map_or_else(
-                        || "python3".to_owned(),
-                        |toolchain| format!("\"{}\"", toolchain.path),
+                        || String::from("python3"),
+                        |toolchain| toolchain.path.to_string(),
                     )
             } else {
                 String::from("python3")
             };
+
+            let active_toolchain = format!("\"{raw_toolchain}\"");
             let toolchain = (PYTHON_ACTIVE_TOOLCHAIN_PATH, active_toolchain);
+            let raw_toolchain_var = (PYTHON_ACTIVE_TOOLCHAIN_PATH_RAW, raw_toolchain);
 
             Ok(task::TaskVariables::from_iter(
                 test_target
                     .into_iter()
                     .chain(module_target.into_iter())
-                    .chain([toolchain]),
+                    .chain([toolchain, raw_toolchain_var]),
             ))
         })
     }
 
     fn associated_tasks(
         &self,
+        _: Arc<dyn Fs>,
         file: Option<Arc<dyn language::File>>,
         cx: &App,
-    ) -> Option<TaskTemplates> {
+    ) -> Task<Option<TaskTemplates>> {
         let test_runner = selected_test_runner(file.as_ref(), cx);
 
         let mut tasks = vec![
@@ -387,6 +501,7 @@ impl ContextProvider for PythonContextProvider {
                     "-c".to_owned(),
                     VariableName::SelectedText.template_value_with_whitespace(),
                 ],
+                cwd: Some("$ZED_WORKTREE_ROOT".into()),
                 ..TaskTemplate::default()
             },
             // Execute an entire file
@@ -394,6 +509,7 @@ impl ContextProvider for PythonContextProvider {
                 label: format!("run '{}'", VariableName::File.template_value()),
                 command: PYTHON_ACTIVE_TOOLCHAIN_PATH.template_value(),
                 args: vec![VariableName::File.template_value_with_whitespace()],
+                cwd: Some("$ZED_WORKTREE_ROOT".into()),
                 ..TaskTemplate::default()
             },
             // Execute a file as module
@@ -404,6 +520,7 @@ impl ContextProvider for PythonContextProvider {
                     "-m".to_owned(),
                     PYTHON_MODULE_NAME_TASK_VARIABLE.template_value(),
                 ],
+                cwd: Some("$ZED_WORKTREE_ROOT".into()),
                 tags: vec!["python-module-main-method".to_owned()],
                 ..TaskTemplate::default()
             },
@@ -421,6 +538,7 @@ impl ContextProvider for PythonContextProvider {
                             "unittest".to_owned(),
                             VariableName::File.template_value_with_whitespace(),
                         ],
+                        cwd: Some("$ZED_WORKTREE_ROOT".into()),
                         ..TaskTemplate::default()
                     },
                     // Run test(s) for a specific target within a file
@@ -436,6 +554,7 @@ impl ContextProvider for PythonContextProvider {
                             "python-unittest-class".to_owned(),
                             "python-unittest-method".to_owned(),
                         ],
+                        cwd: Some("$ZED_WORKTREE_ROOT".into()),
                         ..TaskTemplate::default()
                     },
                 ]
@@ -451,6 +570,7 @@ impl ContextProvider for PythonContextProvider {
                             "pytest".to_owned(),
                             VariableName::File.template_value_with_whitespace(),
                         ],
+                        cwd: Some("$ZED_WORKTREE_ROOT".into()),
                         ..TaskTemplate::default()
                     },
                     // Run test(s) for a specific target within a file
@@ -462,6 +582,7 @@ impl ContextProvider for PythonContextProvider {
                             "pytest".to_owned(),
                             PYTHON_TEST_TARGET_TASK_VARIABLE.template_value_with_whitespace(),
                         ],
+                        cwd: Some("$ZED_WORKTREE_ROOT".into()),
                         tags: vec![
                             "python-pytest-class".to_owned(),
                             "python-pytest-method".to_owned(),
@@ -472,7 +593,7 @@ impl ContextProvider for PythonContextProvider {
             }
         });
 
-        Some(TaskTemplates(tasks))
+        Task::ready(Some(TaskTemplates(tasks)))
     }
 }
 
@@ -643,9 +764,13 @@ fn get_worktree_venv_declaration(worktree_root: &Path) -> Option<String> {
 
 #[async_trait]
 impl ToolchainLister for PythonToolchainProvider {
+    fn manifest_name(&self) -> language::ManifestName {
+        ManifestName::from(SharedString::new_static("pyproject.toml"))
+    }
     async fn list(
         &self,
         worktree_root: PathBuf,
+        subroot_relative_path: Option<Arc<Path>>,
         project_env: Option<HashMap<String, String>>,
     ) -> ToolchainList {
         let env = project_env.unwrap_or_default();
@@ -656,7 +781,14 @@ impl ToolchainLister for PythonToolchainProvider {
             &environment,
         );
         let mut config = Configuration::default();
-        config.workspace_directories = Some(vec![worktree_root.clone()]);
+
+        let mut directories = vec![worktree_root.clone()];
+        if let Some(subroot_relative_path) = subroot_relative_path {
+            debug_assert!(subroot_relative_path.is_relative());
+            directories.push(worktree_root.join(subroot_relative_path));
+        }
+
+        config.workspace_directories = Some(directories);
         for locator in locators.iter() {
             locator.configure(&config);
         }
@@ -854,11 +986,11 @@ impl PyLspAdapter {
     async fn ensure_venv(delegate: &dyn LspAdapterDelegate) -> Result<Arc<Path>> {
         let python_path = Self::find_base_python(delegate)
             .await
-            .ok_or_else(|| anyhow!("Could not find Python installation for PyLSP"))?;
+            .context("Could not find Python installation for PyLSP")?;
         let work_dir = delegate
             .language_server_download_dir(&Self::SERVER_NAME)
             .await
-            .ok_or_else(|| anyhow!("Could not get working directory for PyLSP"))?;
+            .context("Could not get working directory for PyLSP")?;
         let mut path = PathBuf::from(work_dir.as_ref());
         path.push("pylsp-venv");
         if !path.exists() {
@@ -1025,10 +1157,15 @@ impl LspAdapter for PyLspAdapter {
             lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant")?,
             _ => return None,
         };
+        let filter_range = item
+            .filter_text
+            .as_deref()
+            .and_then(|filter| label.find(filter).map(|ix| ix..ix + filter.len()))
+            .unwrap_or(0..label.len());
         Some(language::CodeLabel {
             text: label.clone(),
             runs: vec![(0..label.len(), highlight_id)],
-            filter_range: 0..label.len(),
+            filter_range,
         })
     }
 
@@ -1142,6 +1279,9 @@ impl LspAdapter for PyLspAdapter {
             user_settings
         })
     }
+    fn manifest_name(&self) -> Option<ManifestName> {
+        Some(SharedString::new_static("pyproject.toml").into())
+    }
 }
 
 #[cfg(test)]
@@ -1204,12 +1344,12 @@ mod tests {
                 "def a():\n  \n  if a:\n    b()\n  else:\n    "
             );
 
-            // indent after an open paren. the closing  paren is not indented
+            // indent after an open paren. the closing paren is not indented
             // because there is another token before it on the same line.
             append(&mut buffer, "foo(\n1)", cx);
             assert_eq!(
                 buffer.text(),
-                "def a():\n  \n  if a:\n    b()\n  else:\n    foo(\n    1)"
+                "def a():\n  \n  if a:\n    b()\n  else:\n    foo(\n      1)"
             );
 
             // dedent the closing paren if it is shifted to the beginning of the line
@@ -1255,7 +1395,7 @@ mod tests {
 
             // dedent "else" on the line after a closing paren
             append(&mut buffer, "\n  else:\n", cx);
-            assert_eq!(buffer.text(), "if a:\n  b(\n  )\nelse:\n");
+            assert_eq!(buffer.text(), "if a:\n  b(\n  )\nelse:\n  ");
 
             buffer
         });

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

@@ -28,6 +28,11 @@ brackets = [
 
 auto_indent_using_last_non_empty_line = false
 debuggers = ["Debugpy"]
-significant_indentation = true
-increase_indent_pattern = "^\\s*(try)\\b.*:"
-decrease_indent_pattern = "^\\s*(else|elif|except|finally)\\b.*:"
+increase_indent_pattern = "^[^#].*:\\s*$"
+decrease_indent_patterns = [
+  { pattern = "^\\s*elif\\b.*:",    valid_after = ["if", "elif"] },
+  { pattern = "^\\s*else\\b.*:",    valid_after = ["if", "elif", "for", "while", "except"] },
+  { pattern = "^\\s*except\\b.*:",  valid_after = ["try", "except"] },
+  { pattern = "^\\s*finally\\b.*:", valid_after = ["try", "except", "else"] },
+  { pattern = "^\\s*case\\b.*:",    valid_after = ["match", "case"] }
+]

crates/languages/src/python/debugger.scm 🔗

@@ -0,0 +1,43 @@
+(identifier) @debug-variable
+(#eq? @debug-variable "self")
+
+(assignment left: (identifier) @debug-variable)
+(assignment left: (pattern_list (identifier) @debug-variable))
+(assignment left: (tuple_pattern (identifier) @debug-variable))
+
+(augmented_assignment left: (identifier) @debug-variable)
+
+(for_statement left: (identifier) @debug-variable)
+(for_statement left: (pattern_list (identifier) @debug-variable))
+(for_statement left: (tuple_pattern (identifier) @debug-variable))
+
+(for_in_clause left: (identifier) @debug-variable)
+(for_in_clause left: (pattern_list (identifier) @debug-variable))
+(for_in_clause left: (tuple_pattern (identifier) @debug-variable))
+
+(as_pattern (identifier) @debug-variable)
+
+(binary_operator left: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]"))
+(binary_operator right: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]"))
+(comparison_operator (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]"))
+
+(list (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]"))
+(tuple (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]"))
+(set (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]"))
+
+(subscript value: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]"))
+
+(attribute object: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]"))
+
+(return_statement (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]"))
+
+(parenthesized_expression (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]"))
+
+(argument_list (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]"))
+
+(if_statement condition: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]"))
+
+(while_statement condition: (identifier) @debug-variable (#not-match? @debug-variable "^[A-Z]"))
+
+(block) @debug-scope
+(module) @debug-scope

crates/languages/src/python/highlights.scm 🔗

@@ -52,6 +52,20 @@
 (function_definition
   name: (identifier) @function.definition)
 
+((call
+  function: (identifier) @_isinstance
+  arguments: (argument_list
+    (_)
+    (identifier) @type))
+  (#eq? @_isinstance "isinstance"))
+
+((call
+  function: (identifier) @_issubclass
+  arguments: (argument_list
+    (identifier) @type
+    (identifier) @type))
+  (#eq? @_issubclass "issubclass"))
+
 ; Function arguments
 (function_definition
   parameters: (parameters
@@ -137,6 +151,12 @@
   "}" @punctuation.special) @embedded
 
 ; Docstrings.
+([
+  (expression_statement (assignment))
+  (type_alias_statement)
+]
+. (expression_statement (string) @string.doc)+)
+
 (module
   .(expression_statement (string) @string.doc)+)
 
@@ -159,13 +179,6 @@
   . (comment) @comment*
   . (expression_statement (string) @string.doc)+)
 
-(module
-  [
-    (expression_statement (assignment))
-    (type_alias_statement)
-  ]
-  . (expression_statement (string) @string.doc)+)
-
 (class_definition
   body: (block
     (expression_statement (assignment))

crates/languages/src/python/indents.scm 🔗

@@ -1,68 +1,17 @@
-(function_definition
-  ":" @start
-  body: (block) @indent
-)
-
-(if_statement
-  ":" @start
-  consequence: (block) @indent
-  alternative: (_)? @outdent
-)
-
-(else_clause
-  ":" @start
-  body: (block) @indent
-)
-
-(elif_clause
-  ":" @start
-  consequence: (block) @indent
-)
-
-(for_statement
-  ":" @start
-  body: (block) @indent
-)
-
-(with_statement
-  ":" @start
-  body: (block) @indent
-)
-
-(while_statement
-  ":" @start
-  body: (block) @indent
-)
-
-(match_statement
-  ":" @start
-  body: (block) @indent
-)
-
-(class_definition
-  ":" @start
-  body: (block) @indent
-)
-
-(case_clause
-  ":" @start
-  consequence: (block) @indent
-)
-
-(try_statement
-  ":" @start
-  body: (block) @indent
-  (except_clause)? @outdent
-  (else_clause)? @outdent
-  (finally_clause)? @outdent
-)
-
-(except_clause
-  ":" @start
-  (block) @indent
-)
-
-(finally_clause
-  ":" @start
-  (block) @indent
-)
+(_ "[" "]" @end) @indent
+(_ "{" "}" @end) @indent
+(_ "(" ")" @end) @indent
+
+(function_definition) @start.def
+(class_definition) @start.class
+(if_statement) @start.if
+(for_statement) @start.for
+(while_statement) @start.while
+(with_statement) @start.with
+(match_statement) @start.match
+(try_statement) @start.try
+(elif_clause) @start.elif
+(else_clause) @start.else
+(except_clause) @start.except
+(finally_clause) @start.finally
+(case_pattern) @start.case

crates/languages/src/rust.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use async_compression::futures::bufread::GzipDecoder;
 use async_trait::async_trait;
 use collections::HashMap;
@@ -8,6 +8,7 @@ use http_client::github::AssetKind;
 use http_client::github::{GitHubLspBinaryVersion, latest_github_release};
 pub use language::*;
 use lsp::{InitializeParams, LanguageServerBinary};
+use project::Fs;
 use project::lsp_store::rust_analyzer_ext::CARGO_DIAGNOSTICS_SOURCE_NAME;
 use project::project_settings::ProjectSettings;
 use regex::Regex;
@@ -22,8 +23,13 @@ use std::{
     sync::{Arc, LazyLock},
 };
 use task::{TaskTemplate, TaskTemplates, TaskVariables, VariableName};
+use util::archive::extract_zip;
 use util::merge_json_value_into;
-use util::{ResultExt, fs::remove_matching, maybe};
+use util::{
+    ResultExt,
+    fs::{make_file_executable, remove_matching},
+    maybe,
+};
 
 use crate::language_settings::language_settings;
 
@@ -215,26 +221,16 @@ impl LspAdapter for RustLspAdapter {
                         })?;
                 }
                 AssetKind::Zip => {
-                    node_runtime::extract_zip(
-                        &destination_path,
-                        BufReader::new(response.body_mut()),
-                    )
-                    .await
-                    .with_context(|| {
-                        format!("unzipping {} to {:?}", version.url, destination_path)
-                    })?;
+                    extract_zip(&destination_path, response.body_mut())
+                        .await
+                        .with_context(|| {
+                            format!("unzipping {} to {:?}", version.url, destination_path)
+                        })?;
                 }
             };
 
             // todo("windows")
-            #[cfg(not(windows))]
-            {
-                fs::set_permissions(
-                    &server_path,
-                    <fs::Permissions as fs::unix::PermissionsExt>::from_mode(0o755),
-                )
-                .await?;
-            }
+            make_file_executable(&server_path).await?;
         }
 
         Ok(LanguageServerBinary {
@@ -314,10 +310,15 @@ impl LspAdapter for RustLspAdapter {
                 let source = Rope::from(format!("{prefix}{text} }}"));
                 let runs =
                     language.highlight_text(&source, prefix.len()..prefix.len() + text.len());
+                let filter_range = completion
+                    .filter_text
+                    .as_deref()
+                    .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
+                    .unwrap_or(0..name.len());
                 return Some(CodeLabel {
                     text,
                     runs,
-                    filter_range: 0..name.len(),
+                    filter_range,
                 });
             }
             (
@@ -334,10 +335,15 @@ impl LspAdapter for RustLspAdapter {
                 let source = Rope::from(format!("{prefix}{text} = ();"));
                 let runs =
                     language.highlight_text(&source, prefix.len()..prefix.len() + text.len());
+                let filter_range = completion
+                    .filter_text
+                    .as_deref()
+                    .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
+                    .unwrap_or(0..name.len());
                 return Some(CodeLabel {
                     text,
                     runs,
-                    filter_range: 0..name.len(),
+                    filter_range,
                 });
             }
             (
@@ -371,9 +377,13 @@ impl LspAdapter for RustLspAdapter {
                         text.push(' ');
                         text.push_str(&detail);
                     }
-
+                    let filter_range = completion
+                        .filter_text
+                        .as_deref()
+                        .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
+                        .unwrap_or(0..completion.label.find('(').unwrap_or(text.len()));
                     return Some(CodeLabel {
-                        filter_range: 0..completion.label.find('(').unwrap_or(text.len()),
+                        filter_range,
                         text,
                         runs,
                     });
@@ -382,12 +392,18 @@ impl LspAdapter for RustLspAdapter {
                     .as_ref()
                     .map_or(false, |detail| detail.starts_with("macro_rules! "))
                 {
-                    let source = Rope::from(completion.label.as_str());
-                    let runs = language.highlight_text(&source, 0..completion.label.len());
-
+                    let text = completion.label.clone();
+                    let len = text.len();
+                    let source = Rope::from(text.as_str());
+                    let runs = language.highlight_text(&source, 0..len);
+                    let filter_range = completion
+                        .filter_text
+                        .as_deref()
+                        .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
+                        .unwrap_or(0..len);
                     return Some(CodeLabel {
-                        filter_range: 0..completion.label.len(),
-                        text: completion.label.clone(),
+                        filter_range,
+                        text,
                         runs,
                     });
                 }
@@ -410,7 +426,7 @@ impl LspAdapter for RustLspAdapter {
                     label.push(' ');
                     label.push_str(detail);
                 }
-                let mut label = CodeLabel::plain(label, None);
+                let mut label = CodeLabel::plain(label, completion.filter_text.as_deref());
                 if let Some(highlight_name) = highlight_name {
                     let highlight_id = language.grammar()?.highlight_id_for_name(highlight_name)?;
                     label.runs.push((
@@ -559,12 +575,13 @@ impl ContextProvider for RustContextProvider {
     fn build_context(
         &self,
         task_variables: &TaskVariables,
-        location: &Location,
+        location: ContextLocation<'_>,
         project_env: Option<HashMap<String, String>>,
         _: Arc<dyn LanguageToolchainStore>,
         cx: &mut gpui::App,
     ) -> Task<Result<TaskVariables>> {
         let local_abs_path = location
+            .file_location
             .buffer
             .read(cx)
             .file()
@@ -629,9 +646,10 @@ impl ContextProvider for RustContextProvider {
 
     fn associated_tasks(
         &self,
+        _: Arc<dyn Fs>,
         file: Option<Arc<dyn language::File>>,
         cx: &App,
-    ) -> Option<TaskTemplates> {
+    ) -> Task<Option<TaskTemplates>> {
         const DEFAULT_RUN_NAME_STR: &str = "RUST_DEFAULT_PACKAGE_RUN";
         const CUSTOM_TARGET_DIR: &str = "RUST_TARGET_DIR";
 
@@ -686,6 +704,7 @@ impl ContextProvider for RustContextProvider {
                     RUST_PACKAGE_TASK_VARIABLE.template_value(),
                     "--".into(),
                     "--nocapture".into(),
+                    "--include-ignored".into(),
                     RUST_TEST_NAME_TASK_VARIABLE.template_value(),
                 ],
                 tags: vec!["rust-test".to_owned()],
@@ -706,6 +725,7 @@ impl ContextProvider for RustContextProvider {
                     RUST_PACKAGE_TASK_VARIABLE.template_value(),
                     "--".into(),
                     "--nocapture".into(),
+                    "--include-ignored".into(),
                     RUST_DOC_TEST_NAME_TASK_VARIABLE.template_value(),
                 ],
                 tags: vec!["rust-doc-test".to_owned()],
@@ -797,7 +817,7 @@ impl ContextProvider for RustContextProvider {
                 .collect();
         }
 
-        Some(TaskTemplates(task_templates))
+        Task::ready(Some(TaskTemplates(task_templates)))
     }
 
     fn lsp_task_source(&self) -> Option<LanguageServerName> {
@@ -972,7 +992,7 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
         }
 
         anyhow::Ok(LanguageServerBinary {
-            path: last.ok_or_else(|| anyhow!("no cached binary"))?,
+            path: last.context("no cached binary")?,
             env: None,
             arguments: Default::default(),
         })
@@ -1181,6 +1201,49 @@ mod tests {
                 ],
             })
         );
+
+        assert_eq!(
+            adapter
+                .label_for_completion(
+                    &lsp::CompletionItem {
+                        kind: Some(lsp::CompletionItemKind::METHOD),
+                        label: "await.as_deref_mut()".to_string(),
+                        filter_text: Some("as_deref_mut".to_string()),
+                        label_details: Some(CompletionItemLabelDetails {
+                            detail: None,
+                            description: Some("fn(&mut self) -> IterMut<'_, T>".to_string()),
+                        }),
+                        ..Default::default()
+                    },
+                    &language
+                )
+                .await,
+            Some(CodeLabel {
+                text: "await.as_deref_mut()".to_string(),
+                filter_range: 6..18,
+                runs: vec![],
+            })
+        );
+
+        assert_eq!(
+            adapter
+                .label_for_completion(
+                    &lsp::CompletionItem {
+                        kind: Some(lsp::CompletionItemKind::FIELD),
+                        label: "inner_value".to_string(),
+                        filter_text: Some("value".to_string()),
+                        detail: Some("String".to_string()),
+                        ..Default::default()
+                    },
+                    &language,
+                )
+                .await,
+            Some(CodeLabel {
+                text: "inner_value: String".to_string(),
+                filter_range: 6..11,
+                runs: vec![(0..11, HighlightId(3)), (13..19, HighlightId(0))],
+            })
+        );
     }
 
     #[gpui::test]

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

@@ -16,3 +16,4 @@ brackets = [
 ]
 collapsed_placeholder = " /* ... */ "
 debuggers = ["CodeLLDB", "GDB"]
+documentation = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 }

crates/languages/src/rust/debugger.scm 🔗

@@ -0,0 +1,50 @@
+(metavariable) @debug-variable
+
+(parameter (identifier) @debug-variable)
+
+(self) @debug-variable
+
+(static_item (identifier) @debug-variable)
+(const_item (identifier) @debug-variable)
+
+(let_declaration pattern: (identifier) @debug-variable)
+
+(let_condition (identifier) @debug-variable)
+
+(match_arm (identifier) @debug-variable)
+
+(for_expression (identifier) @debug-variable)
+
+(closure_parameters (identifier) @debug-variable)
+
+(assignment_expression (identifier) @debug-variable)
+
+(field_expression (identifier) @debug-variable)
+
+(binary_expression (identifier) @debug-variable
+  (#not-match? @debug-variable "^[A-Z]"))
+
+(reference_expression (identifier) @debug-variable
+  (#not-match? @debug-variable "^[A-Z]"))
+
+(array_expression (identifier) @debug-variable)
+(tuple_expression (identifier) @debug-variable)
+(return_expression (identifier) @debug-variable)
+(await_expression (identifier) @debug-variable)
+(try_expression (identifier) @debug-variable)
+(index_expression (identifier) @debug-variable)
+(range_expression (identifier) @debug-variable)
+(unary_expression (identifier) @debug-variable)
+
+(if_expression (identifier) @debug-variable)
+(while_expression (identifier) @debug-variable)
+
+(parenthesized_expression (identifier) @debug-variable)
+
+(arguments (identifier) @debug-variable
+  (#not-match? @debug-variable "^[A-Z]"))
+
+(macro_invocation (token_tree (identifier) @debug-variable
+  (#not-match? @debug-variable "^[A-Z]")))
+
+(block) @debug-scope

crates/languages/src/rust/injections.scm 🔗

@@ -1,7 +1,15 @@
 (macro_invocation
-  (token_tree) @injection.content
-  (#set! injection.language "rust"))
+    macro: (identifier) @_macro_name
+    (#not-any-of? @_macro_name "view" "html")
+    (token_tree) @injection.content
+    (#set! injection.language "rust"))
 
-(macro_rule
-  (token_tree) @injection.content
-  (#set! injection.language "rust"))
+; we need a better way for the leptos extension to declare that
+; it wants to inject inside of rust, instead of modifying the rust
+; injections to support leptos injections
+(macro_invocation
+    macro: (identifier) @_macro_name
+    (#any-of? @_macro_name "view" "html")
+    (token_tree) @injection.content
+    (#set! injection.language "rstml")
+    )

crates/languages/src/tailwind.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use async_trait::async_trait;
 use collections::HashMap;
 use futures::StreamExt;
@@ -198,20 +198,17 @@ async fn get_cached_server_binary(
                 last_version_dir = Some(entry.path());
             }
         }
-        let last_version_dir = last_version_dir.ok_or_else(|| anyhow!("no cached binary"))?;
+        let last_version_dir = last_version_dir.context("no cached binary")?;
         let server_path = last_version_dir.join(SERVER_PATH);
-        if server_path.exists() {
-            Ok(LanguageServerBinary {
-                path: node.binary_path().await?,
-                env: None,
-                arguments: server_binary_arguments(&server_path),
-            })
-        } else {
-            Err(anyhow!(
-                "missing executable in directory {:?}",
-                last_version_dir
-            ))
-        }
+        anyhow::ensure!(
+            server_path.exists(),
+            "missing executable in directory {last_version_dir:?}"
+        );
+        Ok(LanguageServerBinary {
+            path: node.binary_path().await?,
+            env: None,
+            arguments: server_binary_arguments(&server_path),
+        })
     })
     .await
     .log_err()

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

@@ -2,6 +2,7 @@ name = "TSX"
 grammar = "tsx"
 path_suffixes = ["tsx"]
 line_comments = ["// "]
+block_comment = ["/*", "*/"]
 autoclose_before = ";:.,=}])>"
 brackets = [
     { start = "{", end = "}", close = true, newline = true },
@@ -18,6 +19,7 @@ scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-
 prettier_parser_name = "typescript"
 tab_size = 2
 debuggers = ["JavaScript"]
+documentation = { start = "/**", end = "*/", prefix = "* ", tab_size = 1 }
 
 [jsx_tag_auto_close]
 open_tag_node_name = "jsx_opening_element"
@@ -36,5 +38,5 @@ completion_query_characters = ["-", "."]
 opt_into_language_servers = ["tailwindcss-language-server"]
 prefer_label_for_snippet = true
 
-[overrides.call_expression]
+[overrides.function_name_before_type_arguments]
 prefer_label_for_snippet = true

crates/languages/src/tsx/highlights.scm 🔗

@@ -7,6 +7,7 @@
 (property_identifier) @property
 (shorthand_property_identifier) @property
 (shorthand_property_identifier_pattern) @property
+(private_property_identifier) @property
 
 ; Function and method calls
 
@@ -15,7 +16,7 @@
 
 (call_expression
   function: (member_expression
-    property: (property_identifier) @function.method))
+    property: [(property_identifier) (private_property_identifier)] @function.method))
 
 ; Function and method definitions
 
@@ -24,18 +25,18 @@
 (function_declaration
   name: (identifier) @function)
 (method_definition
-  name: (property_identifier) @function.method)
+  name: [(property_identifier) (private_property_identifier)] @function.method)
 (method_definition
     name: (property_identifier) @constructor
     (#eq? @constructor "constructor"))
 
 (pair
-  key: (property_identifier) @function.method
+  key: [(property_identifier) (private_property_identifier)] @function.method
   value: [(function_expression) (arrow_function)])
 
 (assignment_expression
   left: (member_expression
-    property: (property_identifier) @function.method)
+    property: [(property_identifier) (private_property_identifier)] @function.method)
   right: [(function_expression) (arrow_function)])
 
 (variable_declarator
@@ -254,4 +255,4 @@
 (jsx_closing_element (["</" ">"]) @punctuation.bracket.jsx)
 (jsx_self_closing_element (["<" "/>"]) @punctuation.bracket.jsx)
 (jsx_attribute "=" @punctuation.delimiter.jsx)
-(jsx_text) @text.jsx
+(jsx_text) @text.jsx

crates/languages/src/tsx/outline.scm 🔗

@@ -83,7 +83,30 @@
         ] @context
         (#any-of? @_name "it" "test" "describe" "context" "suite")
         arguments: (
-            arguments . (string (string_fragment) @name)
+            arguments . [
+                (string (string_fragment) @name)
+                (identifier) @name
+            ]
+        )
+    )
+) @item
+
+; Add support for parameterized tests
+(
+    (call_expression
+        function: (call_expression
+            function: (member_expression
+                object: [(identifier) @_name (member_expression object: (identifier) @_name)]
+                property: (property_identifier) @_property
+            )
+            (#any-of? @_name "it" "test" "describe" "context" "suite")
+            (#any-of? @_property "each")
+        )
+        arguments: (
+            arguments . [
+                (string (string_fragment) @name)
+                (identifier) @name
+            ]
         )
     )
 ) @item

crates/languages/src/tsx/overrides.scm 🔗

@@ -14,4 +14,6 @@
   (jsx_expression)
 ] @default
 
-(_ value: (call_expression) @call_expression)
+(_ value: (call_expression
+  function: (identifier) @function_name_before_type_arguments
+  type_arguments: (type_arguments)))

crates/languages/src/tsx/runnables.scm 🔗

@@ -13,7 +13,32 @@
         ]
         (#any-of? @_name "it" "test" "describe" "context" "suite")
         arguments: (
-            arguments . (string (string_fragment) @run)
+            arguments . [
+                (string (string_fragment) @run)
+                (identifier) @run
+            ]
+        )
+    ) @_js-test
+
+    (#set! tag js-test)
+)
+
+; Add support for parameterized tests
+(
+    (call_expression
+        function: (call_expression
+            function: (member_expression
+                object: [(identifier) @_name (member_expression object: (identifier) @_name)]
+                property: (property_identifier) @_property
+            )
+            (#any-of? @_name "it" "test" "describe" "context" "suite")
+            (#any-of? @_property "each")
+        )
+        arguments: (
+            arguments . [
+                (string (string_fragment) @run)
+                (identifier) @run
+            ]
         )
     ) @_js-test
 

crates/languages/src/typescript.rs 🔗

@@ -1,55 +1,485 @@
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use async_compression::futures::bufread::GzipDecoder;
 use async_tar::Archive;
 use async_trait::async_trait;
+use chrono::{DateTime, Local};
 use collections::HashMap;
-use gpui::AsyncApp;
+use futures::future::join_all;
+use gpui::{App, AppContext, AsyncApp, Task};
 use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url};
-use language::{LanguageToolchainStore, LspAdapter, LspAdapterDelegate};
+use language::{
+    ContextLocation, ContextProvider, File, LanguageToolchainStore, LspAdapter, LspAdapterDelegate,
+};
 use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
 use node_runtime::NodeRuntime;
-use project::ContextProviderWithTasks;
 use project::{Fs, lsp_store::language_server_settings};
 use serde_json::{Value, json};
-use smol::{fs, io::BufReader, stream::StreamExt};
+use smol::{fs, io::BufReader, lock::RwLock, stream::StreamExt};
 use std::{
     any::Any,
+    borrow::Cow,
     ffi::OsString,
     path::{Path, PathBuf},
     sync::Arc,
 };
 use task::{TaskTemplate, TaskTemplates, VariableName};
+use util::archive::extract_zip;
+use util::merge_json_value_into;
 use util::{ResultExt, fs::remove_matching, maybe};
 
-pub(super) fn typescript_task_context() -> ContextProviderWithTasks {
-    ContextProviderWithTasks::new(TaskTemplates(vec![
-        TaskTemplate {
-            label: "jest file test".to_owned(),
-            command: "npx jest".to_owned(),
-            args: vec![VariableName::File.template_value()],
-            ..TaskTemplate::default()
-        },
-        TaskTemplate {
-            label: "jest test $ZED_SYMBOL".to_owned(),
-            command: "npx jest".to_owned(),
-            args: vec![
-                "--testNamePattern".into(),
-                format!("\"{}\"", VariableName::Symbol.template_value()),
-                VariableName::File.template_value(),
-            ],
-            tags: vec!["ts-test".into(), "js-test".into(), "tsx-test".into()],
-            ..TaskTemplate::default()
-        },
-        TaskTemplate {
-            label: "execute selection $ZED_SELECTED_TEXT".to_owned(),
-            command: "node".to_owned(),
-            args: vec![
-                "-e".into(),
-                format!("\"{}\"", VariableName::SelectedText.template_value()),
-            ],
-            ..TaskTemplate::default()
-        },
-    ]))
+use crate::{PackageJson, PackageJsonData};
+
+#[derive(Debug)]
+pub(crate) struct TypeScriptContextProvider {
+    last_package_json: PackageJsonContents,
+}
+
+const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
+    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
+
+const TYPESCRIPT_JEST_TEST_NAME_VARIABLE: VariableName =
+    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_TEST_NAME"));
+
+const TYPESCRIPT_VITEST_TEST_NAME_VARIABLE: VariableName =
+    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_TEST_NAME"));
+
+const TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE: VariableName =
+    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_PACKAGE_PATH"));
+
+const TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE: VariableName =
+    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA_PACKAGE_PATH"));
+
+const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName =
+    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_PACKAGE_PATH"));
+
+const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName =
+    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH"));
+
+#[derive(Clone, Debug, Default)]
+struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
+
+impl PackageJsonData {
+    fn fill_task_templates(&self, task_templates: &mut TaskTemplates) {
+        if self.jest_package_path.is_some() {
+            task_templates.0.push(TaskTemplate {
+                label: "jest file test".to_owned(),
+                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
+                args: vec![
+                    "exec".to_owned(),
+                    "--".to_owned(),
+                    "jest".to_owned(),
+                    "--runInBand".to_owned(),
+                    VariableName::File.template_value(),
+                ],
+                cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
+                ..TaskTemplate::default()
+            });
+            task_templates.0.push(TaskTemplate {
+                label: format!("jest test {}", VariableName::Symbol.template_value()),
+                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
+                args: vec![
+                    "exec".to_owned(),
+                    "--".to_owned(),
+                    "jest".to_owned(),
+                    "--runInBand".to_owned(),
+                    "--testNamePattern".to_owned(),
+                    format!(
+                        "\"{}\"",
+                        TYPESCRIPT_JEST_TEST_NAME_VARIABLE.template_value()
+                    ),
+                    VariableName::File.template_value(),
+                ],
+                tags: vec![
+                    "ts-test".to_owned(),
+                    "js-test".to_owned(),
+                    "tsx-test".to_owned(),
+                ],
+                cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
+                ..TaskTemplate::default()
+            });
+        }
+
+        if self.vitest_package_path.is_some() {
+            task_templates.0.push(TaskTemplate {
+                label: format!("{} file test", "vitest".to_owned()),
+                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
+                args: vec![
+                    "exec".to_owned(),
+                    "--".to_owned(),
+                    "vitest".to_owned(),
+                    "run".to_owned(),
+                    "--poolOptions.forks.minForks=0".to_owned(),
+                    "--poolOptions.forks.maxForks=1".to_owned(),
+                    VariableName::File.template_value(),
+                ],
+                cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
+                ..TaskTemplate::default()
+            });
+            task_templates.0.push(TaskTemplate {
+                label: format!(
+                    "{} test {}",
+                    "vitest".to_owned(),
+                    VariableName::Symbol.template_value(),
+                ),
+                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
+                args: vec![
+                    "exec".to_owned(),
+                    "--".to_owned(),
+                    "vitest".to_owned(),
+                    "run".to_owned(),
+                    "--poolOptions.forks.minForks=0".to_owned(),
+                    "--poolOptions.forks.maxForks=1".to_owned(),
+                    "--testNamePattern".to_owned(),
+                    format!(
+                        "\"{}\"",
+                        TYPESCRIPT_VITEST_TEST_NAME_VARIABLE.template_value()
+                    ),
+                    VariableName::File.template_value(),
+                ],
+                tags: vec![
+                    "ts-test".to_owned(),
+                    "js-test".to_owned(),
+                    "tsx-test".to_owned(),
+                ],
+                cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
+                ..TaskTemplate::default()
+            });
+        }
+
+        if self.mocha_package_path.is_some() {
+            task_templates.0.push(TaskTemplate {
+                label: format!("{} file test", "mocha".to_owned()),
+                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
+                args: vec![
+                    "exec".to_owned(),
+                    "--".to_owned(),
+                    "mocha".to_owned(),
+                    VariableName::File.template_value(),
+                ],
+                cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
+                ..TaskTemplate::default()
+            });
+            task_templates.0.push(TaskTemplate {
+                label: format!(
+                    "{} test {}",
+                    "mocha".to_owned(),
+                    VariableName::Symbol.template_value(),
+                ),
+                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
+                args: vec![
+                    "exec".to_owned(),
+                    "--".to_owned(),
+                    "mocha".to_owned(),
+                    "--grep".to_owned(),
+                    format!("\"{}\"", VariableName::Symbol.template_value()),
+                    VariableName::File.template_value(),
+                ],
+                tags: vec![
+                    "ts-test".to_owned(),
+                    "js-test".to_owned(),
+                    "tsx-test".to_owned(),
+                ],
+                cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
+                ..TaskTemplate::default()
+            });
+        }
+
+        if self.jasmine_package_path.is_some() {
+            task_templates.0.push(TaskTemplate {
+                label: format!("{} file test", "jasmine".to_owned()),
+                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
+                args: vec![
+                    "exec".to_owned(),
+                    "--".to_owned(),
+                    "jasmine".to_owned(),
+                    VariableName::File.template_value(),
+                ],
+                cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
+                ..TaskTemplate::default()
+            });
+            task_templates.0.push(TaskTemplate {
+                label: format!(
+                    "{} test {}",
+                    "jasmine".to_owned(),
+                    VariableName::Symbol.template_value(),
+                ),
+                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
+                args: vec![
+                    "exec".to_owned(),
+                    "--".to_owned(),
+                    "jasmine".to_owned(),
+                    format!("--filter={}", VariableName::Symbol.template_value()),
+                    VariableName::File.template_value(),
+                ],
+                tags: vec![
+                    "ts-test".to_owned(),
+                    "js-test".to_owned(),
+                    "tsx-test".to_owned(),
+                ],
+                cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
+                ..TaskTemplate::default()
+            });
+        }
+
+        for (path, script) in &self.scripts {
+            task_templates.0.push(TaskTemplate {
+                label: format!("package.json > {script}",),
+                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
+                args: vec!["run".to_owned(), script.to_owned()],
+                tags: vec!["package-script".into()],
+                cwd: Some(
+                    path.parent()
+                        .unwrap_or(Path::new(""))
+                        .to_string_lossy()
+                        .to_string(),
+                ),
+                ..TaskTemplate::default()
+            });
+        }
+    }
+}
+
+impl TypeScriptContextProvider {
+    pub fn new() -> Self {
+        Self {
+            last_package_json: PackageJsonContents::default(),
+        }
+    }
+
+    fn combined_package_json_data(
+        &self,
+        fs: Arc<dyn Fs>,
+        worktree_root: &Path,
+        file_relative_path: &Path,
+        cx: &App,
+    ) -> Task<anyhow::Result<PackageJsonData>> {
+        let new_json_data = file_relative_path
+            .ancestors()
+            .map(|path| worktree_root.join(path))
+            .map(|parent_path| {
+                self.package_json_data(&parent_path, self.last_package_json.clone(), fs.clone(), cx)
+            })
+            .collect::<Vec<_>>();
+
+        cx.background_spawn(async move {
+            let mut package_json_data = PackageJsonData::default();
+            for new_data in join_all(new_json_data).await.into_iter().flatten() {
+                package_json_data.merge(new_data);
+            }
+            Ok(package_json_data)
+        })
+    }
+
+    fn package_json_data(
+        &self,
+        directory_path: &Path,
+        existing_package_json: PackageJsonContents,
+        fs: Arc<dyn Fs>,
+        cx: &App,
+    ) -> Task<anyhow::Result<PackageJsonData>> {
+        let package_json_path = directory_path.join("package.json");
+        let metadata_check_fs = fs.clone();
+        cx.background_spawn(async move {
+            let metadata = metadata_check_fs
+                .metadata(&package_json_path)
+                .await
+                .with_context(|| format!("getting metadata for {package_json_path:?}"))?
+                .with_context(|| format!("missing FS metadata for {package_json_path:?}"))?;
+            let mtime = DateTime::<Local>::from(metadata.mtime.timestamp_for_user());
+            let existing_data = {
+                let contents = existing_package_json.0.read().await;
+                contents
+                    .get(&package_json_path)
+                    .filter(|package_json| package_json.mtime == mtime)
+                    .map(|package_json| package_json.data.clone())
+            };
+            match existing_data {
+                Some(existing_data) => Ok(existing_data),
+                None => {
+                    let package_json_string =
+                        fs.load(&package_json_path).await.with_context(|| {
+                            format!("loading package.json from {package_json_path:?}")
+                        })?;
+                    let package_json: HashMap<String, serde_json_lenient::Value> =
+                        serde_json_lenient::from_str(&package_json_string).with_context(|| {
+                            format!("parsing package.json from {package_json_path:?}")
+                        })?;
+                    let new_data =
+                        PackageJsonData::new(package_json_path.as_path().into(), package_json);
+                    {
+                        let mut contents = existing_package_json.0.write().await;
+                        contents.insert(
+                            package_json_path,
+                            PackageJson {
+                                mtime,
+                                data: new_data.clone(),
+                            },
+                        );
+                    }
+                    Ok(new_data)
+                }
+            }
+        })
+    }
+}
+
+async fn detect_package_manager(
+    worktree_root: PathBuf,
+    fs: Arc<dyn Fs>,
+    package_json_data: Option<PackageJsonData>,
+) -> &'static str {
+    if let Some(package_json_data) = package_json_data {
+        if let Some(package_manager) = package_json_data.package_manager {
+            return package_manager;
+        }
+    }
+    if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await {
+        return "pnpm";
+    }
+    if fs.is_file(&worktree_root.join("yarn.lock")).await {
+        return "yarn";
+    }
+    "npm"
+}
+
+impl ContextProvider for TypeScriptContextProvider {
+    fn associated_tasks(
+        &self,
+        fs: Arc<dyn Fs>,
+        file: Option<Arc<dyn File>>,
+        cx: &App,
+    ) -> Task<Option<TaskTemplates>> {
+        let Some(file) = project::File::from_dyn(file.as_ref()).cloned() else {
+            return Task::ready(None);
+        };
+        let Some(worktree_root) = file.worktree.read(cx).root_dir() else {
+            return Task::ready(None);
+        };
+        let file_relative_path = file.path().clone();
+        let package_json_data =
+            self.combined_package_json_data(fs.clone(), &worktree_root, &file_relative_path, cx);
+
+        cx.background_spawn(async move {
+            let mut task_templates = TaskTemplates(Vec::new());
+            task_templates.0.push(TaskTemplate {
+                label: format!(
+                    "execute selection {}",
+                    VariableName::SelectedText.template_value()
+                ),
+                command: "node".to_owned(),
+                args: vec![
+                    "-e".to_owned(),
+                    format!("\"{}\"", VariableName::SelectedText.template_value()),
+                ],
+                ..TaskTemplate::default()
+            });
+
+            match package_json_data.await {
+                Ok(package_json) => {
+                    package_json.fill_task_templates(&mut task_templates);
+                }
+                Err(e) => {
+                    log::error!(
+                        "Failed to read package.json for worktree {file_relative_path:?}: {e:#}"
+                    );
+                }
+            }
+
+            Some(task_templates)
+        })
+    }
+
+    fn build_context(
+        &self,
+        current_vars: &task::TaskVariables,
+        location: ContextLocation<'_>,
+        _project_env: Option<HashMap<String, String>>,
+        _toolchains: Arc<dyn LanguageToolchainStore>,
+        cx: &mut App,
+    ) -> Task<Result<task::TaskVariables>> {
+        let mut vars = task::TaskVariables::default();
+
+        if let Some(symbol) = current_vars.get(&VariableName::Symbol) {
+            vars.insert(
+                TYPESCRIPT_JEST_TEST_NAME_VARIABLE,
+                replace_test_name_parameters(symbol),
+            );
+            vars.insert(
+                TYPESCRIPT_VITEST_TEST_NAME_VARIABLE,
+                replace_test_name_parameters(symbol),
+            );
+        }
+        let file_path = location
+            .file_location
+            .buffer
+            .read(cx)
+            .file()
+            .map(|file| file.path());
+
+        let args = location.worktree_root.zip(location.fs).zip(file_path).map(
+            |((worktree_root, fs), file_path)| {
+                (
+                    self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
+                    worktree_root,
+                    fs,
+                )
+            },
+        );
+        cx.background_spawn(async move {
+            if let Some((task, worktree_root, fs)) = args {
+                let package_json_data = task.await.log_err();
+                vars.insert(
+                    TYPESCRIPT_RUNNER_VARIABLE,
+                    detect_package_manager(worktree_root, fs, package_json_data.clone())
+                        .await
+                        .to_owned(),
+                );
+
+                if let Some(package_json_data) = package_json_data {
+                    if let Some(path) = package_json_data.jest_package_path {
+                        vars.insert(
+                            TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
+                            path.parent()
+                                .unwrap_or(Path::new(""))
+                                .to_string_lossy()
+                                .to_string(),
+                        );
+                    }
+
+                    if let Some(path) = package_json_data.mocha_package_path {
+                        vars.insert(
+                            TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
+                            path.parent()
+                                .unwrap_or(Path::new(""))
+                                .to_string_lossy()
+                                .to_string(),
+                        );
+                    }
+
+                    if let Some(path) = package_json_data.vitest_package_path {
+                        vars.insert(
+                            TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
+                            path.parent()
+                                .unwrap_or(Path::new(""))
+                                .to_string_lossy()
+                                .to_string(),
+                        );
+                    }
+
+                    if let Some(path) = package_json_data.jasmine_package_path {
+                        vars.insert(
+                            TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
+                            path.parent()
+                                .unwrap_or(Path::new(""))
+                                .to_string_lossy()
+                                .to_string(),
+                        );
+                    }
+                }
+            }
+            Ok(vars)
+        })
+    }
 }
 
 fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
@@ -64,6 +494,12 @@ fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
     ]
 }
 
+fn replace_test_name_parameters(test_name: &str) -> String {
+    let pattern = regex::Regex::new(r"(%|\$)[0-9a-zA-Z]+").unwrap();
+
+    pattern.replace_all(test_name, "(.+?)").to_string()
+}
+
 pub struct TypeScriptLspAdapter {
     node: NodeRuntime,
 }
@@ -232,11 +668,15 @@ impl LspAdapter for TypeScriptLspAdapter {
         } else {
             item.label.clone()
         };
-
+        let filter_range = item
+            .filter_text
+            .as_deref()
+            .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
+            .unwrap_or(0..len);
         Some(language::CodeLabel {
             text,
             runs: vec![(0..len, highlight_id)],
-            filter_range: 0..len,
+            filter_range,
         })
     }
 
@@ -315,10 +755,7 @@ async fn get_cached_ts_server_binary(
                 arguments: typescript_server_binary_arguments(&old_server_path),
             })
         } else {
-            Err(anyhow!(
-                "missing executable in directory {:?}",
-                container_dir
-            ))
+            anyhow::bail!("missing executable in directory {container_dir:?}")
         }
     })
     .await
@@ -330,8 +767,8 @@ pub struct EsLintLspAdapter {
 }
 
 impl EsLintLspAdapter {
-    const CURRENT_VERSION: &'static str = "2.4.4";
-    const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
+    const CURRENT_VERSION: &'static str = "3.0.10";
+    const CURRENT_VERSION_TAG_NAME: &'static str = "release/3.0.10";
 
     #[cfg(not(windows))]
     const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
@@ -376,81 +813,53 @@ impl LspAdapter for EsLintLspAdapter {
         cx: &mut AsyncApp,
     ) -> Result<Value> {
         let workspace_root = delegate.worktree_root_path();
+        let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
+            .iter()
+            .any(|file| workspace_root.join(file).is_file());
+
+        let mut default_workspace_configuration = json!({
+            "validate": "on",
+            "rulesCustomizations": [],
+            "run": "onType",
+            "nodePath": null,
+            "workingDirectory": {
+                "mode": "auto"
+            },
+            "workspaceFolder": {
+                "uri": workspace_root,
+                "name": workspace_root.file_name()
+                    .unwrap_or(workspace_root.as_os_str())
+                    .to_string_lossy(),
+            },
+            "problems": {},
+            "codeActionOnSave": {
+                // We enable this, but without also configuring code_actions_on_format
+                // in the Zed configuration, it doesn't have an effect.
+                "enable": true,
+            },
+            "codeAction": {
+                "disableRuleComment": {
+                    "enable": true,
+                    "location": "separateLine",
+                },
+                "showDocumentation": {
+                    "enable": true
+                }
+            },
+            "useFlatConfig": use_flat_config,
+        });
 
-        let eslint_user_settings = cx.update(|cx| {
+        let override_options = cx.update(|cx| {
             language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
                 .and_then(|s| s.settings.clone())
-                .unwrap_or_default()
         })?;
 
-        let mut code_action_on_save = json!({
-            // We enable this, but without also configuring `code_actions_on_format`
-            // in the Zed configuration, it doesn't have an effect.
-            "enable": true,
-        });
-
-        if let Some(code_action_settings) = eslint_user_settings
-            .get("codeActionOnSave")
-            .and_then(|settings| settings.as_object())
-        {
-            if let Some(enable) = code_action_settings.get("enable") {
-                code_action_on_save["enable"] = enable.clone();
-            }
-            if let Some(mode) = code_action_settings.get("mode") {
-                code_action_on_save["mode"] = mode.clone();
-            }
-            if let Some(rules) = code_action_settings.get("rules") {
-                code_action_on_save["rules"] = rules.clone();
-            }
+        if let Some(override_options) = override_options {
+            merge_json_value_into(override_options, &mut default_workspace_configuration);
         }
 
-        let working_directory = eslint_user_settings
-            .get("workingDirectory")
-            .cloned()
-            .unwrap_or_else(|| json!({"mode": "auto"}));
-
-        let problems = eslint_user_settings
-            .get("problems")
-            .cloned()
-            .unwrap_or_else(|| json!({}));
-
-        let rules_customizations = eslint_user_settings
-            .get("rulesCustomizations")
-            .cloned()
-            .unwrap_or_else(|| json!([]));
-
-        let node_path = eslint_user_settings.get("nodePath").unwrap_or(&Value::Null);
-        let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
-            .iter()
-            .any(|file| workspace_root.join(file).is_file());
-
         Ok(json!({
-            "": {
-                "validate": "on",
-                "rulesCustomizations": rules_customizations,
-                "run": "onType",
-                "nodePath": node_path,
-                "workingDirectory": working_directory,
-                "workspaceFolder": {
-                    "uri": workspace_root,
-                    "name": workspace_root.file_name()
-                        .unwrap_or(workspace_root.as_os_str()),
-                },
-                "problems": problems,
-                "codeActionOnSave": code_action_on_save,
-                "codeAction": {
-                    "disableRuleComment": {
-                        "enable": true,
-                        "location": "separateLine",
-                    },
-                    "showDocumentation": {
-                        "enable": true
-                    }
-                },
-                "experimental": {
-                    "useFlatConfig": use_flat_config,
-                },
-            }
+            "": default_workspace_configuration
         }))
     }
 
@@ -491,7 +900,7 @@ impl LspAdapter for EsLintLspAdapter {
                 .http_client()
                 .get(&version.url, Default::default(), true)
                 .await
-                .map_err(|err| anyhow!("error downloading release: {}", err))?;
+                .context("downloading release")?;
             match Self::GITHUB_ASSET_KIND {
                 AssetKind::TarGz => {
                     let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
@@ -517,19 +926,16 @@ impl LspAdapter for EsLintLspAdapter {
                         })?;
                 }
                 AssetKind::Zip => {
-                    node_runtime::extract_zip(
-                        &destination_path,
-                        BufReader::new(response.body_mut()),
-                    )
-                    .await
-                    .with_context(|| {
-                        format!("unzipping {} to {:?}", version.url, destination_path)
-                    })?;
+                    extract_zip(&destination_path, response.body_mut())
+                        .await
+                        .with_context(|| {
+                            format!("unzipping {} to {:?}", version.url, destination_path)
+                        })?;
                 }
             }
 
             let mut dir = fs::read_dir(&destination_path).await?;
-            let first = dir.next().await.ok_or(anyhow!("missing first file"))??;
+            let first = dir.next().await.context("missing first file")??;
             let repo_root = destination_path.join("vscode-eslint");
             fs::rename(first.path(), &repo_root).await?;
 
@@ -580,9 +986,10 @@ impl LspAdapter for EsLintLspAdapter {
 
 #[cfg(target_os = "windows")]
 async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
-    if fs::metadata(&src_dir).await.is_err() {
-        return Err(anyhow!("Directory {} not present.", src_dir.display()));
-    }
+    anyhow::ensure!(
+        fs::metadata(&src_dir).await.is_ok(),
+        "Directory {src_dir:?} is not present"
+    );
     if fs::metadata(&dest_dir).await.is_ok() {
         fs::remove_file(&dest_dir).await?;
     }
@@ -599,8 +1006,16 @@ async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
 
 #[cfg(test)]
 mod tests {
-    use gpui::{AppContext as _, TestAppContext};
+    use std::path::Path;
+
+    use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
+    use language::language_settings;
+    use project::{FakeFs, Project};
+    use serde_json::json;
     use unindent::Unindent;
+    use util::path;
+
+    use crate::typescript::{PackageJsonData, TypeScriptContextProvider};
 
     #[gpui::test]
     async fn test_outline(cx: &mut TestAppContext) {
@@ -625,7 +1040,7 @@ mod tests {
         .unindent();
 
         let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
-        let outline = buffer.update(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
+        let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
         assert_eq!(
             outline
                 .items
@@ -641,4 +1056,82 @@ mod tests {
             ]
         );
     }
+
+    #[gpui::test]
+    async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            settings::init(cx);
+            Project::init_settings(cx);
+            language_settings::init(cx);
+        });
+
+        let package_json_1 = json!({
+            "dependencies": {
+                "mocha": "1.0.0",
+                "vitest": "1.0.0"
+            },
+            "scripts": {
+                "test": ""
+            }
+        })
+        .to_string();
+
+        let package_json_2 = json!({
+            "devDependencies": {
+                "vitest": "2.0.0"
+            },
+            "scripts": {
+                "test": ""
+            }
+        })
+        .to_string();
+
+        let fs = FakeFs::new(executor);
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "package.json": package_json_1,
+                "sub": {
+                    "package.json": package_json_2,
+                    "file.js": "",
+                }
+            }),
+        )
+        .await;
+
+        let provider = TypeScriptContextProvider::new();
+        let package_json_data = cx
+            .update(|cx| {
+                provider.combined_package_json_data(
+                    fs.clone(),
+                    path!("/root").as_ref(),
+                    "sub/file1.js".as_ref(),
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+        pretty_assertions::assert_eq!(
+            package_json_data,
+            PackageJsonData {
+                jest_package_path: None,
+                mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
+                vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
+                jasmine_package_path: None,
+                scripts: [
+                    (
+                        Path::new(path!("/root/package.json")).into(),
+                        "test".to_owned()
+                    ),
+                    (
+                        Path::new(path!("/root/sub/package.json")).into(),
+                        "test".to_owned()
+                    )
+                ]
+                .into_iter()
+                .collect(),
+                package_manager: None,
+            }
+        );
+    }
 }

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

@@ -1,8 +1,9 @@
 name = "TypeScript"
 grammar = "typescript"
-path_suffixes = ["ts", "cts", "d.cts", "d.mts", "mts"]
-first_line_pattern = '^#!.*\b(?:deno run|ts-node|bun|tsx)\b'
+path_suffixes = ["ts", "cts", "mts"]
+first_line_pattern = '^#!.*\b(?:deno run|ts-node|bun|tsx|[/ ]node)\b'
 line_comments = ["// "]
+block_comment = ["/*", "*/"]
 autoclose_before = ";:.,=}])>"
 brackets = [
     { start = "{", end = "}", close = true, newline = true },
@@ -18,10 +19,11 @@ word_characters = ["#", "$"]
 prettier_parser_name = "typescript"
 tab_size = 2
 debuggers = ["JavaScript"]
+documentation = { start = "/**", end = "*/", prefix = "* ", tab_size = 1 }
 
 [overrides.string]
 completion_query_characters = ["."]
 prefer_label_for_snippet = true
 
-[overrides.call_expression]
+[overrides.function_name_before_type_arguments]
 prefer_label_for_snippet = true

crates/languages/src/typescript/highlights.scm 🔗

@@ -39,6 +39,7 @@
 (property_identifier) @property
 (shorthand_property_identifier) @property
 (shorthand_property_identifier_pattern) @property
+(private_property_identifier) @property
 
 ; Function and method calls
 
@@ -47,7 +48,7 @@
 
 (call_expression
   function: (member_expression
-    property: (property_identifier) @function.method))
+    property: [(property_identifier) (private_property_identifier)] @function.method))
 
 ; Function and method definitions
 
@@ -56,18 +57,18 @@
 (function_declaration
   name: (identifier) @function)
 (method_definition
-  name: (property_identifier) @function.method)
+  name: [(property_identifier) (private_property_identifier)] @function.method)
 (method_definition
     name: (property_identifier) @constructor
     (#eq? @constructor "constructor"))
 
 (pair
-  key: (property_identifier) @function.method
+  key: [(property_identifier) (private_property_identifier)] @function.method
   value: [(function_expression) (arrow_function)])
 
 (assignment_expression
   left: (member_expression
-    property: (property_identifier) @function.method)
+    property: [(property_identifier) (private_property_identifier)] @function.method)
   right: [(function_expression) (arrow_function)])
 
 (variable_declarator
@@ -104,6 +105,8 @@
 
 (comment) @comment
 
+(hash_bang_line) @comment
+
 [
   (string)
   (template_string)
@@ -268,4 +271,4 @@
   "while"
   "with"
   "yield"
-] @keyword
+] @keyword

crates/languages/src/typescript/outline.scm 🔗

@@ -83,7 +83,30 @@
         ] @context
         (#any-of? @_name "it" "test" "describe" "context" "suite")
         arguments: (
-            arguments . (string (string_fragment) @name)
+            arguments . [
+                (string (string_fragment) @name)
+                (identifier) @name
+            ]
+        )
+    )
+) @item
+
+; Add support for parameterized tests
+(
+    (call_expression
+        function: (call_expression
+            function: (member_expression
+                object: [(identifier) @_name (member_expression object: (identifier) @_name)]
+                property: (property_identifier) @_property
+            )
+            (#any-of? @_name "it" "test" "describe" "context" "suite")
+            (#any-of? @_property "each")
+        )
+        arguments: (
+            arguments . [
+                (string (string_fragment) @name)
+                (identifier) @name
+            ]
         )
     )
 ) @item

crates/languages/src/typescript/overrides.scm 🔗

@@ -1,4 +1,6 @@
 (comment) @comment.inclusive
 (string) @string
 
-(_ value: (call_expression) @call_expression)
+(_ value: (call_expression
+  function: (identifier) @function_name_before_type_arguments
+  type_arguments: (type_arguments)))

crates/languages/src/typescript/runnables.scm 🔗

@@ -13,7 +13,32 @@
         ]
         (#any-of? @_name "it" "test" "describe" "context" "suite")
         arguments: (
-            arguments . (string (string_fragment) @run)
+            arguments . [
+                (string (string_fragment) @run)
+                (identifier) @run
+            ]
+        )
+    ) @_js-test
+
+    (#set! tag js-test)
+)
+
+; Add support for parameterized tests
+(
+    (call_expression
+        function: (call_expression
+            function: (member_expression
+                object: [(identifier) @_name (member_expression object: (identifier) @_name)]
+                property: (property_identifier) @_property
+            )
+            (#any-of? @_name "it" "test" "describe" "context" "suite")
+            (#any-of? @_property "each")
+        )
+        arguments: (
+            arguments . [
+                (string (string_fragment) @run)
+                (identifier) @run
+            ]
         )
     ) @_js-test
 

crates/languages/src/vtsls.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Result, anyhow};
+use anyhow::Result;
 use async_trait::async_trait;
 use collections::HashMap;
 use gpui::AsyncApp;
@@ -195,11 +195,15 @@ impl LspAdapter for VtslsLspAdapter {
         } else {
             item.label.clone()
         };
-
+        let filter_range = item
+            .filter_text
+            .as_deref()
+            .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
+            .unwrap_or(0..len);
         Some(language::CodeLabel {
             text,
             runs: vec![(0..len, highlight_id)],
-            filter_range: 0..len,
+            filter_range,
         })
     }
 
@@ -284,18 +288,15 @@ async fn get_cached_ts_server_binary(
 ) -> Option<LanguageServerBinary> {
     maybe!(async {
         let server_path = container_dir.join(VtslsLspAdapter::SERVER_PATH);
-        if server_path.exists() {
-            Ok(LanguageServerBinary {
-                path: node.binary_path().await?,
-                env: None,
-                arguments: typescript_server_binary_arguments(&server_path),
-            })
-        } else {
-            Err(anyhow!(
-                "missing executable in directory {:?}",
-                container_dir
-            ))
-        }
+        anyhow::ensure!(
+            server_path.exists(),
+            "missing executable in directory {container_dir:?}"
+        );
+        Ok(LanguageServerBinary {
+            path: node.binary_path().await?,
+            env: None,
+            arguments: typescript_server_binary_arguments(&server_path),
+        })
     })
     .await
     .log_err()

crates/languages/src/yaml.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use async_trait::async_trait;
 use futures::StreamExt;
 use gpui::AsyncApp;
@@ -173,20 +173,17 @@ async fn get_cached_server_binary(
                 last_version_dir = Some(entry.path());
             }
         }
-        let last_version_dir = last_version_dir.ok_or_else(|| anyhow!("no cached binary"))?;
+        let last_version_dir = last_version_dir.context("no cached binary")?;
         let server_path = last_version_dir.join(SERVER_PATH);
-        if server_path.exists() {
-            Ok(LanguageServerBinary {
-                path: node.binary_path().await?,
-                env: None,
-                arguments: server_binary_arguments(&server_path),
-            })
-        } else {
-            Err(anyhow!(
-                "missing executable in directory {:?}",
-                last_version_dir
-            ))
-        }
+        anyhow::ensure!(
+            server_path.exists(),
+            "missing executable in directory {last_version_dir:?}"
+        );
+        Ok(LanguageServerBinary {
+            path: node.binary_path().await?,
+            env: None,
+            arguments: server_binary_arguments(&server_path),
+        })
     })
     .await
     .log_err()

crates/livekit_api/src/livekit_api.rs 🔗

@@ -1,7 +1,7 @@
 pub mod proto;
 pub mod token;
 
-use anyhow::{Result, anyhow};
+use anyhow::Result;
 use async_trait::async_trait;
 use prost::Message;
 use reqwest::header::CONTENT_TYPE;
@@ -79,12 +79,12 @@ impl LiveKitClient {
                 Ok(Res::decode(response.bytes().await?)?)
             } else {
                 log::error!("Response {}: {:?}", url, response.status());
-                Err(anyhow!(
+                anyhow::bail!(
                     "POST {} failed with status code {:?}, {:?}",
                     url,
                     response.status(),
                     response.text().await
-                ))
+                );
             }
         }
     }

crates/livekit_api/src/token.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Result, anyhow};
+use anyhow::Result;
 use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
 use serde::{Deserialize, Serialize};
 use std::{
@@ -74,9 +74,7 @@ pub fn create(
     video_grant: VideoGrant,
 ) -> Result<String> {
     if video_grant.room_join.is_some() && identity.is_none() {
-        Err(anyhow!(
-            "identity is required for room_join grant, but it is none"
-        ))?;
+        anyhow::bail!("identity is required for room_join grant, but it is none");
     }
 
     let now = SystemTime::now();

crates/livekit_client/Cargo.toml 🔗

@@ -23,7 +23,7 @@ test-support = ["collections/test-support", "gpui/test-support"]
 anyhow.workspace = true
 async-trait.workspace = true
 collections.workspace = true
-cpal = "0.15"
+cpal.workspace = true
 futures.workspace = true
 gpui = { workspace = true, features = ["x11", "wayland"] }
 gpui_tokio.workspace = true
@@ -39,9 +39,9 @@ tokio-tungstenite.workspace = true
 util.workspace = true
 workspace-hack.workspace = true
 
-[target.'cfg(not(all(target_os = "windows", target_env = "gnu")))'.dependencies]
-libwebrtc = { rev = "80bb8f4c9112789f7c24cc98d8423010977806a6", git = "https://github.com/zed-industries/livekit-rust-sdks" }
-livekit = { rev = "80bb8f4c9112789f7c24cc98d8423010977806a6", git = "https://github.com/zed-industries/livekit-rust-sdks", features = [
+[target.'cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))'.dependencies]
+libwebrtc = { rev = "d2eade7a6b15d6dbdb38ba12a1ff7bf07fcebba4", git = "https://github.com/zed-industries/livekit-rust-sdks" }
+livekit = { rev = "d2eade7a6b15d6dbdb38ba12a1ff7bf07fcebba4", git = "https://github.com/zed-industries/livekit-rust-sdks", features = [
     "__rustls-tls"
 ] }
 

crates/livekit_client/src/lib.rs 🔗

@@ -6,32 +6,32 @@ pub use remote_video_track_view::{RemoteVideoTrackView, RemoteVideoTrackViewEven
 #[cfg(not(any(
     test,
     feature = "test-support",
-    all(target_os = "windows", target_env = "gnu")
+    any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")
 )))]
 mod livekit_client;
 #[cfg(not(any(
     test,
     feature = "test-support",
-    all(target_os = "windows", target_env = "gnu")
+    any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")
 )))]
 pub use livekit_client::*;
 
 #[cfg(any(
     test,
     feature = "test-support",
-    all(target_os = "windows", target_env = "gnu")
+    any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")
 ))]
 mod mock_client;
 #[cfg(any(
     test,
     feature = "test-support",
-    all(target_os = "windows", target_env = "gnu")
+    any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")
 ))]
 pub mod test;
 #[cfg(any(
     test,
     feature = "test-support",
-    all(target_os = "windows", target_env = "gnu")
+    any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")
 ))]
 pub use mock_client::*;
 

crates/livekit_client/src/livekit_client.rs 🔗

@@ -1,6 +1,6 @@
 use std::sync::Arc;
 
-use anyhow::Result;
+use anyhow::{Context as _, Result};
 use collections::HashMap;
 use futures::{SinkExt, channel::mpsc};
 use gpui::{App, AsyncApp, ScreenCaptureSource, ScreenCaptureStream, Task};
@@ -160,7 +160,7 @@ impl LocalParticipant {
         })?
         .await?
         .map(LocalTrackPublication)
-        .map_err(|error| anyhow::anyhow!("failed to publish track: {error}"))
+        .context("publishing a track")
     }
 
     pub async fn unpublish_track(
@@ -172,7 +172,7 @@ impl LocalParticipant {
         Tokio::spawn(cx, async move { participant.unpublish_track(&sid).await })?
             .await?
             .map(LocalTrackPublication)
-            .map_err(|error| anyhow::anyhow!("failed to unpublish track: {error}"))
+            .context("unpublishing a track")
     }
 }
 

crates/livekit_client/src/livekit_client/playback.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 
 use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _};
 use futures::channel::mpsc::UnboundedSender;
@@ -365,14 +365,14 @@ fn default_device(input: bool) -> Result<(cpal::Device, cpal::SupportedStreamCon
     if input {
         device = cpal::default_host()
             .default_input_device()
-            .ok_or_else(|| anyhow!("no audio input device available"))?;
+            .context("no audio input device available")?;
         config = device
             .default_input_config()
             .context("failed to get default input config")?;
     } else {
         device = cpal::default_host()
             .default_output_device()
-            .ok_or_else(|| anyhow!("no audio output device available"))?;
+            .context("no audio output device available")?;
         config = device
             .default_output_config()
             .context("failed to get default output config")?;
@@ -412,7 +412,7 @@ impl libwebrtc::native::audio_mixer::AudioMixerSource for AudioMixerSource {
         self.sample_rate
     }
 
-    fn get_audio_frame_with_info<'a>(&self, target_sample_rate: u32) -> Option<AudioFrame> {
+    fn get_audio_frame_with_info<'a>(&self, target_sample_rate: u32) -> Option<AudioFrame<'_>> {
         assert_eq!(self.sample_rate, target_sample_rate);
         let buf = self.buffer.lock().pop_front()?;
         Some(AudioFrame {
@@ -493,10 +493,7 @@ fn create_buffer_pool(
     ]);
 
     pixel_buffer_pool::CVPixelBufferPool::new(None, Some(&buffer_attributes)).map_err(|cv_return| {
-        anyhow!(
-            "failed to create pixel buffer pool: CVReturn({})",
-            cv_return
-        )
+        anyhow::anyhow!("failed to create pixel buffer pool: CVReturn({cv_return})",)
     })
 }
 
@@ -707,7 +704,7 @@ mod macos {
     }
 
     impl super::DeviceChangeListenerApi for CoreAudioDefaultDeviceChangeListener {
-        fn new(input: bool) -> gpui::Result<Self> {
+        fn new(input: bool) -> anyhow::Result<Self> {
             let (tx, rx) = futures::channel::mpsc::unbounded();
 
             let callback = Box::new(PropertyListenerCallbackWrapper(Box::new(move || {

crates/livekit_client/src/test.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{AudioStream, Participant, RemoteTrack, RoomEvent, TrackPublication};
 
 use crate::mock_client::{participant::*, publication::*, track::*};
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use async_trait::async_trait;
 use collections::{BTreeMap, HashMap, HashSet, btree_map::Entry as BTreeEntry, hash_map::Entry};
 use gpui::{App, AsyncApp, BackgroundExecutor};
@@ -69,7 +69,7 @@ impl TestServer {
             e.insert(server.clone());
             Ok(server)
         } else {
-            Err(anyhow!("a server with url {:?} already exists", url))
+            anyhow::bail!("a server with url {url:?} already exists");
         }
     }
 
@@ -77,7 +77,7 @@ impl TestServer {
         Ok(SERVERS
             .lock()
             .get(url)
-            .ok_or_else(|| anyhow!("no server found for url"))?
+            .context("no server found for url")?
             .clone())
     }
 
@@ -85,7 +85,7 @@ impl TestServer {
         SERVERS
             .lock()
             .remove(&self.url)
-            .ok_or_else(|| anyhow!("server with url {:?} does not exist", self.url))?;
+            .with_context(|| format!("server with url {:?} does not exist", self.url))?;
         Ok(())
     }
 
@@ -103,7 +103,7 @@ impl TestServer {
             e.insert(Default::default());
             Ok(())
         } else {
-            Err(anyhow!("room {:?} already exists", room))
+            anyhow::bail!("{room:?} already exists");
         }
     }
 
@@ -113,7 +113,7 @@ impl TestServer {
         let mut server_rooms = self.rooms.lock();
         server_rooms
             .remove(&room)
-            .ok_or_else(|| anyhow!("room {:?} does not exist", room))?;
+            .with_context(|| format!("room {room:?} does not exist"))?;
         Ok(())
     }
 
@@ -176,11 +176,7 @@ impl TestServer {
             e.insert(client_room);
             Ok(identity)
         } else {
-            Err(anyhow!(
-                "{:?} attempted to join room {:?} twice",
-                identity,
-                room_name
-            ))
+            anyhow::bail!("{identity:?} attempted to join room {room_name:?} twice");
         }
     }
 
@@ -193,13 +189,9 @@ impl TestServer {
         let mut server_rooms = self.rooms.lock();
         let room = server_rooms
             .get_mut(&*room_name)
-            .ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
-        room.client_rooms.remove(&identity).ok_or_else(|| {
-            anyhow!(
-                "{:?} attempted to leave room {:?} before joining it",
-                identity,
-                room_name
-            )
+            .with_context(|| format!("room {room_name:?} does not exist"))?;
+        room.client_rooms.remove(&identity).with_context(|| {
+            format!("{identity:?} attempted to leave room {room_name:?} before joining it")
         })?;
         Ok(())
     }
@@ -247,14 +239,10 @@ impl TestServer {
         let mut server_rooms = self.rooms.lock();
         let room = server_rooms
             .get_mut(&room_name)
-            .ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
-        room.client_rooms.remove(&identity).ok_or_else(|| {
-            anyhow!(
-                "participant {:?} did not join room {:?}",
-                identity,
-                room_name
-            )
-        })?;
+            .with_context(|| format!("room {room_name} does not exist"))?;
+        room.client_rooms
+            .remove(&identity)
+            .with_context(|| format!("participant {identity:?} did not join room {room_name:?}"))?;
         Ok(())
     }
 
@@ -269,7 +257,7 @@ impl TestServer {
         let mut server_rooms = self.rooms.lock();
         let room = server_rooms
             .get_mut(&room_name)
-            .ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
+            .with_context(|| format!("room {room_name} does not exist"))?;
         room.participant_permissions
             .insert(ParticipantIdentity(identity), permission);
         Ok(())
@@ -308,7 +296,7 @@ impl TestServer {
         let mut server_rooms = self.rooms.lock();
         let room = server_rooms
             .get_mut(&*room_name)
-            .ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
+            .with_context(|| format!("room {room_name} does not exist"))?;
 
         let can_publish = room
             .participant_permissions
@@ -317,9 +305,7 @@ impl TestServer {
             .or(claims.video.can_publish)
             .unwrap_or(true);
 
-        if !can_publish {
-            return Err(anyhow!("user is not allowed to publish"));
-        }
+        anyhow::ensure!(can_publish, "user is not allowed to publish");
 
         let sid: TrackSid = format!("TR_{}", nanoid::nanoid!(17)).try_into().unwrap();
         let server_track = Arc::new(TestServerVideoTrack {
@@ -374,7 +360,7 @@ impl TestServer {
         let mut server_rooms = self.rooms.lock();
         let room = server_rooms
             .get_mut(&*room_name)
-            .ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
+            .with_context(|| format!("room {room_name} does not exist"))?;
 
         let can_publish = room
             .participant_permissions
@@ -383,9 +369,7 @@ impl TestServer {
             .or(claims.video.can_publish)
             .unwrap_or(true);
 
-        if !can_publish {
-            return Err(anyhow!("user is not allowed to publish"));
-        }
+        anyhow::ensure!(can_publish, "user is not allowed to publish");
 
         let sid: TrackSid = format!("TR_{}", nanoid::nanoid!(17)).try_into().unwrap();
         let server_track = Arc::new(TestServerAudioTrack {
@@ -443,7 +427,7 @@ impl TestServer {
         let mut server_rooms = self.rooms.lock();
         let room = server_rooms
             .get_mut(&*room_name)
-            .ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
+            .with_context(|| format!("room {room_name} does not exist"))?;
         if let Some(track) = room
             .audio_tracks
             .iter_mut()
@@ -513,11 +497,11 @@ impl TestServer {
         let mut server_rooms = self.rooms.lock();
         let room = server_rooms
             .get_mut(&*room_name)
-            .ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
+            .with_context(|| format!("room {room_name} does not exist"))?;
         let client_room = room
             .client_rooms
             .get(&identity)
-            .ok_or_else(|| anyhow!("not a participant in room"))?;
+            .context("not a participant in room")?;
         Ok(room
             .video_tracks
             .iter()
@@ -536,11 +520,11 @@ impl TestServer {
         let mut server_rooms = self.rooms.lock();
         let room = server_rooms
             .get_mut(&*room_name)
-            .ok_or_else(|| anyhow!("room {} does not exist", room_name))?;
+            .with_context(|| format!("room {room_name} does not exist"))?;
         let client_room = room
             .client_rooms
             .get(&identity)
-            .ok_or_else(|| anyhow!("not a participant in room"))?;
+            .context("not a participant in room")?;
         Ok(room
             .audio_tracks
             .iter()

crates/lmstudio/src/lmstudio.rs 🔗

@@ -1,9 +1,9 @@
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
 use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, http};
 use serde::{Deserialize, Serialize};
-use serde_json::{Value, value::RawValue};
-use std::{convert::TryFrom, sync::Arc, time::Duration};
+use serde_json::Value;
+use std::{convert::TryFrom, time::Duration};
 
 pub const LMSTUDIO_API_URL: &str = "http://localhost:1234/api/v0";
 
@@ -25,7 +25,7 @@ impl TryFrom<String> for Role {
             "assistant" => Ok(Self::Assistant),
             "system" => Ok(Self::System),
             "tool" => Ok(Self::Tool),
-            _ => Err(anyhow!("invalid role '{value}'")),
+            _ => anyhow::bail!("invalid role '{value}'"),
         }
     }
 }
@@ -46,15 +46,25 @@ impl From<Role> for String {
 pub struct Model {
     pub name: String,
     pub display_name: Option<String>,
-    pub max_tokens: usize,
+    pub max_tokens: u64,
+    pub supports_tool_calls: bool,
+    pub supports_images: bool,
 }
 
 impl Model {
-    pub fn new(name: &str, display_name: Option<&str>, max_tokens: Option<usize>) -> Self {
+    pub fn new(
+        name: &str,
+        display_name: Option<&str>,
+        max_tokens: Option<u64>,
+        supports_tool_calls: bool,
+        supports_images: bool,
+    ) -> Self {
         Self {
             name: name.to_owned(),
             display_name: display_name.map(|s| s.to_owned()),
             max_tokens: max_tokens.unwrap_or(2048),
+            supports_tool_calls,
+            supports_images,
         }
     }
 
@@ -66,50 +76,132 @@ impl Model {
         self.display_name.as_ref().unwrap_or(&self.name)
     }
 
-    pub fn max_token_count(&self) -> usize {
+    pub fn max_token_count(&self) -> u64 {
         self.max_tokens
     }
+
+    pub fn supports_tool_calls(&self) -> bool {
+        self.supports_tool_calls
+    }
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum ToolChoice {
+    Auto,
+    Required,
+    None,
+    Other(ToolDefinition),
 }
+
+#[derive(Clone, Deserialize, Serialize, Debug)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum ToolDefinition {
+    #[allow(dead_code)]
+    Function { function: FunctionDefinition },
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct FunctionDefinition {
+    pub name: String,
+    pub description: Option<String>,
+    pub parameters: Option<Value>,
+}
+
 #[derive(Serialize, Deserialize, Debug)]
 #[serde(tag = "role", rename_all = "lowercase")]
 pub enum ChatMessage {
     Assistant {
         #[serde(default)]
-        content: Option<String>,
-        #[serde(default)]
-        tool_calls: Option<Vec<LmStudioToolCall>>,
+        content: Option<MessageContent>,
+        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+        tool_calls: Vec<ToolCall>,
     },
     User {
-        content: String,
+        content: MessageContent,
     },
     System {
-        content: String,
+        content: MessageContent,
+    },
+    Tool {
+        content: MessageContent,
+        tool_call_id: String,
     },
 }
 
-#[derive(Serialize, Deserialize, Debug)]
-#[serde(rename_all = "lowercase")]
-pub enum LmStudioToolCall {
-    Function(LmStudioFunctionCall),
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+#[serde(untagged)]
+pub enum MessageContent {
+    Plain(String),
+    Multipart(Vec<MessagePart>),
 }
 
-#[derive(Serialize, Deserialize, Debug)]
-pub struct LmStudioFunctionCall {
-    pub name: String,
-    pub arguments: Box<RawValue>,
+impl MessageContent {
+    pub fn empty() -> Self {
+        MessageContent::Multipart(vec![])
+    }
+
+    pub fn push_part(&mut self, part: MessagePart) {
+        match self {
+            MessageContent::Plain(text) => {
+                *self =
+                    MessageContent::Multipart(vec![MessagePart::Text { text: text.clone() }, part]);
+            }
+            MessageContent::Multipart(parts) if parts.is_empty() => match part {
+                MessagePart::Text { text } => *self = MessageContent::Plain(text),
+                MessagePart::Image { .. } => *self = MessageContent::Multipart(vec![part]),
+            },
+            MessageContent::Multipart(parts) => parts.push(part),
+        }
+    }
+}
+
+impl From<Vec<MessagePart>> for MessageContent {
+    fn from(mut parts: Vec<MessagePart>) -> Self {
+        if let [MessagePart::Text { text }] = parts.as_mut_slice() {
+            MessageContent::Plain(std::mem::take(text))
+        } else {
+            MessageContent::Multipart(parts)
+        }
+    }
 }
 
 #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
-pub struct LmStudioFunctionTool {
-    pub name: String,
-    pub description: Option<String>,
-    pub parameters: Option<Value>,
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum MessagePart {
+    Text {
+        text: String,
+    },
+    #[serde(rename = "image_url")]
+    Image {
+        image_url: ImageUrl,
+    },
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
+pub struct ImageUrl {
+    pub url: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub detail: Option<String>,
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+pub struct ToolCall {
+    pub id: String,
+    #[serde(flatten)]
+    pub content: ToolCallContent,
 }
 
 #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
 #[serde(tag = "type", rename_all = "lowercase")]
-pub enum LmStudioTool {
-    Function { function: LmStudioFunctionTool },
+pub enum ToolCallContent {
+    Function { function: FunctionContent },
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+pub struct FunctionContent {
+    pub name: String,
+    pub arguments: String,
 }
 
 #[derive(Serialize, Debug)]
@@ -117,10 +209,16 @@ pub struct ChatCompletionRequest {
     pub model: String,
     pub messages: Vec<ChatMessage>,
     pub stream: bool,
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub max_tokens: Option<i32>,
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub stop: Option<Vec<String>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub temperature: Option<f32>,
-    pub tools: Vec<LmStudioTool>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    pub tools: Vec<ToolDefinition>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub tool_choice: Option<ToolChoice>,
 }
 
 #[derive(Serialize, Deserialize, Debug)]
@@ -135,8 +233,7 @@ pub struct ChatResponse {
 #[derive(Serialize, Deserialize, Debug)]
 pub struct ChoiceDelta {
     pub index: u32,
-    #[serde(default)]
-    pub delta: serde_json::Value,
+    pub delta: ResponseMessageDelta,
     pub finish_reason: Option<String>,
 }
 
@@ -159,9 +256,23 @@ pub struct FunctionChunk {
 
 #[derive(Serialize, Deserialize, Debug)]
 pub struct Usage {
-    pub prompt_tokens: u32,
-    pub completion_tokens: u32,
-    pub total_tokens: u32,
+    pub prompt_tokens: u64,
+    pub completion_tokens: u64,
+    pub total_tokens: u64,
+}
+
+#[derive(Debug, Default, Clone, Deserialize, PartialEq)]
+#[serde(transparent)]
+pub struct Capabilities(Vec<String>);
+
+impl Capabilities {
+    pub fn supports_tool_calls(&self) -> bool {
+        self.0.iter().any(|cap| cap == "tool_use")
+    }
+
+    pub fn supports_images(&self) -> bool {
+        self.0.iter().any(|cap| cap == "vision")
+    }
 }
 
 #[derive(Serialize, Deserialize, Debug)]
@@ -175,16 +286,17 @@ pub enum ResponseStreamResult {
 pub struct ResponseStreamEvent {
     pub created: u32,
     pub model: String,
+    pub object: String,
     pub choices: Vec<ChoiceDelta>,
     pub usage: Option<Usage>,
 }
 
-#[derive(Serialize, Deserialize)]
+#[derive(Deserialize)]
 pub struct ListModelsResponse {
     pub data: Vec<ModelEntry>,
 }
 
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
+#[derive(Clone, Debug, Deserialize, PartialEq)]
 pub struct ModelEntry {
     pub id: String,
     pub object: String,
@@ -194,8 +306,10 @@ pub struct ModelEntry {
     pub compatibility_type: CompatibilityType,
     pub quantization: Option<String>,
     pub state: ModelState,
-    pub max_context_length: Option<u32>,
-    pub loaded_context_length: Option<u32>,
+    pub max_context_length: Option<u64>,
+    pub loaded_context_length: Option<u64>,
+    #[serde(default)]
+    pub capabilities: Capabilities,
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -226,6 +340,8 @@ pub struct ResponseMessageDelta {
     pub role: Option<Role>,
     pub content: Option<String>,
     #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub reasoning_content: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub tool_calls: Option<Vec<ToolCallChunk>>,
 }
 
@@ -253,11 +369,11 @@ pub async fn complete(
         let mut body = Vec::new();
         response.body_mut().read_to_end(&mut body).await?;
         let body_str = std::str::from_utf8(&body)?;
-        Err(anyhow!(
+        anyhow::bail!(
             "Failed to connect to API: {} {}",
             response.status(),
             body_str
-        ))
+        );
     }
 }
 
@@ -265,7 +381,7 @@ pub async fn stream_chat_completion(
     client: &dyn HttpClient,
     api_url: &str,
     request: ChatCompletionRequest,
-) -> Result<BoxStream<'static, Result<ChatResponse>>> {
+) -> Result<BoxStream<'static, Result<ResponseStreamEvent>>> {
     let uri = format!("{api_url}/chat/completions");
     let request_builder = http::Request::builder()
         .method(Method::POST)
@@ -304,12 +420,11 @@ pub async fn stream_chat_completion(
     } else {
         let mut body = String::new();
         response.body_mut().read_to_string(&mut body).await?;
-
-        Err(anyhow!(
+        anyhow::bail!(
             "Failed to connect to LM Studio API: {} {}",
             response.status(),
             body,
-        ))
+        );
     }
 }
 
@@ -331,47 +446,48 @@ pub async fn get_models(
     let mut body = String::new();
     response.body_mut().read_to_string(&mut body).await?;
 
-    if response.status().is_success() {
-        let response: ListModelsResponse =
-            serde_json::from_str(&body).context("Unable to parse LM Studio models response")?;
-        Ok(response.data)
-    } else {
-        Err(anyhow!(
-            "Failed to connect to LM Studio API: {} {}",
-            response.status(),
-            body,
-        ))
-    }
+    anyhow::ensure!(
+        response.status().is_success(),
+        "Failed to connect to LM Studio API: {} {}",
+        response.status(),
+        body,
+    );
+    let response: ListModelsResponse =
+        serde_json::from_str(&body).context("Unable to parse LM Studio models response")?;
+    Ok(response.data)
 }
 
-/// Sends an empty request to LM Studio to trigger loading the model
-pub async fn preload_model(client: Arc<dyn HttpClient>, api_url: &str, model: &str) -> Result<()> {
-    let uri = format!("{api_url}/completions");
-    let request = HttpRequest::builder()
-        .method(Method::POST)
-        .uri(uri)
-        .header("Content-Type", "application/json")
-        .body(AsyncBody::from(serde_json::to_string(
-            &serde_json::json!({
-                "model": model,
-                "messages": [],
-                "stream": false,
-                "max_tokens": 0,
-            }),
-        )?))?;
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_image_message_part_serialization() {
+        let image_part = MessagePart::Image {
+            image_url: ImageUrl {
+                url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==".to_string(),
+                detail: None,
+            },
+        };
+
+        let json = serde_json::to_string(&image_part).unwrap();
+        println!("Serialized image part: {}", json);
+
+        // Verify the structure matches what LM Studio expects
+        let expected_structure = r#"{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="}}"#;
+        assert_eq!(json, expected_structure);
+    }
 
-    let mut response = client.send(request).await?;
+    #[test]
+    fn test_text_message_part_serialization() {
+        let text_part = MessagePart::Text {
+            text: "Hello, world!".to_string(),
+        };
 
-    if response.status().is_success() {
-        Ok(())
-    } else {
-        let mut body = String::new();
-        response.body_mut().read_to_string(&mut body).await?;
+        let json = serde_json::to_string(&text_part).unwrap();
+        println!("Serialized text part: {}", json);
 
-        Err(anyhow!(
-            "Failed to connect to LM Studio API: {} {}",
-            response.status(),
-            body,
-        ))
+        let expected_structure = r#"{"type":"text","text":"Hello, world!"}"#;
+        assert_eq!(json, expected_structure);
     }
 }

crates/lsp/Cargo.toml 🔗

@@ -36,6 +36,6 @@ workspace-hack.workspace = true
 [dev-dependencies]
 async-pipe.workspace = true
 ctor.workspace = true
-env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 util = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/lsp/src/input_handler.rs 🔗

@@ -1,7 +1,7 @@
 use std::str;
 use std::sync::Arc;
 
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use collections::HashMap;
 use futures::{
     AsyncBufReadExt, AsyncRead, AsyncReadExt as _,
@@ -35,7 +35,7 @@ where
         }
 
         if reader.read_until(b'\n', buffer).await? == 0 {
-            return Err(anyhow!("cannot read LSP message headers"));
+            anyhow::bail!("cannot read LSP message headers");
         }
     }
 }
@@ -82,7 +82,7 @@ impl LspStdoutHandler {
                 .split('\n')
                 .find(|line| line.starts_with(CONTENT_LEN_HEADER))
                 .and_then(|line| line.strip_prefix(CONTENT_LEN_HEADER))
-                .ok_or_else(|| anyhow!("invalid LSP message header {headers:?}"))?
+                .with_context(|| format!("invalid LSP message header {headers:?}"))?
                 .trim_end()
                 .parse()?;
 

crates/lsp/src/lsp.rs 🔗

@@ -108,6 +108,12 @@ pub struct LanguageServer {
     root_uri: Url,
 }
 
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub enum LanguageServerSelector {
+    Id(LanguageServerId),
+    Name(LanguageServerName),
+}
+
 /// Identifies a running language server.
 #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
 #[repr(transparent)]
@@ -352,7 +358,7 @@ impl LanguageServer {
         let stdout = server.stdout.take().unwrap();
         let stderr = server.stderr.take().unwrap();
         let root_uri = Url::from_file_path(&working_dir)
-            .map_err(|_| anyhow!("{} is not a valid URI", working_dir.display()))?;
+            .map_err(|()| anyhow!("{working_dir:?} is not a valid URI"))?;
         let server = Self::new_internal(
             server_id,
             server_name,
@@ -603,7 +609,7 @@ impl LanguageServer {
         Ok(())
     }
 
-    pub fn default_initialize_params(&self, cx: &App) -> InitializeParams {
+    pub fn default_initialize_params(&self, pull_diagnostics: bool, cx: &App) -> InitializeParams {
         let workspace_folders = self
             .workspace_folders
             .lock()
@@ -643,8 +649,9 @@ impl LanguageServer {
                         refresh_support: Some(true),
                     }),
                     diagnostic: Some(DiagnosticWorkspaceClientCapabilities {
-                        refresh_support: None,
-                    }),
+                        refresh_support: Some(true),
+                    })
+                    .filter(|_| pull_diagnostics),
                     code_lens: Some(CodeLensWorkspaceClientCapabilities {
                         refresh_support: Some(true),
                     }),
@@ -758,7 +765,12 @@ impl LanguageServer {
                     }),
                     publish_diagnostics: Some(PublishDiagnosticsClientCapabilities {
                         related_information: Some(true),
-                        ..Default::default()
+                        version_support: Some(true),
+                        data_support: Some(true),
+                        tag_support: Some(TagSupport {
+                            value_set: vec![DiagnosticTag::UNNECESSARY, DiagnosticTag::DEPRECATED],
+                        }),
+                        code_description_support: Some(true),
                     }),
                     formatting: Some(DynamicRegistrationClientCapabilities {
                         dynamic_registration: Some(true),
@@ -793,6 +805,14 @@ impl LanguageServer {
                         hierarchical_document_symbol_support: Some(true),
                         ..DocumentSymbolClientCapabilities::default()
                     }),
+                    diagnostic: Some(DiagnosticClientCapabilities {
+                        dynamic_registration: Some(false),
+                        related_document_support: Some(true),
+                    })
+                    .filter(|_| pull_diagnostics),
+                    color_provider: Some(DocumentColorClientCapabilities {
+                        dynamic_registration: Some(false),
+                    }),
                     ..TextDocumentClientCapabilities::default()
                 }),
                 experimental: Some(json!({
@@ -1131,8 +1151,6 @@ impl LanguageServer {
     where
         T::Result: 'static + Send,
         T: request::Request,
-        // TODO kb
-        // <T as lsp_types::request::Request>::Result: ConnectionResult,
     {
         let id = next_id.fetch_add(1, SeqCst);
         let message = serde_json::to_string(&Request {
@@ -1581,7 +1599,7 @@ impl FakeLanguageServer {
         T: 'static + request::Request,
         T::Params: 'static + Send,
         F: 'static + Send + FnMut(T::Params, gpui::AsyncApp) -> Fut,
-        Fut: 'static + Send + Future<Output = Result<T::Result>>,
+        Fut: 'static + Future<Output = Result<T::Result>>,
     {
         let (responded_tx, responded_rx) = futures::channel::mpsc::unbounded();
         self.server.remove_request_handler::<T>();
@@ -1670,9 +1688,7 @@ mod tests {
 
     #[ctor::ctor]
     fn init_logger() {
-        if std::env::var("RUST_LOG").is_ok() {
-            env_logger::init();
-        }
+        zlog::init_test();
     }
 
     #[gpui::test]
@@ -1707,7 +1723,7 @@ mod tests {
 
         let server = cx
             .update(|cx| {
-                let params = server.default_initialize_params(cx);
+                let params = server.default_initialize_params(false, cx);
                 let configuration = DidChangeConfigurationParams {
                     settings: Default::default(),
                 };

crates/markdown/Cargo.toml 🔗

@@ -19,8 +19,8 @@ test-support = [
 ]
 
 [dependencies]
-anyhow.workspace = true
 base64.workspace = true
+futures.workspace = true
 gpui.workspace = true
 language.workspace = true
 linkify.workspace = true

crates/markdown/examples/markdown.rs 🔗

@@ -67,14 +67,8 @@ struct MarkdownExample {
 
 impl MarkdownExample {
     pub fn new(text: SharedString, language_registry: Arc<LanguageRegistry>, cx: &mut App) -> Self {
-        let markdown = cx.new(|cx| {
-            Markdown::new(
-                text,
-                Some(language_registry),
-                Some("TypeScript".to_string()),
-                cx,
-            )
-        });
+        let markdown = cx
+            .new(|cx| Markdown::new(text, Some(language_registry), Some("TypeScript".into()), cx));
         Self { markdown }
     }
 }
@@ -113,11 +107,7 @@ impl Render for MarkdownExample {
                 ..Default::default()
             },
             syntax: cx.theme().syntax().clone(),
-            selection_background_color: {
-                let mut selection = cx.theme().players().local().selection;
-                selection.fade_out(0.7);
-                selection
-            },
+            selection_background_color: cx.theme().colors().element_selection_background,
             ..Default::default()
         };
 

crates/markdown/examples/markdown_as_child.rs 🔗

@@ -91,11 +91,7 @@ impl Render for HelloWorld {
                 ..Default::default()
             },
             syntax: cx.theme().syntax().clone(),
-            selection_background_color: {
-                let mut selection = cx.theme().players().local().selection;
-                selection.fade_out(0.7);
-                selection
-            },
+            selection_background_color: cx.theme().colors().element_selection_background,
             heading: Default::default(),
             ..Default::default()
         };

crates/markdown/src/markdown.rs 🔗

@@ -2,6 +2,9 @@ pub mod parser;
 mod path_range;
 
 use base64::Engine as _;
+use futures::FutureExt as _;
+use gpui::HitboxBehavior;
+use language::LanguageName;
 use log::Level;
 pub use path_range::{LineCol, PathWithRange};
 
@@ -30,7 +33,7 @@ use pulldown_cmark::Alignment;
 use sum_tree::TreeMap;
 use theme::SyntaxTheme;
 use ui::{Tooltip, prelude::*};
-use util::{ResultExt, TryFutureExt};
+use util::ResultExt;
 
 use crate::parser::CodeBlockKind;
 
@@ -98,10 +101,10 @@ pub struct Markdown {
     parsed_markdown: ParsedMarkdown,
     images_by_source_offset: HashMap<usize, Arc<Image>>,
     should_reparse: bool,
-    pending_parse: Option<Task<Option<()>>>,
+    pending_parse: Option<Task<()>>,
     focus_handle: FocusHandle,
     language_registry: Option<Arc<LanguageRegistry>>,
-    fallback_code_block_language: Option<String>,
+    fallback_code_block_language: Option<LanguageName>,
     options: Options,
     copied_code_blocks: HashSet<ElementId>,
 }
@@ -144,7 +147,7 @@ impl Markdown {
     pub fn new(
         source: SharedString,
         language_registry: Option<Arc<LanguageRegistry>>,
-        fallback_code_block_language: Option<String>,
+        fallback_code_block_language: Option<LanguageName>,
         cx: &mut Context<Self>,
     ) -> Self {
         let focus_handle = cx.focus_handle();
@@ -192,6 +195,10 @@ impl Markdown {
         this
     }
 
+    pub fn is_parsing(&self) -> bool {
+        self.pending_parse.is_some()
+    }
+
     pub fn source(&self) -> &str {
         &self.source
     }
@@ -219,11 +226,12 @@ impl Markdown {
         self.parse(cx);
     }
 
+    #[cfg(any(test, feature = "test-support"))]
     pub fn parsed_markdown(&self) -> &ParsedMarkdown {
         &self.parsed_markdown
     }
 
-    pub fn escape(s: &str) -> Cow<str> {
+    pub fn escape(s: &str) -> Cow<'_, str> {
         // Valid to use bytes since multi-byte UTF-8 doesn't use ASCII chars.
         let count = s
             .bytes()
@@ -275,14 +283,19 @@ impl Markdown {
             self.should_reparse = true;
             return;
         }
+        self.should_reparse = false;
+        self.pending_parse = Some(self.start_background_parse(cx));
+    }
 
+    fn start_background_parse(&self, cx: &Context<Self>) -> Task<()> {
         let source = self.source.clone();
         let should_parse_links_only = self.options.parse_links_only;
         let language_registry = self.language_registry.clone();
         let fallback = self.fallback_code_block_language.clone();
+
         let parsed = cx.background_spawn(async move {
             if should_parse_links_only {
-                return anyhow::Ok((
+                return (
                     ParsedMarkdown {
                         events: Arc::from(parse_links_only(source.as_ref())),
                         source,
@@ -290,8 +303,9 @@ impl Markdown {
                         languages_by_path: TreeMap::default(),
                     },
                     Default::default(),
-                ));
+                );
             }
+
             let (events, language_names, paths) = parse_markdown(&source);
             let mut images_by_source_offset = HashMap::default();
             let mut languages_by_name = TreeMap::default();
@@ -299,9 +313,9 @@ impl Markdown {
             if let Some(registry) = language_registry.as_ref() {
                 for name in language_names {
                     let language = if !name.is_empty() {
-                        registry.language_for_name_or_extension(&name)
+                        registry.language_for_name_or_extension(&name).left_future()
                     } else if let Some(fallback) = &fallback {
-                        registry.language_for_name_or_extension(fallback)
+                        registry.language_for_name(fallback.as_ref()).right_future()
                     } else {
                         continue;
                     };
@@ -343,7 +357,7 @@ impl Markdown {
                 }
             }
 
-            anyhow::Ok((
+            (
                 ParsedMarkdown {
                     source,
                     events: Arc::from(events),
@@ -351,29 +365,23 @@ impl Markdown {
                     languages_by_path,
                 },
                 images_by_source_offset,
-            ))
+            )
         });
 
-        self.should_reparse = false;
-        self.pending_parse = Some(cx.spawn(async move |this, cx| {
-            async move {
-                let (parsed, images_by_source_offset) = parsed.await?;
-
-                this.update(cx, |this, cx| {
-                    this.parsed_markdown = parsed;
-                    this.images_by_source_offset = images_by_source_offset;
-                    this.pending_parse.take();
-                    if this.should_reparse {
-                        this.parse(cx);
-                    }
-                    cx.notify();
-                })
-                .ok();
-                anyhow::Ok(())
-            }
-            .log_err()
-            .await
-        }));
+        cx.spawn(async move |this, cx| {
+            let (parsed, images_by_source_offset) = parsed.await;
+
+            this.update(cx, |this, cx| {
+                this.parsed_markdown = parsed;
+                this.images_by_source_offset = images_by_source_offset;
+                this.pending_parse.take();
+                if this.should_reparse {
+                    this.parse(cx);
+                }
+                cx.refresh_windows();
+            })
+            .ok();
+        })
     }
 }
 
@@ -496,7 +504,6 @@ impl MarkdownElement {
         let selection = self.markdown.read(cx).selection;
         let selection_start = rendered_text.position_for_source_index(selection.start);
         let selection_end = rendered_text.position_for_source_index(selection.end);
-
         if let Some(((start_position, start_line_height), (end_position, end_line_height))) =
             selection_start.zip(selection_end)
         {
@@ -568,9 +575,9 @@ impl MarkdownElement {
                 .is_some();
 
         if is_hovering_link {
-            window.set_cursor_style(CursorStyle::PointingHand, Some(hitbox));
+            window.set_cursor_style(CursorStyle::PointingHand, hitbox);
         } else {
-            window.set_cursor_style(CursorStyle::IBeam, Some(hitbox));
+            window.set_cursor_style(CursorStyle::IBeam, hitbox);
         }
 
         let on_open_url = self.on_url_click.take();
@@ -715,9 +722,14 @@ impl Element for MarkdownElement {
         None
     }
 
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
     fn request_layout(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
         window: &mut Window,
         cx: &mut App,
     ) -> (gpui::LayoutId, Self::RequestLayoutState) {
@@ -1189,6 +1201,7 @@ impl Element for MarkdownElement {
     fn prepaint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
         bounds: Bounds<Pixels>,
         rendered_markdown: &mut Self::RequestLayoutState,
         window: &mut Window,
@@ -1196,8 +1209,9 @@ impl Element for MarkdownElement {
     ) -> Self::PrepaintState {
         let focus_handle = self.markdown.read(cx).focus_handle.clone();
         window.set_focus_handle(&focus_handle, cx);
+        window.set_view_id(self.markdown.entity_id());
 
-        let hitbox = window.insert_hitbox(bounds, false);
+        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
         rendered_markdown.element.prepaint(window, cx);
         self.autoscroll(&rendered_markdown.text, window, cx);
         hitbox
@@ -1206,6 +1220,7 @@ impl Element for MarkdownElement {
     fn paint(
         &mut self,
         _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
         bounds: Bounds<Pixels>,
         rendered_markdown: &mut Self::RequestLayoutState,
         hitbox: &mut Self::PrepaintState,

crates/markdown/src/parser.rs 🔗

@@ -33,7 +33,7 @@ pub fn parse_markdown(
     let mut parser = Parser::new_ext(text, PARSE_OPTIONS)
         .into_offset_iter()
         .peekable();
-    while let Some((pulldown_event, mut range)) = parser.next() {
+    while let Some((pulldown_event, range)) = parser.next() {
         if within_metadata {
             if let pulldown_cmark::Event::End(pulldown_cmark::TagEnd::MetadataBlock { .. }) =
                 pulldown_event
@@ -303,9 +303,10 @@ pub fn parse_markdown(
                 }
             }
             pulldown_cmark::Event::Code(_) => {
-                range.start += 1;
-                range.end -= 1;
-                events.push((range, MarkdownEvent::Code))
+                let content_range = extract_code_content_range(&text[range.clone()]);
+                let content_range =
+                    content_range.start + range.start..content_range.end + range.start;
+                events.push((content_range, MarkdownEvent::Code))
             }
             pulldown_cmark::Event::Html(_) => events.push((range, MarkdownEvent::Html)),
             pulldown_cmark::Event::InlineHtml(_) => events.push((range, MarkdownEvent::InlineHtml)),
@@ -497,6 +498,27 @@ pub struct CodeBlockMetadata {
     pub line_count: usize,
 }
 
+fn extract_code_content_range(text: &str) -> Range<usize> {
+    let text_len = text.len();
+    if text_len == 0 {
+        return 0..0;
+    }
+
+    let start_ticks = text.chars().take_while(|&c| c == '`').count();
+
+    if start_ticks == 0 || start_ticks > text_len {
+        return 0..text_len;
+    }
+
+    let end_ticks = text.chars().rev().take_while(|&c| c == '`').count();
+
+    if end_ticks != start_ticks || text_len < start_ticks + end_ticks {
+        return 0..text_len;
+    }
+
+    start_ticks..text_len - end_ticks
+}
+
 pub(crate) fn extract_code_block_content_range(text: &str) -> Range<usize> {
     let mut range = 0..text.len();
     if text.starts_with("```") {
@@ -679,6 +701,24 @@ mod tests {
         )
     }
 
+    #[test]
+    fn test_extract_code_content_range() {
+        let input = "```let x = 5;```";
+        assert_eq!(extract_code_content_range(input), 3..13);
+
+        let input = "``let x = 5;``";
+        assert_eq!(extract_code_content_range(input), 2..12);
+
+        let input = "`let x = 5;`";
+        assert_eq!(extract_code_content_range(input), 1..11);
+
+        let input = "plain text";
+        assert_eq!(extract_code_content_range(input), 0..10);
+
+        let input = "``let x = 5;`";
+        assert_eq!(extract_code_content_range(input), 0..13);
+    }
+
     #[test]
     fn test_extract_code_block_content_range() {
         let input = "```rust\nlet x = 5;\n```";

crates/markdown_preview/Cargo.toml 🔗

@@ -31,6 +31,7 @@ ui.workspace = true
 util.workspace = true
 workspace.workspace = true
 workspace-hack.workspace = true
+fs.workspace = true
 
 [dev-dependencies]
 editor = { workspace = true, features = ["test-support"] }

crates/markdown_preview/src/markdown_parser.rs 🔗

@@ -72,25 +72,25 @@ impl<'a> MarkdownParser<'a> {
         self.cursor >= self.tokens.len() - 1
     }
 
-    fn peek(&self, steps: usize) -> Option<&(Event, Range<usize>)> {
+    fn peek(&self, steps: usize) -> Option<&(Event<'_>, Range<usize>)> {
         if self.eof() || (steps + self.cursor) >= self.tokens.len() {
             return self.tokens.last();
         }
         return self.tokens.get(self.cursor + steps);
     }
 
-    fn previous(&self) -> Option<&(Event, Range<usize>)> {
+    fn previous(&self) -> Option<&(Event<'_>, Range<usize>)> {
         if self.cursor == 0 || self.cursor > self.tokens.len() {
             return None;
         }
         return self.tokens.get(self.cursor - 1);
     }
 
-    fn current(&self) -> Option<&(Event, Range<usize>)> {
+    fn current(&self) -> Option<&(Event<'_>, Range<usize>)> {
         return self.peek(0);
     }
 
-    fn current_event(&self) -> Option<&Event> {
+    fn current_event(&self) -> Option<&Event<'_>> {
         return self.current().map(|(event, _)| event);
     }
 

crates/markdown_preview/src/markdown_preview.rs 🔗

@@ -6,7 +6,10 @@ pub mod markdown_parser;
 pub mod markdown_preview_view;
 pub mod markdown_renderer;
 
-actions!(markdown, [OpenPreview, OpenPreviewToTheSide]);
+actions!(
+    markdown,
+    [OpenPreview, OpenPreviewToTheSide, OpenFollowingPreview]
+);
 
 pub fn init(cx: &mut App) {
     cx.observe_new(|workspace: &mut Workspace, window, cx| {

crates/markdown_preview/src/markdown_preview_view.rs 🔗

@@ -4,7 +4,7 @@ use std::{ops::Range, path::PathBuf};
 
 use anyhow::Result;
 use editor::scroll::Autoscroll;
-use editor::{Editor, EditorEvent};
+use editor::{Editor, EditorEvent, SelectionEffects};
 use gpui::{
     App, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
     IntoElement, ListState, ParentElement, Render, RetainAllImageCache, Styled, Subscription, Task,
@@ -17,10 +17,9 @@ use ui::prelude::*;
 use workspace::item::{Item, ItemHandle};
 use workspace::{Pane, Workspace};
 
-use crate::OpenPreviewToTheSide;
 use crate::markdown_elements::ParsedMarkdownElement;
 use crate::{
-    OpenPreview,
+    OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide,
     markdown_elements::ParsedMarkdown,
     markdown_parser::parse_markdown,
     markdown_renderer::{RenderContext, render_markdown_block},
@@ -36,9 +35,9 @@ pub struct MarkdownPreviewView {
     contents: Option<ParsedMarkdown>,
     selected_block: usize,
     list_state: ListState,
-    tab_content_text: SharedString,
     language_registry: Arc<LanguageRegistry>,
     parsing_markdown_task: Option<Task<Result<()>>>,
+    mode: MarkdownPreviewMode,
 }
 
 #[derive(Clone, Copy, Debug, PartialEq)]
@@ -58,9 +57,11 @@ impl MarkdownPreviewView {
     pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context<Workspace>) {
         workspace.register_action(move |workspace, _: &OpenPreview, window, cx| {
             if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
-                let view = Self::create_markdown_view(workspace, editor, window, cx);
+                let view = Self::create_markdown_view(workspace, editor.clone(), window, cx);
                 workspace.active_pane().update(cx, |pane, cx| {
-                    if let Some(existing_view_idx) = Self::find_existing_preview_item_idx(pane) {
+                    if let Some(existing_view_idx) =
+                        Self::find_existing_independent_preview_item_idx(pane, &editor, cx)
+                    {
                         pane.activate_item(existing_view_idx, true, true, window, cx);
                     } else {
                         pane.add_item(Box::new(view.clone()), true, true, None, window, cx)
@@ -84,7 +85,9 @@ impl MarkdownPreviewView {
                         )
                     });
                 pane.update(cx, |pane, cx| {
-                    if let Some(existing_view_idx) = Self::find_existing_preview_item_idx(pane) {
+                    if let Some(existing_view_idx) =
+                        Self::find_existing_independent_preview_item_idx(pane, &editor, cx)
+                    {
                         pane.activate_item(existing_view_idx, true, true, window, cx);
                     } else {
                         pane.add_item(Box::new(view.clone()), false, false, None, window, cx)
@@ -94,11 +97,49 @@ impl MarkdownPreviewView {
                 cx.notify();
             }
         });
+
+        workspace.register_action(move |workspace, _: &OpenFollowingPreview, window, cx| {
+            if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) {
+                // Check if there's already a following preview
+                let existing_follow_view_idx = {
+                    let active_pane = workspace.active_pane().read(cx);
+                    active_pane
+                        .items_of_type::<MarkdownPreviewView>()
+                        .find(|view| view.read(cx).mode == MarkdownPreviewMode::Follow)
+                        .and_then(|view| active_pane.index_for_item(&view))
+                };
+
+                if let Some(existing_follow_view_idx) = existing_follow_view_idx {
+                    workspace.active_pane().update(cx, |pane, cx| {
+                        pane.activate_item(existing_follow_view_idx, true, true, window, cx);
+                    });
+                } else {
+                    let view =
+                        Self::create_following_markdown_view(workspace, editor.clone(), window, cx);
+                    workspace.active_pane().update(cx, |pane, cx| {
+                        pane.add_item(Box::new(view.clone()), true, true, None, window, cx)
+                    });
+                }
+                cx.notify();
+            }
+        });
     }
 
-    fn find_existing_preview_item_idx(pane: &Pane) -> Option<usize> {
+    fn find_existing_independent_preview_item_idx(
+        pane: &Pane,
+        editor: &Entity<Editor>,
+        cx: &App,
+    ) -> Option<usize> {
         pane.items_of_type::<MarkdownPreviewView>()
-            .nth(0)
+            .find(|view| {
+                let view_read = view.read(cx);
+                // Only look for independent (Default mode) previews, not Follow previews
+                view_read.mode == MarkdownPreviewMode::Default
+                    && view_read
+                        .active_editor
+                        .as_ref()
+                        .is_some_and(|active_editor| active_editor.editor == *editor)
+            })
             .and_then(|view| pane.index_for_item(&view))
     }
 
@@ -122,6 +163,24 @@ impl MarkdownPreviewView {
         editor: Entity<Editor>,
         window: &mut Window,
         cx: &mut Context<Workspace>,
+    ) -> Entity<MarkdownPreviewView> {
+        let language_registry = workspace.project().read(cx).languages().clone();
+        let workspace_handle = workspace.weak_handle();
+        MarkdownPreviewView::new(
+            MarkdownPreviewMode::Default,
+            editor,
+            workspace_handle,
+            language_registry,
+            window,
+            cx,
+        )
+    }
+
+    fn create_following_markdown_view(
+        workspace: &mut Workspace,
+        editor: Entity<Editor>,
+        window: &mut Window,
+        cx: &mut Context<Workspace>,
     ) -> Entity<MarkdownPreviewView> {
         let language_registry = workspace.project().read(cx).languages().clone();
         let workspace_handle = workspace.weak_handle();
@@ -130,7 +189,6 @@ impl MarkdownPreviewView {
             editor,
             workspace_handle,
             language_registry,
-            "Markdown Preview".into(),
             window,
             cx,
         )
@@ -141,7 +199,6 @@ impl MarkdownPreviewView {
         active_editor: Entity<Editor>,
         workspace: WeakEntity<Workspace>,
         language_registry: Arc<LanguageRegistry>,
-        tab_content_text: SharedString,
         window: &mut Window,
         cx: &mut Context<Workspace>,
     ) -> Entity<Self> {
@@ -262,10 +319,10 @@ impl MarkdownPreviewView {
                 workspace: workspace.clone(),
                 contents: None,
                 list_state,
-                tab_content_text,
                 language_registry,
                 parsing_markdown_task: None,
                 image_cache: RetainAllImageCache::new(cx),
+                mode,
             };
 
             this.set_editor(active_editor, window, cx);
@@ -342,9 +399,6 @@ impl MarkdownPreviewView {
             },
         );
 
-        let tab_content = editor.read(cx).tab_content_text(0, cx);
-        self.tab_content_text = format!("Preview {}", tab_content).into();
-
         self.active_editor = Some(EditorState {
             editor,
             _subscription: subscription,
@@ -414,9 +468,12 @@ impl MarkdownPreviewView {
     ) {
         if let Some(state) = &self.active_editor {
             state.editor.update(cx, |editor, cx| {
-                editor.change_selections(Some(Autoscroll::center()), window, cx, |selections| {
-                    selections.select_ranges(vec![selection])
-                });
+                editor.change_selections(
+                    SelectionEffects::scroll(Autoscroll::center()),
+                    window,
+                    cx,
+                    |selections| selections.select_ranges(vec![selection]),
+                );
                 window.focus(&editor.focus_handle(cx));
             });
         }
@@ -481,20 +538,29 @@ impl Focusable for MarkdownPreviewView {
     }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum PreviewEvent {}
-
-impl EventEmitter<PreviewEvent> for MarkdownPreviewView {}
+impl EventEmitter<()> for MarkdownPreviewView {}
 
 impl Item for MarkdownPreviewView {
-    type Event = PreviewEvent;
+    type Event = ();
 
     fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
         Some(Icon::new(IconName::FileDoc))
     }
 
-    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
-        self.tab_content_text.clone()
+    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
+        self.active_editor
+            .as_ref()
+            .and_then(|editor_state| {
+                let buffer = editor_state.editor.read(cx).buffer().read(cx);
+                let buffer = buffer.as_singleton()?;
+                let file = buffer.read(cx).file()?;
+                let local_file = file.as_local()?;
+                local_file
+                    .abs_path(cx)
+                    .file_name()
+                    .map(|name| format!("Preview {}", name.to_string_lossy()).into())
+            })
+            .unwrap_or_else(|| SharedString::from("Markdown Preview"))
     }
 
     fn telemetry_event_text(&self) -> Option<&'static str> {

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -4,6 +4,7 @@ use crate::markdown_elements::{
     ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable,
     ParsedMarkdownTableAlignment, ParsedMarkdownTableRow,
 };
+use fs::normalize_path;
 use gpui::{
     AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, DefiniteLength, Div,
     Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement,
@@ -680,7 +681,7 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext)
                                         _ = workspace.update(cx, |workspace, cx| {
                                             workspace
                                                 .open_abs_path(
-                                                    path.clone(),
+                                                    normalize_path(path.clone().as_path()),
                                                     OpenOptions {
                                                         visible: Some(OpenVisible::None),
                                                         ..Default::default()

crates/media/src/media.rs 🔗

@@ -11,7 +11,7 @@ pub mod core_media {
         CMItemIndex, CMSampleTimingInfo, CMTime, CMTimeMake, CMVideoCodecType,
         kCMSampleAttachmentKey_NotSync, kCMTimeInvalid, kCMVideoCodecType_H264,
     };
-    use anyhow::{Result, anyhow};
+    use anyhow::Result;
     use core_foundation::{
         array::{CFArray, CFArrayRef},
         base::{CFTypeID, OSStatus, TCFType},
@@ -69,12 +69,11 @@ pub mod core_media {
                     index as CMItemIndex,
                     &mut timing_info,
                 );
-
-                if result == 0 {
-                    Ok(timing_info)
-                } else {
-                    Err(anyhow!("error getting sample timing info, code {}", result))
-                }
+                anyhow::ensure!(
+                    result == 0,
+                    "error getting sample timing info, code {result}"
+                );
+                Ok(timing_info)
             }
         }
 
@@ -153,11 +152,8 @@ pub mod core_media {
                     ptr::null_mut(),
                     ptr::null_mut(),
                 );
-                if result == 0 {
-                    Ok(std::slice::from_raw_parts(bytes, len))
-                } else {
-                    Err(anyhow!("error getting parameter set, code: {}", result))
-                }
+                anyhow::ensure!(result == 0, "error getting parameter set, code: {result}");
+                Ok(std::slice::from_raw_parts(bytes, len))
             }
         }
     }
@@ -231,7 +227,7 @@ pub mod core_video {
         kCVPixelFormatType_32BGRA, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
         kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, kCVPixelFormatType_420YpCbCr8Planar,
     };
-    use anyhow::{Result, anyhow};
+    use anyhow::Result;
     use core_foundation::{
         base::kCFAllocatorDefault, dictionary::CFDictionaryRef, mach_port::CFAllocatorRef,
     };
@@ -267,11 +263,11 @@ pub mod core_video {
                     &mut this,
                 )
             };
-            if result == kCVReturnSuccess {
-                unsafe { Ok(CVMetalTextureCache::wrap_under_create_rule(this)) }
-            } else {
-                Err(anyhow!("could not create texture cache, code: {}", result))
-            }
+            anyhow::ensure!(
+                result == kCVReturnSuccess,
+                "could not create texture cache, code: {result}"
+            );
+            unsafe { Ok(CVMetalTextureCache::wrap_under_create_rule(this)) }
         }
 
         /// # Safety
@@ -300,11 +296,11 @@ pub mod core_video {
                     &mut this,
                 )
             };
-            if result == kCVReturnSuccess {
-                unsafe { Ok(CVMetalTexture::wrap_under_create_rule(this)) }
-            } else {
-                Err(anyhow!("could not create texture, code: {}", result))
-            }
+            anyhow::ensure!(
+                result == kCVReturnSuccess,
+                "could not create texture, code: {result}"
+            );
+            unsafe { Ok(CVMetalTexture::wrap_under_create_rule(this)) }
         }
     }
 

crates/migrator/src/migrations.rs 🔗

@@ -69,3 +69,21 @@ pub(crate) mod m_2025_05_08 {
 
     pub(crate) use settings::SETTINGS_PATTERNS;
 }
+
+pub(crate) mod m_2025_05_29 {
+    mod settings;
+
+    pub(crate) use settings::SETTINGS_PATTERNS;
+}
+
+pub(crate) mod m_2025_06_16 {
+    mod settings;
+
+    pub(crate) use settings::SETTINGS_PATTERNS;
+}
+
+pub(crate) mod m_2025_06_25 {
+    mod settings;
+
+    pub(crate) use settings::SETTINGS_PATTERNS;
+}

crates/migrator/src/migrations/m_2025_05_29/settings.rs 🔗

@@ -0,0 +1,51 @@
+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,
+    replace_preferred_completion_mode_value,
+)];
+
+fn replace_preferred_completion_mode_value(
+    contents: &str,
+    mat: &QueryMatch,
+    query: &Query,
+) -> Option<(Range<usize>, String)> {
+    let parent_object_capture_ix = query.capture_index_for_name("parent_key")?;
+    let parent_object_range = mat
+        .nodes_for_capture_index(parent_object_capture_ix)
+        .next()?
+        .byte_range();
+    let parent_object_name = contents.get(parent_object_range.clone())?;
+
+    if parent_object_name != "agent" {
+        return None;
+    }
+
+    let setting_name_capture_ix = query.capture_index_for_name("setting_name")?;
+    let setting_name_range = mat
+        .nodes_for_capture_index(setting_name_capture_ix)
+        .next()?
+        .byte_range();
+    let setting_name = contents.get(setting_name_range.clone())?;
+
+    if setting_name != "preferred_completion_mode" {
+        return None;
+    }
+
+    let value_capture_ix = query.capture_index_for_name("setting_value")?;
+    let value_range = mat
+        .nodes_for_capture_index(value_capture_ix)
+        .next()?
+        .byte_range();
+    let value = contents.get(value_range.clone())?;
+
+    if value.trim() == "\"max\"" {
+        Some((value_range, "\"burn\"".to_string()))
+    } else {
+        None
+    }
+}

crates/migrator/src/migrations/m_2025_06_16/settings.rs 🔗

@@ -0,0 +1,90 @@
+use std::ops::Range;
+
+use tree_sitter::{Query, QueryMatch};
+
+use crate::MigrationPatterns;
+
+pub const SETTINGS_PATTERNS: MigrationPatterns = &[(
+    SETTINGS_CONTEXT_SERVER_PATTERN,
+    migrate_context_server_settings,
+)];
+
+const SETTINGS_CONTEXT_SERVER_PATTERN: &str = r#"(document
+    (object
+        (pair
+            key: (string (string_content) @context-servers)
+            value: (object
+                (pair
+                    key: (string (string_content) @server-name)
+                    value: (object) @server-settings
+                )
+            )
+        )
+    )
+    (#eq? @context-servers "context_servers")
+)"#;
+
+fn migrate_context_server_settings(
+    contents: &str,
+    mat: &QueryMatch,
+    query: &Query,
+) -> Option<(Range<usize>, String)> {
+    let server_settings_index = query.capture_index_for_name("server-settings")?;
+    let server_settings = mat.nodes_for_capture_index(server_settings_index).next()?;
+
+    let mut has_command = false;
+    let mut has_settings = false;
+    let mut other_keys = 0;
+    let mut column = None;
+
+    // Parse the server settings to check what keys it contains
+    let mut cursor = server_settings.walk();
+    for child in server_settings.children(&mut cursor) {
+        if child.kind() == "pair" {
+            if let Some(key_node) = child.child_by_field_name("key") {
+                if let (None, Some(quote_content)) = (column, key_node.child(0)) {
+                    column = Some(quote_content.start_position().column);
+                }
+                if let Some(string_content) = key_node.child(1) {
+                    let key = &contents[string_content.byte_range()];
+                    match key {
+                        // If it already has a source key, don't modify it
+                        "source" => return None,
+                        "command" => has_command = true,
+                        "settings" => has_settings = true,
+                        _ => other_keys += 1,
+                    }
+                }
+            }
+        }
+    }
+
+    let source_type = if has_command { "custom" } else { "extension" };
+
+    // Insert the source key at the beginning of the object
+    let start = server_settings.start_byte() + 1;
+    let indent = " ".repeat(column.unwrap_or(12));
+
+    if !has_command && !has_settings {
+        return Some((
+            start..start,
+            format!(
+                r#"
+{indent}"source": "{}",
+{indent}"settings": {{}}{}
+        "#,
+                source_type,
+                if other_keys > 0 { "," } else { "" }
+            ),
+        ));
+    }
+
+    Some((
+        start..start,
+        format!(
+            r#"
+{indent}"source": "{}","#,
+            source_type
+        ),
+    ))
+}

crates/migrator/src/migrations/m_2025_06_25/settings.rs 🔗

@@ -0,0 +1,133 @@
+use std::ops::Range;
+use tree_sitter::{Query, QueryMatch};
+
+use crate::MigrationPatterns;
+
+pub const SETTINGS_PATTERNS: MigrationPatterns = &[
+    (SETTINGS_VERSION_PATTERN, remove_version_fields),
+    (
+        SETTINGS_NESTED_VERSION_PATTERN,
+        remove_nested_version_fields,
+    ),
+];
+
+const SETTINGS_VERSION_PATTERN: &str = r#"(document
+    (object
+        (pair
+            key: (string (string_content) @key)
+            value: (object
+                (pair
+                    key: (string (string_content) @version_key)
+                    value: (_) @version_value
+                ) @version_pair
+            )
+        )
+    )
+    (#eq? @key "agent")
+    (#eq? @version_key "version")
+)"#;
+
+const SETTINGS_NESTED_VERSION_PATTERN: &str = r#"(document
+    (object
+        (pair
+            key: (string (string_content) @language_models)
+            value: (object
+                (pair
+                    key: (string (string_content) @provider)
+                    value: (object
+                        (pair
+                            key: (string (string_content) @version_key)
+                            value: (_) @version_value
+                        ) @version_pair
+                    )
+                )
+            )
+        )
+    )
+    (#eq? @language_models "language_models")
+    (#match? @provider "^(anthropic|openai)$")
+    (#eq? @version_key "version")
+)"#;
+
+fn remove_version_fields(
+    contents: &str,
+    mat: &QueryMatch,
+    query: &Query,
+) -> Option<(Range<usize>, String)> {
+    let version_pair_ix = query.capture_index_for_name("version_pair")?;
+    let version_pair_node = mat.nodes_for_capture_index(version_pair_ix).next()?;
+
+    remove_pair_with_whitespace(contents, version_pair_node)
+}
+
+fn remove_nested_version_fields(
+    contents: &str,
+    mat: &QueryMatch,
+    query: &Query,
+) -> Option<(Range<usize>, String)> {
+    let version_pair_ix = query.capture_index_for_name("version_pair")?;
+    let version_pair_node = mat.nodes_for_capture_index(version_pair_ix).next()?;
+
+    remove_pair_with_whitespace(contents, version_pair_node)
+}
+
+fn remove_pair_with_whitespace(
+    contents: &str,
+    pair_node: tree_sitter::Node,
+) -> Option<(Range<usize>, String)> {
+    let mut range_to_remove = pair_node.byte_range();
+
+    // Check if there's a comma after this pair
+    if let Some(next_sibling) = pair_node.next_sibling() {
+        if next_sibling.kind() == "," {
+            range_to_remove.end = next_sibling.end_byte();
+        }
+    } else {
+        // If no next sibling, check if there's a comma before
+        if let Some(prev_sibling) = pair_node.prev_sibling() {
+            if prev_sibling.kind() == "," {
+                range_to_remove.start = prev_sibling.start_byte();
+            }
+        }
+    }
+
+    // Include any leading whitespace/newline, including comments
+    let text_before = &contents[..range_to_remove.start];
+    if let Some(last_newline) = text_before.rfind('\n') {
+        let whitespace_start = last_newline + 1;
+        let potential_whitespace = &contents[whitespace_start..range_to_remove.start];
+
+        // Check if it's only whitespace or comments
+        let mut is_whitespace_or_comment = true;
+        let mut in_comment = false;
+        let mut chars = potential_whitespace.chars().peekable();
+
+        while let Some(ch) = chars.next() {
+            if in_comment {
+                if ch == '\n' {
+                    in_comment = false;
+                }
+            } else if ch == '/' && chars.peek() == Some(&'/') {
+                in_comment = true;
+                chars.next(); // Skip the second '/'
+            } else if !ch.is_whitespace() {
+                is_whitespace_or_comment = false;
+                break;
+            }
+        }
+
+        if is_whitespace_or_comment {
+            range_to_remove.start = whitespace_start;
+        }
+    }
+
+    // Also check if we need to include trailing whitespace up to the next line
+    let text_after = &contents[range_to_remove.end..];
+    if let Some(newline_pos) = text_after.find('\n') {
+        if text_after[..newline_pos].chars().all(|c| c.is_whitespace()) {
+            range_to_remove.end += newline_pos + 1;
+        }
+    }
+
+    Some((range_to_remove, String::new()))
+}

crates/migrator/src/migrator.rs 🔗

@@ -14,7 +14,7 @@
 //!
 //! You only need to write replacement logic for x-1 to x because you can be certain that, internally, every user will be at x-1, regardless of their on disk state.
 
-use anyhow::{Context, Result};
+use anyhow::{Context as _, Result};
 use std::{cmp::Reverse, ops::Range, sync::LazyLock};
 use streaming_iterator::StreamingIterator;
 use tree_sitter::{Query, QueryMatch};
@@ -144,6 +144,18 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
             migrations::m_2025_05_08::SETTINGS_PATTERNS,
             &SETTINGS_QUERY_2025_05_08,
         ),
+        (
+            migrations::m_2025_05_29::SETTINGS_PATTERNS,
+            &SETTINGS_QUERY_2025_05_29,
+        ),
+        (
+            migrations::m_2025_06_16::SETTINGS_PATTERNS,
+            &SETTINGS_QUERY_2025_06_16,
+        ),
+        (
+            migrations::m_2025_06_25::SETTINGS_PATTERNS,
+            &SETTINGS_QUERY_2025_06_25,
+        ),
     ];
     run_migrations(text, migrations)
 }
@@ -238,6 +250,18 @@ define_query!(
     SETTINGS_QUERY_2025_05_08,
     migrations::m_2025_05_08::SETTINGS_PATTERNS
 );
+define_query!(
+    SETTINGS_QUERY_2025_05_29,
+    migrations::m_2025_05_29::SETTINGS_PATTERNS
+);
+define_query!(
+    SETTINGS_QUERY_2025_06_16,
+    migrations::m_2025_06_16::SETTINGS_PATTERNS
+);
+define_query!(
+    SETTINGS_QUERY_2025_06_25,
+    migrations::m_2025_06_25::SETTINGS_PATTERNS
+);
 
 // custom query
 static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock<Query> = LazyLock::new(|| {
@@ -785,4 +809,326 @@ mod tests {
             ),
         );
     }
+
+    #[test]
+    fn test_preferred_completion_mode_migration() {
+        assert_migrate_settings(
+            r#"{
+                "agent": {
+                    "preferred_completion_mode": "max",
+                    "enabled": true
+                }
+            }"#,
+            Some(
+                r#"{
+                "agent": {
+                    "preferred_completion_mode": "burn",
+                    "enabled": true
+                }
+            }"#,
+            ),
+        );
+
+        assert_migrate_settings(
+            r#"{
+                "agent": {
+                    "preferred_completion_mode": "normal",
+                    "enabled": true
+                }
+            }"#,
+            None,
+        );
+
+        assert_migrate_settings(
+            r#"{
+                "agent": {
+                    "preferred_completion_mode": "burn",
+                    "enabled": true
+                }
+            }"#,
+            None,
+        );
+
+        assert_migrate_settings(
+            r#"{
+                "other_section": {
+                    "preferred_completion_mode": "max"
+                },
+                "agent": {
+                    "preferred_completion_mode": "max"
+                }
+            }"#,
+            Some(
+                r#"{
+                "other_section": {
+                    "preferred_completion_mode": "max"
+                },
+                "agent": {
+                    "preferred_completion_mode": "burn"
+                }
+            }"#,
+            ),
+        );
+    }
+
+    #[test]
+    fn test_mcp_settings_migration() {
+        assert_migrate_settings(
+            r#"{
+    "context_servers": {
+        "empty_server": {},
+        "extension_server": {
+            "settings": {
+                "foo": "bar"
+            }
+        },
+        "custom_server": {
+            "command": {
+                "path": "foo",
+                "args": ["bar"],
+                "env": {
+                    "FOO": "BAR"
+                }
+            }
+        },
+        "invalid_server": {
+            "command": {
+                "path": "foo",
+                "args": ["bar"],
+                "env": {
+                    "FOO": "BAR"
+                }
+            },
+            "settings": {
+                "foo": "bar"
+            }
+        },
+        "empty_server2": {},
+        "extension_server2": {
+            "foo": "bar",
+            "settings": {
+                "foo": "bar"
+            },
+            "bar": "foo"
+        },
+        "custom_server2": {
+            "foo": "bar",
+            "command": {
+                "path": "foo",
+                "args": ["bar"],
+                "env": {
+                    "FOO": "BAR"
+                }
+            },
+            "bar": "foo"
+        },
+        "invalid_server2": {
+            "foo": "bar",
+            "command": {
+                "path": "foo",
+                "args": ["bar"],
+                "env": {
+                    "FOO": "BAR"
+                }
+            },
+            "bar": "foo",
+            "settings": {
+                "foo": "bar"
+            }
+        }
+    }
+}"#,
+            Some(
+                r#"{
+    "context_servers": {
+        "empty_server": {
+            "source": "extension",
+            "settings": {}
+        },
+        "extension_server": {
+            "source": "extension",
+            "settings": {
+                "foo": "bar"
+            }
+        },
+        "custom_server": {
+            "source": "custom",
+            "command": {
+                "path": "foo",
+                "args": ["bar"],
+                "env": {
+                    "FOO": "BAR"
+                }
+            }
+        },
+        "invalid_server": {
+            "source": "custom",
+            "command": {
+                "path": "foo",
+                "args": ["bar"],
+                "env": {
+                    "FOO": "BAR"
+                }
+            },
+            "settings": {
+                "foo": "bar"
+            }
+        },
+        "empty_server2": {
+            "source": "extension",
+            "settings": {}
+        },
+        "extension_server2": {
+            "source": "extension",
+            "foo": "bar",
+            "settings": {
+                "foo": "bar"
+            },
+            "bar": "foo"
+        },
+        "custom_server2": {
+            "source": "custom",
+            "foo": "bar",
+            "command": {
+                "path": "foo",
+                "args": ["bar"],
+                "env": {
+                    "FOO": "BAR"
+                }
+            },
+            "bar": "foo"
+        },
+        "invalid_server2": {
+            "source": "custom",
+            "foo": "bar",
+            "command": {
+                "path": "foo",
+                "args": ["bar"],
+                "env": {
+                    "FOO": "BAR"
+                }
+            },
+            "bar": "foo",
+            "settings": {
+                "foo": "bar"
+            }
+        }
+    }
+}"#,
+            ),
+        );
+    }
+
+    #[test]
+    fn test_mcp_settings_migration_doesnt_change_valid_settings() {
+        let settings = r#"{
+    "context_servers": {
+        "empty_server": {
+            "source": "extension",
+            "settings": {}
+        },
+        "extension_server": {
+            "source": "extension",
+            "settings": {
+                "foo": "bar"
+            }
+        },
+        "custom_server": {
+            "source": "custom",
+            "command": {
+                "path": "foo",
+                "args": ["bar"],
+                "env": {
+                    "FOO": "BAR"
+                }
+            }
+        },
+        "invalid_server": {
+            "source": "custom",
+            "command": {
+                "path": "foo",
+                "args": ["bar"],
+                "env": {
+                    "FOO": "BAR"
+                }
+            },
+            "settings": {
+                "foo": "bar"
+            }
+        }
+    }
+}"#;
+        assert_migrate_settings(settings, None);
+    }
+
+    #[test]
+    fn test_remove_version_fields() {
+        assert_migrate_settings(
+            r#"{
+    "language_models": {
+        "anthropic": {
+            "version": "1",
+            "api_url": "https://api.anthropic.com"
+        },
+        "openai": {
+            "version": "1",
+            "api_url": "https://api.openai.com/v1"
+        }
+    },
+    "agent": {
+        "version": "2",
+        "enabled": true,
+        "preferred_completion_mode": "normal",
+        "button": true,
+        "dock": "right",
+        "default_width": 640,
+        "default_height": 320,
+        "default_model": {
+            "provider": "zed.dev",
+            "model": "claude-sonnet-4"
+        }
+    }
+}"#,
+            Some(
+                r#"{
+    "language_models": {
+        "anthropic": {
+            "api_url": "https://api.anthropic.com"
+        },
+        "openai": {
+            "api_url": "https://api.openai.com/v1"
+        }
+    },
+    "agent": {
+        "enabled": true,
+        "preferred_completion_mode": "normal",
+        "button": true,
+        "dock": "right",
+        "default_width": 640,
+        "default_height": 320,
+        "default_model": {
+            "provider": "zed.dev",
+            "model": "claude-sonnet-4"
+        }
+    }
+}"#,
+            ),
+        );
+
+        // Test that version fields in other contexts are not removed
+        assert_migrate_settings(
+            r#"{
+    "language_models": {
+        "other_provider": {
+            "version": "1",
+            "api_url": "https://api.example.com"
+        }
+    },
+    "other_section": {
+        "version": "1"
+    }
+}"#,
+            None,
+        );
+    }
 }

crates/mistral/src/mistral.rs 🔗

@@ -26,7 +26,7 @@ impl TryFrom<String> for Role {
             "assistant" => Ok(Self::Assistant),
             "system" => Ok(Self::System),
             "tool" => Ok(Self::Tool),
-            _ => Err(anyhow!("invalid role '{value}'")),
+            _ => anyhow::bail!("invalid role '{value}'"),
         }
     }
 }
@@ -58,15 +58,23 @@ pub enum Model {
     OpenMistralNemo,
     #[serde(rename = "open-codestral-mamba", alias = "open-codestral-mamba")]
     OpenCodestralMamba,
+    #[serde(rename = "devstral-small-latest", alias = "devstral-small-latest")]
+    DevstralSmallLatest,
+    #[serde(rename = "pixtral-12b-latest", alias = "pixtral-12b-latest")]
+    Pixtral12BLatest,
+    #[serde(rename = "pixtral-large-latest", alias = "pixtral-large-latest")]
+    PixtralLargeLatest,
 
     #[serde(rename = "custom")]
     Custom {
         name: String,
         /// The name displayed in the UI, such as in the assistant panel model dropdown menu.
         display_name: Option<String>,
-        max_tokens: usize,
-        max_output_tokens: Option<u32>,
-        max_completion_tokens: Option<u32>,
+        max_tokens: u64,
+        max_output_tokens: Option<u64>,
+        max_completion_tokens: Option<u64>,
+        supports_tools: Option<bool>,
+        supports_images: Option<bool>,
     },
 }
 
@@ -83,7 +91,10 @@ impl Model {
             "mistral-small-latest" => Ok(Self::MistralSmallLatest),
             "open-mistral-nemo" => Ok(Self::OpenMistralNemo),
             "open-codestral-mamba" => Ok(Self::OpenCodestralMamba),
-            _ => Err(anyhow!("invalid model id")),
+            "devstral-small-latest" => Ok(Self::DevstralSmallLatest),
+            "pixtral-12b-latest" => Ok(Self::Pixtral12BLatest),
+            "pixtral-large-latest" => Ok(Self::PixtralLargeLatest),
+            invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"),
         }
     }
 
@@ -95,6 +106,9 @@ impl Model {
             Self::MistralSmallLatest => "mistral-small-latest",
             Self::OpenMistralNemo => "open-mistral-nemo",
             Self::OpenCodestralMamba => "open-codestral-mamba",
+            Self::DevstralSmallLatest => "devstral-small-latest",
+            Self::Pixtral12BLatest => "pixtral-12b-latest",
+            Self::PixtralLargeLatest => "pixtral-large-latest",
             Self::Custom { name, .. } => name,
         }
     }
@@ -107,13 +121,16 @@ impl Model {
             Self::MistralSmallLatest => "mistral-small-latest",
             Self::OpenMistralNemo => "open-mistral-nemo",
             Self::OpenCodestralMamba => "open-codestral-mamba",
+            Self::DevstralSmallLatest => "devstral-small-latest",
+            Self::Pixtral12BLatest => "pixtral-12b-latest",
+            Self::PixtralLargeLatest => "pixtral-large-latest",
             Self::Custom {
                 name, display_name, ..
             } => display_name.as_ref().unwrap_or(name),
         }
     }
 
-    pub fn max_token_count(&self) -> usize {
+    pub fn max_token_count(&self) -> u64 {
         match self {
             Self::CodestralLatest => 256000,
             Self::MistralLargeLatest => 131000,
@@ -121,11 +138,14 @@ impl Model {
             Self::MistralSmallLatest => 32000,
             Self::OpenMistralNemo => 131000,
             Self::OpenCodestralMamba => 256000,
+            Self::DevstralSmallLatest => 262144,
+            Self::Pixtral12BLatest => 128000,
+            Self::PixtralLargeLatest => 128000,
             Self::Custom { max_tokens, .. } => *max_tokens,
         }
     }
 
-    pub fn max_output_tokens(&self) -> Option<u32> {
+    pub fn max_output_tokens(&self) -> Option<u64> {
         match self {
             Self::Custom {
                 max_output_tokens, ..
@@ -133,6 +153,38 @@ impl Model {
             _ => None,
         }
     }
+
+    pub fn supports_tools(&self) -> bool {
+        match self {
+            Self::CodestralLatest
+            | Self::MistralLargeLatest
+            | Self::MistralMediumLatest
+            | Self::MistralSmallLatest
+            | Self::OpenMistralNemo
+            | Self::OpenCodestralMamba
+            | Self::DevstralSmallLatest
+            | Self::Pixtral12BLatest
+            | Self::PixtralLargeLatest => true,
+            Self::Custom { supports_tools, .. } => supports_tools.unwrap_or(false),
+        }
+    }
+
+    pub fn supports_images(&self) -> bool {
+        match self {
+            Self::Pixtral12BLatest
+            | Self::PixtralLargeLatest
+            | Self::MistralMediumLatest
+            | Self::MistralSmallLatest => true,
+            Self::CodestralLatest
+            | Self::MistralLargeLatest
+            | Self::OpenMistralNemo
+            | Self::OpenCodestralMamba
+            | Self::DevstralSmallLatest => false,
+            Self::Custom {
+                supports_images, ..
+            } => supports_images.unwrap_or(false),
+        }
+    }
 }
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -141,11 +193,15 @@ pub struct Request {
     pub messages: Vec<RequestMessage>,
     pub stream: bool,
     #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub max_tokens: Option<u32>,
+    pub max_tokens: Option<u64>,
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub temperature: Option<f32>,
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub response_format: Option<ResponseFormat>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub tool_choice: Option<ToolChoice>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub parallel_tool_calls: Option<bool>,
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     pub tools: Vec<ToolDefinition>,
 }
@@ -190,12 +246,13 @@ pub enum Prediction {
 }
 
 #[derive(Debug, Serialize, Deserialize)]
-#[serde(untagged)]
+#[serde(rename_all = "snake_case")]
 pub enum ToolChoice {
     Auto,
     Required,
     None,
-    Other(ToolDefinition),
+    Any,
+    Function(ToolDefinition),
 }
 
 #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
@@ -207,7 +264,8 @@ pub enum RequestMessage {
         tool_calls: Vec<ToolCall>,
     },
     User {
-        content: String,
+        #[serde(flatten)]
+        content: MessageContent,
     },
     System {
         content: String,
@@ -218,6 +276,54 @@ pub enum RequestMessage {
     },
 }
 
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+#[serde(untagged)]
+pub enum MessageContent {
+    #[serde(rename = "content")]
+    Plain { content: String },
+    #[serde(rename = "content")]
+    Multipart { content: Vec<MessagePart> },
+}
+
+impl MessageContent {
+    pub fn empty() -> Self {
+        Self::Plain {
+            content: String::new(),
+        }
+    }
+
+    pub fn push_part(&mut self, part: MessagePart) {
+        match self {
+            Self::Plain { content } => match part {
+                MessagePart::Text { text } => {
+                    content.push_str(&text);
+                }
+                part => {
+                    let mut parts = if content.is_empty() {
+                        Vec::new()
+                    } else {
+                        vec![MessagePart::Text {
+                            text: content.clone(),
+                        }]
+                    };
+                    parts.push(part);
+                    *self = Self::Multipart { content: parts };
+                }
+            },
+            Self::Multipart { content } => {
+                content.push(part);
+            }
+        }
+    }
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum MessagePart {
+    Text { text: String },
+    ImageUrl { image_url: String },
+}
+
 #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
 pub struct ToolCall {
     pub id: String,
@@ -254,9 +360,9 @@ pub struct Response {
 
 #[derive(Serialize, Deserialize, Debug)]
 pub struct Usage {
-    pub prompt_tokens: u32,
-    pub completion_tokens: u32,
-    pub total_tokens: u32,
+    pub prompt_tokens: u64,
+    pub completion_tokens: u64,
+    pub total_tokens: u64,
 }
 
 #[derive(Serialize, Deserialize, Debug)]
@@ -273,6 +379,7 @@ pub struct StreamResponse {
     pub created: u64,
     pub model: String,
     pub choices: Vec<StreamChoice>,
+    pub usage: Option<Usage>,
 }
 
 #[derive(Serialize, Deserialize, Debug)]
@@ -345,10 +452,10 @@ pub async fn stream_completion(
     } else {
         let mut body = String::new();
         response.body_mut().read_to_string(&mut body).await?;
-        Err(anyhow!(
+        anyhow::bail!(
             "Failed to connect to Mistral API: {} {}",
             response.status(),
             body,
-        ))
+        );
     }
 }

crates/multi_buffer/Cargo.toml 🔗

@@ -27,7 +27,6 @@ clock.workspace = true
 collections.workspace = true
 ctor.workspace = true
 buffer_diff.workspace = true
-env_logger.workspace = true
 gpui.workspace = true
 itertools.workspace = true
 language.workspace = true
@@ -57,3 +56,4 @@ rand.workspace = true
 settings = { workspace = true, features = ["test-support"] }
 text = { workspace = true, features = ["test-support"] }
 util = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -43,7 +43,7 @@ use std::{
     sync::Arc,
     time::{Duration, Instant},
 };
-use sum_tree::{Bias, Cursor, SumTree, TreeMap};
+use sum_tree::{Bias, Cursor, Dimension, SumTree, Summary, TreeMap};
 use text::{
     BufferId, Edit, LineIndent, TextSummary,
     locator::Locator,
@@ -417,8 +417,7 @@ struct Excerpt {
 #[derive(Clone)]
 pub struct MultiBufferExcerpt<'a> {
     excerpt: &'a Excerpt,
-    diff_transforms:
-        sum_tree::Cursor<'a, DiffTransform, (OutputDimension<usize>, ExcerptDimension<usize>)>,
+    diff_transforms: sum_tree::Cursor<'a, DiffTransform, DiffTransforms<usize>>,
     offset: usize,
     excerpt_offset: ExcerptDimension<usize>,
     buffer_offset: usize,
@@ -506,10 +505,36 @@ pub struct ReversedMultiBufferBytes<'a> {
     chunk: &'a [u8],
 }
 
+#[derive(Clone)]
+struct DiffTransforms<D> {
+    output_dimension: OutputDimension<D>,
+    excerpt_dimension: ExcerptDimension<D>,
+}
+
+impl<'a, D: TextDimension> Dimension<'a, DiffTransformSummary> for DiffTransforms<D> {
+    fn zero(cx: &<DiffTransformSummary as sum_tree::Summary>::Context) -> Self {
+        Self {
+            output_dimension: OutputDimension::zero(cx),
+            excerpt_dimension: <ExcerptDimension<D> as Dimension<'a, DiffTransformSummary>>::zero(
+                cx,
+            ),
+        }
+    }
+
+    fn add_summary(
+        &mut self,
+        summary: &'a DiffTransformSummary,
+        cx: &<DiffTransformSummary as sum_tree::Summary>::Context,
+    ) {
+        self.output_dimension.add_summary(summary, cx);
+        self.excerpt_dimension.add_summary(summary, cx);
+    }
+}
+
 #[derive(Clone)]
 struct MultiBufferCursor<'a, D: TextDimension> {
     excerpts: Cursor<'a, Excerpt, ExcerptDimension<D>>,
-    diff_transforms: Cursor<'a, DiffTransform, (OutputDimension<D>, ExcerptDimension<D>)>,
+    diff_transforms: Cursor<'a, DiffTransform, DiffTransforms<D>>,
     diffs: &'a TreeMap<BufferId, BufferDiffSnapshot>,
     cached_region: Option<MultiBufferRegion<'a, D>>,
 }
@@ -703,7 +728,7 @@ impl MultiBuffer {
         self.snapshot.borrow().clone()
     }
 
-    pub fn read(&self, cx: &App) -> Ref<MultiBufferSnapshot> {
+    pub fn read(&self, cx: &App) -> Ref<'_, MultiBufferSnapshot> {
         self.sync(cx);
         self.snapshot.borrow()
     }
@@ -1121,10 +1146,10 @@ impl MultiBuffer {
 
     pub fn last_transaction_id(&self, cx: &App) -> Option<TransactionId> {
         if let Some(buffer) = self.as_singleton() {
-            return buffer.read_with(cx, |b, _| {
-                b.peek_undo_stack()
-                    .map(|history_entry| history_entry.transaction_id())
-            });
+            return buffer
+                .read(cx)
+                .peek_undo_stack()
+                .map(|history_entry| history_entry.transaction_id());
         } else {
             let last_transaction = self.history.undo_stack.last()?;
             return Some(last_transaction.id);
@@ -1575,7 +1600,7 @@ impl MultiBuffer {
         context_line_count: u32,
         cx: &mut Context<Self>,
     ) -> (Vec<Range<Anchor>>, bool) {
-        let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
+        let buffer_snapshot = buffer.read(cx).snapshot();
         let excerpt_ranges = build_excerpt_ranges(ranges, context_line_count, &buffer_snapshot);
 
         let (new, counts) = Self::merge_excerpt_ranges(&excerpt_ranges);
@@ -1690,7 +1715,9 @@ impl MultiBuffer {
                     last_range.context.start <= range.context.start,
                     "Last range: {last_range:?} Range: {range:?}"
                 );
-                if last_range.context.end >= range.context.start {
+                if last_range.context.end >= range.context.start
+                    || last_range.context.end.row + 1 == range.context.start.row
+                {
                     last_range.context.end = range.context.end.max(last_range.context.end);
                     *counts.last_mut().unwrap() += 1;
                     continue;
@@ -2574,14 +2601,59 @@ impl MultiBuffer {
         }
 
         if let Some(buffer) = self.as_singleton() {
-            if let Some(file) = buffer.read(cx).file() {
+            let buffer = buffer.read(cx);
+
+            if let Some(file) = buffer.file() {
                 return file.file_name(cx).to_string_lossy();
             }
-        }
+
+            if let Some(title) = self.buffer_content_title(buffer) {
+                return title;
+            }
+        };
 
         "untitled".into()
     }
 
+    fn buffer_content_title(&self, buffer: &Buffer) -> Option<Cow<'_, str>> {
+        let mut is_leading_whitespace = true;
+        let mut count = 0;
+        let mut prev_was_space = false;
+        let mut title = String::new();
+
+        for ch in buffer.snapshot().chars() {
+            if is_leading_whitespace && ch.is_whitespace() {
+                continue;
+            }
+
+            is_leading_whitespace = false;
+
+            if ch == '\n' || count >= 40 {
+                break;
+            }
+
+            if ch.is_whitespace() {
+                if !prev_was_space {
+                    title.push(' ');
+                    count += 1;
+                    prev_was_space = true;
+                }
+            } else {
+                title.push(ch);
+                count += 1;
+                prev_was_space = false;
+            }
+        }
+
+        let title = title.trim_end().to_string();
+
+        if title.is_empty() {
+            return None;
+        }
+
+        Some(title.into())
+    }
+
     pub fn set_title(&mut self, title: String, cx: &mut Context<Self>) {
         self.title = Some(title);
         cx.notify();
@@ -3707,7 +3779,7 @@ impl MultiBufferSnapshot {
             .flat_map(|c| c.chars().rev())
     }
 
-    fn reversed_chunks_in_range(&self, range: Range<usize>) -> ReversedMultiBufferChunks {
+    fn reversed_chunks_in_range(&self, range: Range<usize>) -> ReversedMultiBufferChunks<'_> {
         let mut cursor = self.cursor::<usize>();
         cursor.seek(&range.end);
         let current_chunks = cursor.region().as_ref().map(|region| {
@@ -4142,6 +4214,19 @@ impl MultiBufferSnapshot {
         self.diffs.values().any(|diff| !diff.is_empty())
     }
 
+    pub fn is_inside_word<T: ToOffset>(&self, position: T, for_completion: bool) -> bool {
+        let position = position.to_offset(self);
+        let classifier = self
+            .char_classifier_at(position)
+            .for_completion(for_completion);
+        let next_char_kind = self.chars_at(position).next().map(|c| classifier.kind(c));
+        let prev_char_kind = self
+            .reversed_chars_at(position)
+            .next()
+            .map(|c| classifier.kind(c));
+        prev_char_kind.zip(next_char_kind) == Some((CharKind::Word, CharKind::Word))
+    }
+
     pub fn surrounding_word<T: ToOffset>(
         &self,
         start: T,
@@ -4180,6 +4265,20 @@ impl MultiBufferSnapshot {
         (start..end, word_kind)
     }
 
+    pub fn char_kind_before<T: ToOffset>(
+        &self,
+        start: T,
+        for_completion: bool,
+    ) -> Option<CharKind> {
+        let start = start.to_offset(self);
+        let classifier = self
+            .char_classifier_at(start)
+            .for_completion(for_completion);
+        self.reversed_chars_at(start)
+            .next()
+            .map(|ch| classifier.kind(ch))
+    }
+
     pub fn is_singleton(&self) -> bool {
         self.singleton
     }
@@ -4208,7 +4307,7 @@ impl MultiBufferSnapshot {
         self.excerpts.summary().widest_line_number + 1
     }
 
-    pub fn bytes_in_range<T: ToOffset>(&self, range: Range<T>) -> MultiBufferBytes {
+    pub fn bytes_in_range<T: ToOffset>(&self, range: Range<T>) -> MultiBufferBytes<'_> {
         let range = range.start.to_offset(self)..range.end.to_offset(self);
         let mut excerpts = self.cursor::<usize>();
         excerpts.seek(&range.start);
@@ -4247,7 +4346,7 @@ impl MultiBufferSnapshot {
     pub fn reversed_bytes_in_range<T: ToOffset>(
         &self,
         range: Range<T>,
-    ) -> ReversedMultiBufferBytes {
+    ) -> ReversedMultiBufferBytes<'_> {
         let range = range.start.to_offset(self)..range.end.to_offset(self);
         let mut chunks = self.reversed_chunks_in_range(range.clone());
         let chunk = chunks.next().map_or(&[][..], |c| c.as_bytes());
@@ -4258,7 +4357,7 @@ impl MultiBufferSnapshot {
         }
     }
 
-    pub fn row_infos(&self, start_row: MultiBufferRow) -> MultiBufferRows {
+    pub fn row_infos(&self, start_row: MultiBufferRow) -> MultiBufferRows<'_> {
         let mut cursor = self.cursor::<Point>();
         cursor.seek(&Point::new(start_row.0, 0));
         let mut result = MultiBufferRows {
@@ -4271,7 +4370,11 @@ impl MultiBufferSnapshot {
         result
     }
 
-    pub fn chunks<T: ToOffset>(&self, range: Range<T>, language_aware: bool) -> MultiBufferChunks {
+    pub fn chunks<T: ToOffset>(
+        &self,
+        range: Range<T>,
+        language_aware: bool,
+    ) -> MultiBufferChunks<'_> {
         let mut chunks = MultiBufferChunks {
             excerpt_offset_range: ExcerptOffset::new(0)..ExcerptOffset::new(0),
             range: 0..0,
@@ -5232,7 +5335,7 @@ impl MultiBufferSnapshot {
             .map(|excerpt| (excerpt.id, &excerpt.buffer, excerpt.range.clone()))
     }
 
-    fn cursor<D: TextDimension + Default>(&self) -> MultiBufferCursor<D> {
+    fn cursor<D: TextDimension + Default>(&self) -> MultiBufferCursor<'_, D> {
         let excerpts = self.excerpts.cursor(&());
         let diff_transforms = self.diff_transforms.cursor(&());
         MultiBufferCursor {
@@ -5251,18 +5354,16 @@ impl MultiBufferSnapshot {
         excerpts.seek(&Some(start_locator), Bias::Left, &());
         excerpts.prev(&());
 
-        let mut diff_transforms = self
-            .diff_transforms
-            .cursor::<(OutputDimension<usize>, ExcerptDimension<usize>)>(&());
+        let mut diff_transforms = self.diff_transforms.cursor::<DiffTransforms<usize>>(&());
         diff_transforms.seek(&excerpts.start().1, Bias::Left, &());
-        if diff_transforms.end(&()).1 < excerpts.start().1 {
+        if diff_transforms.end(&()).excerpt_dimension < excerpts.start().1 {
             diff_transforms.next(&());
         }
 
         let excerpt = excerpts.item()?;
         Some(MultiBufferExcerpt {
             excerpt,
-            offset: diff_transforms.start().0.0,
+            offset: diff_transforms.start().output_dimension.0,
             buffer_offset: excerpt.range.context.start.to_offset(&excerpt.buffer),
             excerpt_offset: excerpts.start().1.clone(),
             diff_transforms,
@@ -5753,21 +5854,34 @@ impl MultiBufferSnapshot {
         let mut result = Vec::new();
         let mut indent_stack = SmallVec::<[IndentGuide; 8]>::new();
 
+        let mut prev_settings = None;
         while let Some((first_row, mut line_indent, buffer)) = row_indents.next() {
             if first_row > end_row {
                 break;
             }
             let current_depth = indent_stack.len() as u32;
 
-            let settings =
-                language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx);
-            let tab_size = settings.tab_size.get() as u32;
+            // Avoid retrieving the language settings repeatedly for every buffer row.
+            if let Some((prev_buffer_id, _)) = &prev_settings {
+                if prev_buffer_id != &buffer.remote_id() {
+                    prev_settings.take();
+                }
+            }
+            let settings = &prev_settings
+                .get_or_insert_with(|| {
+                    (
+                        buffer.remote_id(),
+                        language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx),
+                    )
+                })
+                .1;
+            let tab_size = settings.tab_size.get();
 
             // When encountering empty, continue until found useful line indent
             // then add to the indent stack with the depth found
             let mut found_indent = false;
             let mut last_row = first_row;
-            if line_indent.is_line_empty() {
+            if line_indent.is_line_blank() {
                 while !found_indent {
                     let Some((target_row, new_line_indent, _)) = row_indents.next() else {
                         break;
@@ -5777,7 +5891,7 @@ impl MultiBufferSnapshot {
                         break;
                     }
 
-                    if new_line_indent.is_line_empty() {
+                    if new_line_indent.is_line_blank() {
                         continue;
                     }
                     last_row = target_row.min(end_row);
@@ -5793,7 +5907,7 @@ impl MultiBufferSnapshot {
                 line_indent.len(tab_size) / tab_size
                     + ((line_indent.len(tab_size) % tab_size) > 0) as u32
             } else {
-                current_depth
+                0
             };
 
             match depth.cmp(&current_depth) {
@@ -5984,7 +6098,7 @@ impl MultiBufferSnapshot {
     pub fn syntax_ancestor<T: ToOffset>(
         &self,
         range: Range<T>,
-    ) -> Option<(tree_sitter::Node, MultiOrSingleBufferOffsetRange)> {
+    ) -> Option<(tree_sitter::Node<'_>, MultiOrSingleBufferOffsetRange)> {
         let range = range.start.to_offset(self)..range.end.to_offset(self);
         let mut excerpt = self.excerpt_containing(range.clone())?;
         let node = excerpt
@@ -6182,7 +6296,10 @@ impl MultiBufferSnapshot {
     }
 
     /// Returns the excerpt containing range and its offset start within the multibuffer or none if `range` spans multiple excerpts
-    pub fn excerpt_containing<T: ToOffset>(&self, range: Range<T>) -> Option<MultiBufferExcerpt> {
+    pub fn excerpt_containing<T: ToOffset>(
+        &self,
+        range: Range<T>,
+    ) -> Option<MultiBufferExcerpt<'_>> {
         let range = range.start.to_offset(self)..range.end.to_offset(self);
         let mut cursor = self.cursor::<usize>();
         cursor.seek(&range.start);
@@ -6198,7 +6315,7 @@ impl MultiBufferSnapshot {
         cursor.seek_to_start_of_current_excerpt();
         let region = cursor.region()?;
         let offset = region.range.start;
-        let buffer_offset = region.buffer_range.start;
+        let buffer_offset = start_excerpt.buffer_start_offset();
         let excerpt_offset = cursor.excerpts.start().clone();
         Some(MultiBufferExcerpt {
             diff_transforms: cursor.diff_transforms,
@@ -6357,13 +6474,15 @@ where
         self.cached_region.take();
         self.diff_transforms
             .seek(&OutputDimension(*position), Bias::Right, &());
-        if self.diff_transforms.item().is_none() && *position == self.diff_transforms.start().0.0 {
+        if self.diff_transforms.item().is_none()
+            && *position == self.diff_transforms.start().output_dimension.0
+        {
             self.diff_transforms.prev(&());
         }
 
-        let mut excerpt_position = self.diff_transforms.start().1.0;
+        let mut excerpt_position = self.diff_transforms.start().excerpt_dimension.0;
         if let Some(DiffTransform::BufferContent { .. }) = self.diff_transforms.item() {
-            let overshoot = *position - self.diff_transforms.start().0.0;
+            let overshoot = *position - self.diff_transforms.start().output_dimension.0;
             excerpt_position.add_assign(&overshoot);
         }
 
@@ -6378,12 +6497,14 @@ where
         self.cached_region.take();
         self.diff_transforms
             .seek_forward(&OutputDimension(*position), Bias::Right, &());
-        if self.diff_transforms.item().is_none() && *position == self.diff_transforms.start().0.0 {
+        if self.diff_transforms.item().is_none()
+            && *position == self.diff_transforms.start().output_dimension.0
+        {
             self.diff_transforms.prev(&());
         }
 
-        let overshoot = *position - self.diff_transforms.start().0.0;
-        let mut excerpt_position = self.diff_transforms.start().1.0;
+        let overshoot = *position - self.diff_transforms.start().output_dimension.0;
+        let mut excerpt_position = self.diff_transforms.start().excerpt_dimension.0;
         if let Some(DiffTransform::BufferContent { .. }) = self.diff_transforms.item() {
             excerpt_position.add_assign(&overshoot);
         }
@@ -6409,8 +6530,8 @@ where
         self.cached_region.take();
         self.diff_transforms
             .seek(self.excerpts.start(), Bias::Left, &());
-        if self.diff_transforms.end(&()).1 == *self.excerpts.start()
-            && self.diff_transforms.start().1 < *self.excerpts.start()
+        if self.diff_transforms.end(&()).excerpt_dimension == *self.excerpts.start()
+            && self.diff_transforms.start().excerpt_dimension < *self.excerpts.start()
             && self.diff_transforms.next_item().is_some()
         {
             self.diff_transforms.next(&());
@@ -6419,12 +6540,17 @@ where
 
     fn next(&mut self) {
         self.cached_region.take();
-        match self.diff_transforms.end(&()).1.cmp(&self.excerpts.end(&())) {
+        match self
+            .diff_transforms
+            .end(&())
+            .excerpt_dimension
+            .cmp(&self.excerpts.end(&()))
+        {
             cmp::Ordering::Less => self.diff_transforms.next(&()),
             cmp::Ordering::Greater => self.excerpts.next(&()),
             cmp::Ordering::Equal => {
                 self.diff_transforms.next(&());
-                if self.diff_transforms.end(&()).1 > self.excerpts.end(&())
+                if self.diff_transforms.end(&()).excerpt_dimension > self.excerpts.end(&())
                     || self.diff_transforms.item().is_none()
                 {
                     self.excerpts.next(&());
@@ -6445,12 +6571,17 @@ where
 
     fn prev(&mut self) {
         self.cached_region.take();
-        match self.diff_transforms.start().1.cmp(self.excerpts.start()) {
+        match self
+            .diff_transforms
+            .start()
+            .excerpt_dimension
+            .cmp(self.excerpts.start())
+        {
             cmp::Ordering::Less => self.excerpts.prev(&()),
             cmp::Ordering::Greater => self.diff_transforms.prev(&()),
             cmp::Ordering::Equal => {
                 self.diff_transforms.prev(&());
-                if self.diff_transforms.start().1 < *self.excerpts.start()
+                if self.diff_transforms.start().excerpt_dimension < *self.excerpts.start()
                     || self.diff_transforms.item().is_none()
                 {
                     self.excerpts.prev(&());
@@ -6467,9 +6598,9 @@ where
     }
 
     fn is_at_start_of_excerpt(&mut self) -> bool {
-        if self.diff_transforms.start().1 > *self.excerpts.start() {
+        if self.diff_transforms.start().excerpt_dimension > *self.excerpts.start() {
             return false;
-        } else if self.diff_transforms.start().1 < *self.excerpts.start() {
+        } else if self.diff_transforms.start().excerpt_dimension < *self.excerpts.start() {
             return true;
         }
 
@@ -6483,9 +6614,9 @@ where
     }
 
     fn is_at_end_of_excerpt(&mut self) -> bool {
-        if self.diff_transforms.end(&()).1 < self.excerpts.end(&()) {
+        if self.diff_transforms.end(&()).excerpt_dimension < self.excerpts.end(&()) {
             return false;
-        } else if self.diff_transforms.end(&()).1 > self.excerpts.end(&())
+        } else if self.diff_transforms.end(&()).excerpt_dimension > self.excerpts.end(&())
             || self.diff_transforms.item().is_none()
         {
             return true;
@@ -6506,7 +6637,7 @@ where
         let buffer = &excerpt.buffer;
         let buffer_context_start = excerpt.range.context.start.summary::<D>(buffer);
         let mut buffer_start = buffer_context_start;
-        let overshoot = self.diff_transforms.end(&()).1.0 - self.excerpts.start().0;
+        let overshoot = self.diff_transforms.end(&()).excerpt_dimension.0 - self.excerpts.start().0;
         buffer_start.add_assign(&overshoot);
         Some(buffer_start)
     }
@@ -6528,8 +6659,8 @@ where
                 let buffer_range_len = rope_cursor.summary::<D>(base_text_byte_range.end);
                 let mut buffer_end = buffer_start;
                 buffer_end.add_assign(&buffer_range_len);
-                let start = self.diff_transforms.start().0.0;
-                let end = self.diff_transforms.end(&()).0.0;
+                let start = self.diff_transforms.start().output_dimension.0;
+                let end = self.diff_transforms.end(&()).output_dimension.0;
                 return Some(MultiBufferRegion {
                     buffer,
                     excerpt,
@@ -6548,28 +6679,32 @@ where
                 let buffer = &excerpt.buffer;
                 let buffer_context_start = excerpt.range.context.start.summary::<D>(buffer);
 
-                let mut start = self.diff_transforms.start().0.0;
+                let mut start = self.diff_transforms.start().output_dimension.0;
                 let mut buffer_start = buffer_context_start;
-                if self.diff_transforms.start().1 < *self.excerpts.start() {
-                    let overshoot = self.excerpts.start().0 - self.diff_transforms.start().1.0;
+                if self.diff_transforms.start().excerpt_dimension < *self.excerpts.start() {
+                    let overshoot =
+                        self.excerpts.start().0 - self.diff_transforms.start().excerpt_dimension.0;
                     start.add_assign(&overshoot);
                 } else {
-                    let overshoot = self.diff_transforms.start().1.0 - self.excerpts.start().0;
+                    let overshoot =
+                        self.diff_transforms.start().excerpt_dimension.0 - self.excerpts.start().0;
                     buffer_start.add_assign(&overshoot);
                 }
 
                 let mut end;
                 let mut buffer_end;
                 let has_trailing_newline;
-                if self.diff_transforms.end(&()).1.0 < self.excerpts.end(&()).0 {
-                    let overshoot = self.diff_transforms.end(&()).1.0 - self.excerpts.start().0;
-                    end = self.diff_transforms.end(&()).0.0;
+                if self.diff_transforms.end(&()).excerpt_dimension.0 < self.excerpts.end(&()).0 {
+                    let overshoot =
+                        self.diff_transforms.end(&()).excerpt_dimension.0 - self.excerpts.start().0;
+                    end = self.diff_transforms.end(&()).output_dimension.0;
                     buffer_end = buffer_context_start;
                     buffer_end.add_assign(&overshoot);
                     has_trailing_newline = false;
                 } else {
-                    let overshoot = self.excerpts.end(&()).0 - self.diff_transforms.start().1.0;
-                    end = self.diff_transforms.start().0.0;
+                    let overshoot =
+                        self.excerpts.end(&()).0 - self.diff_transforms.start().excerpt_dimension.0;
+                    end = self.diff_transforms.start().output_dimension.0;
                     end.add_assign(&overshoot);
                     buffer_end = excerpt.range.context.end.summary::<D>(buffer);
                     has_trailing_newline = excerpt.has_trailing_newline;
@@ -6818,7 +6953,7 @@ impl Excerpt {
         }
     }
 
-    fn chunks_in_range(&self, range: Range<usize>, language_aware: bool) -> ExcerptChunks {
+    fn chunks_in_range(&self, range: Range<usize>, language_aware: bool) -> ExcerptChunks<'_> {
         let content_start = self.range.context.start.to_offset(&self.buffer);
         let chunks_start = content_start + range.start;
         let chunks_end = content_start + cmp::min(range.end, self.text_summary.len);
@@ -6965,9 +7100,9 @@ impl<'a> MultiBufferExcerpt<'a> {
     }
 
     fn map_offset_to_buffer_internal(&self, offset: usize) -> usize {
-        let mut excerpt_offset = self.diff_transforms.start().1.clone();
+        let mut excerpt_offset = self.diff_transforms.start().excerpt_dimension.clone();
         if let Some(DiffTransform::BufferContent { .. }) = self.diff_transforms.item() {
-            excerpt_offset.0 += offset - self.diff_transforms.start().0.0;
+            excerpt_offset.0 += offset - self.diff_transforms.start().output_dimension.0;
         };
         let offset_in_excerpt = excerpt_offset.0.saturating_sub(self.excerpt_offset.0);
         self.buffer_offset + offset_in_excerpt
@@ -6990,22 +7125,22 @@ impl<'a> MultiBufferExcerpt<'a> {
         let overshoot = buffer_range.start - self.buffer_offset;
         let excerpt_offset = ExcerptDimension(self.excerpt_offset.0 + overshoot);
         self.diff_transforms.seek(&excerpt_offset, Bias::Right, &());
-        if excerpt_offset.0 < self.diff_transforms.start().1.0 {
+        if excerpt_offset.0 < self.diff_transforms.start().excerpt_dimension.0 {
             log::warn!(
                 "Attempting to map a range from a buffer offset that starts before the current buffer offset"
             );
             return buffer_range;
         }
-        let overshoot = excerpt_offset.0 - self.diff_transforms.start().1.0;
-        let start = self.diff_transforms.start().0.0 + overshoot;
+        let overshoot = excerpt_offset.0 - self.diff_transforms.start().excerpt_dimension.0;
+        let start = self.diff_transforms.start().output_dimension.0 + overshoot;
 
         let end = if buffer_range.end > buffer_range.start {
             let overshoot = buffer_range.end - self.buffer_offset;
             let excerpt_offset = ExcerptDimension(self.excerpt_offset.0 + overshoot);
             self.diff_transforms
                 .seek_forward(&excerpt_offset, Bias::Right, &());
-            let overshoot = excerpt_offset.0 - self.diff_transforms.start().1.0;
-            self.diff_transforms.start().0.0 + overshoot
+            let overshoot = excerpt_offset.0 - self.diff_transforms.start().excerpt_dimension.0;
+            self.diff_transforms.start().output_dimension.0 + overshoot
         } else {
             start
         };
@@ -7172,7 +7307,7 @@ impl sum_tree::Summary for ExcerptSummary {
     fn add_summary(&mut self, summary: &Self, _: &()) {
         debug_assert!(summary.excerpt_locator > self.excerpt_locator);
         self.excerpt_locator = summary.excerpt_locator.clone();
-        self.text.add_summary(&summary.text, &());
+        Summary::add_summary(&mut self.text, &summary.text, &());
         self.widest_line_number = cmp::max(self.widest_line_number, summary.widest_line_number);
     }
 }
@@ -7281,16 +7416,11 @@ impl<D: TextDimension + Ord> sum_tree::SeekTarget<'_, DiffTransformSummary, Diff
     }
 }
 
-impl<D: TextDimension + Ord>
-    sum_tree::SeekTarget<'_, DiffTransformSummary, (OutputDimension<D>, ExcerptDimension<D>)>
+impl<D: TextDimension + Ord> sum_tree::SeekTarget<'_, DiffTransformSummary, DiffTransforms<D>>
     for ExcerptDimension<D>
 {
-    fn cmp(
-        &self,
-        cursor_location: &(OutputDimension<D>, ExcerptDimension<D>),
-        _: &(),
-    ) -> cmp::Ordering {
-        Ord::cmp(&self.0, &cursor_location.1.0)
+    fn cmp(&self, cursor_location: &DiffTransforms<D>, _: &()) -> cmp::Ordering {
+        Ord::cmp(&self.0, &cursor_location.excerpt_dimension.0)
     }
 }
 
@@ -7304,6 +7434,14 @@ impl<'a, D: TextDimension> sum_tree::Dimension<'a, DiffTransformSummary> for Exc
     }
 }
 
+impl<D: TextDimension + Ord> sum_tree::SeekTarget<'_, DiffTransformSummary, DiffTransforms<D>>
+    for OutputDimension<D>
+{
+    fn cmp(&self, cursor_location: &DiffTransforms<D>, _: &()) -> cmp::Ordering {
+        Ord::cmp(&self.0, &cursor_location.output_dimension.0)
+    }
+}
+
 impl<'a, D: TextDimension> sum_tree::Dimension<'a, DiffTransformSummary> for OutputDimension<D> {
     fn zero(_: &()) -> Self {
         OutputDimension(D::default())
@@ -7372,7 +7510,7 @@ impl Iterator for MultiBufferRows<'_> {
             if let Some(next_region) = self.cursor.region() {
                 region = next_region;
             } else {
-                if self.point == self.cursor.diff_transforms.end(&()).0.0 {
+                if self.point == self.cursor.diff_transforms.end(&()).output_dimension.0 {
                     let multibuffer_row = MultiBufferRow(self.point.row);
                     let last_excerpt = self
                         .cursor

crates/multi_buffer/src/multi_buffer_tests.rs 🔗

@@ -11,9 +11,7 @@ use util::test::sample_text;
 
 #[ctor::ctor]
 fn init_logger() {
-    if std::env::var("RUST_LOG").is_ok() {
-        env_logger::init();
-    }
+    zlog::init_test();
 }
 
 #[gpui::test]
@@ -1594,7 +1592,6 @@ fn test_set_excerpts_for_buffer_ordering(cx: &mut TestAppContext) {
              six
              seven
              eight
-             -----
              nine
              ten
              eleven
@@ -1850,7 +1847,6 @@ fn test_set_excerpts_for_buffer_rename(cx: &mut TestAppContext) {
         zero
         one
         two
-        -----
         three
         four
         five
@@ -2846,6 +2842,22 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
                 .unwrap()
                 + 1
         );
+        let reference_ranges = cx.update(|cx| {
+            reference
+                .excerpts
+                .iter()
+                .map(|excerpt| {
+                    (
+                        excerpt.id,
+                        excerpt.range.to_offset(&excerpt.buffer.read(cx).snapshot()),
+                    )
+                })
+                .collect::<HashMap<_, _>>()
+        });
+        for i in 0..snapshot.len() {
+            let excerpt = snapshot.excerpt_containing(i..i).unwrap();
+            assert_eq!(excerpt.buffer_range(), reference_ranges[&excerpt.id()]);
+        }
 
         assert_consistent_line_numbers(&snapshot);
         assert_position_translation(&snapshot);
@@ -3639,3 +3651,69 @@ fn assert_line_indents(snapshot: &MultiBufferSnapshot) {
         "reversed_line_indents({max_row})"
     );
 }
+
+#[gpui::test]
+fn test_new_empty_buffer_uses_untitled_title(cx: &mut App) {
+    let buffer = cx.new(|cx| Buffer::local("", cx));
+    let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
+
+    assert_eq!(multibuffer.read(cx).title(cx), "untitled");
+}
+
+#[gpui::test]
+fn test_new_empty_buffer_uses_untitled_title_when_only_contains_whitespace(cx: &mut App) {
+    let buffer = cx.new(|cx| Buffer::local("\n ", cx));
+    let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
+
+    assert_eq!(multibuffer.read(cx).title(cx), "untitled");
+}
+
+#[gpui::test]
+fn test_new_empty_buffer_takes_first_line_for_title(cx: &mut App) {
+    let buffer = cx.new(|cx| Buffer::local("Hello World\nSecond line", cx));
+    let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
+
+    assert_eq!(multibuffer.read(cx).title(cx), "Hello World");
+}
+
+#[gpui::test]
+fn test_new_empty_buffer_takes_trimmed_first_line_for_title(cx: &mut App) {
+    let buffer = cx.new(|cx| Buffer::local("\nHello, World ", cx));
+    let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
+
+    assert_eq!(multibuffer.read(cx).title(cx), "Hello, World");
+}
+
+#[gpui::test]
+fn test_new_empty_buffer_uses_truncated_first_line_for_title(cx: &mut App) {
+    let title = "aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeee";
+    let title_after = "aaaaaaaaaabbbbbbbbbbccccccccccdddddddddd";
+    let buffer = cx.new(|cx| Buffer::local(title, cx));
+    let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
+
+    assert_eq!(multibuffer.read(cx).title(cx), title_after);
+}
+
+#[gpui::test]
+fn test_new_empty_buffer_uses_truncated_first_line_for_title_after_merging_adjacent_spaces(
+    cx: &mut App,
+) {
+    let title = "aaaaaaaaaabbbbbbbbbb    ccccccccccddddddddddeeeeeeeeee";
+    let title_after = "aaaaaaaaaabbbbbbbbbb ccccccccccddddddddd";
+    let buffer = cx.new(|cx| Buffer::local(title, cx));
+    let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
+
+    assert_eq!(multibuffer.read(cx).title(cx), title_after);
+}
+
+#[gpui::test]
+fn test_new_empty_buffers_title_can_be_set(cx: &mut App) {
+    let buffer = cx.new(|cx| Buffer::local("Hello World", cx));
+    let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
+    assert_eq!(multibuffer.read(cx).title(cx), "Hello World");
+
+    multibuffer.update(cx, |multibuffer, cx| {
+        multibuffer.set_title("Hey".into(), cx)
+    });
+    assert_eq!(multibuffer.read(cx).title(cx), "Hey");
+}

crates/multi_buffer/src/position.rs 🔗

@@ -126,17 +126,17 @@ impl<T> Default for TypedRow<T> {
 
 impl<T> PartialOrd for TypedOffset<T> {
     fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
-        Some(self.value.cmp(&other.value))
+        Some(self.cmp(&other))
     }
 }
 impl<T> PartialOrd for TypedPoint<T> {
     fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
-        Some(self.value.cmp(&other.value))
+        Some(self.cmp(&other))
     }
 }
 impl<T> PartialOrd for TypedRow<T> {
     fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
-        Some(self.value.cmp(&other.value))
+        Some(self.cmp(&other))
     }
 }
 

crates/node_runtime/Cargo.toml 🔗

@@ -13,15 +13,13 @@ path = "src/node_runtime.rs"
 doctest = false
 
 [features]
-test-support = ["tempfile"]
+test-support = []
 
 [dependencies]
 anyhow.workspace = true
 async-compression.workspace = true
-async-watch.workspace = true
 async-tar.workspace = true
 async-trait.workspace = true
-async_zip.workspace = true
 futures.workspace = true
 http_client.workspace = true
 log.workspace = true
@@ -30,14 +28,10 @@ semver.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 smol.workspace = true
-tempfile = { workspace = true, optional = true }
 util.workspace = true
-walkdir = "2.5.0"
+watch.workspace = true
 which.workspace = true
 workspace-hack.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 async-std = { version = "1.12.0", features = ["unstable"] }
-
-[dev-dependencies]
-tempfile.workspace = true

crates/node_runtime/src/archive.rs 🔗

@@ -1,118 +0,0 @@
-use std::path::Path;
-
-use anyhow::Result;
-use async_zip::base::read::stream::ZipFileReader;
-use futures::{AsyncRead, io::BufReader};
-
-pub async fn extract_zip<R: AsyncRead + Unpin>(destination: &Path, reader: R) -> Result<()> {
-    let mut reader = ZipFileReader::new(BufReader::new(reader));
-
-    let destination = &destination
-        .canonicalize()
-        .unwrap_or_else(|_| destination.to_path_buf());
-
-    while let Some(mut item) = reader.next_with_entry().await? {
-        let entry_reader = item.reader_mut();
-        let entry = entry_reader.entry();
-        let path = destination.join(entry.filename().as_str().unwrap());
-
-        if entry.dir().unwrap() {
-            std::fs::create_dir_all(&path)?;
-        } else {
-            let parent_dir = path.parent().expect("failed to get parent directory");
-            std::fs::create_dir_all(parent_dir)?;
-            let mut file = smol::fs::File::create(&path).await?;
-            futures::io::copy(entry_reader, &mut file).await?;
-        }
-
-        reader = item.skip().await?;
-    }
-
-    Ok(())
-}
-
-#[cfg(test)]
-mod tests {
-    use std::path::PathBuf;
-
-    use async_zip::ZipEntryBuilder;
-    use async_zip::base::write::ZipFileWriter;
-    use futures::AsyncWriteExt;
-    use smol::io::Cursor;
-    use tempfile::TempDir;
-
-    use super::*;
-
-    async fn compress_zip(src_dir: &Path, dst: &Path) -> Result<()> {
-        let mut out = smol::fs::File::create(dst).await?;
-        let mut writer = ZipFileWriter::new(&mut out);
-
-        for entry in walkdir::WalkDir::new(src_dir) {
-            let entry = entry?;
-            let path = entry.path();
-
-            if path.is_dir() {
-                continue;
-            }
-
-            let relative_path = path.strip_prefix(src_dir)?;
-            let data = smol::fs::read(&path).await?;
-
-            let filename = relative_path.display().to_string();
-            let builder = ZipEntryBuilder::new(filename.into(), async_zip::Compression::Deflate);
-
-            writer.write_entry_whole(builder, &data).await?;
-        }
-
-        writer.close().await?;
-        out.flush().await?;
-
-        Ok(())
-    }
-
-    #[track_caller]
-    fn assert_file_content(path: &Path, content: &str) {
-        assert!(path.exists(), "file not found: {:?}", path);
-        let actual = std::fs::read_to_string(path).unwrap();
-        assert_eq!(actual, content);
-    }
-
-    #[track_caller]
-    fn make_test_data() -> TempDir {
-        let dir = tempfile::tempdir().unwrap();
-        let dst = dir.path();
-
-        std::fs::write(dst.join("test"), "Hello world.").unwrap();
-        std::fs::create_dir_all(dst.join("foo/bar")).unwrap();
-        std::fs::write(dst.join("foo/bar.txt"), "Foo bar.").unwrap();
-        std::fs::write(dst.join("foo/dar.md"), "Bar dar.").unwrap();
-        std::fs::write(dst.join("foo/bar/dar你好.txt"), "你好世界").unwrap();
-
-        dir
-    }
-
-    async fn read_archive(path: &PathBuf) -> impl AsyncRead + Unpin {
-        let data = smol::fs::read(&path).await.unwrap();
-        Cursor::new(data)
-    }
-
-    #[test]
-    fn test_extract_zip() {
-        let test_dir = make_test_data();
-        let zip_file = test_dir.path().join("test.zip");
-
-        smol::block_on(async {
-            compress_zip(test_dir.path(), &zip_file).await.unwrap();
-            let reader = read_archive(&zip_file).await;
-
-            let dir = tempfile::tempdir().unwrap();
-            let dst = dir.path();
-            extract_zip(dst, reader).await.unwrap();
-
-            assert_file_content(&dst.join("test"), "Hello world.");
-            assert_file_content(&dst.join("foo/bar.txt"), "Foo bar.");
-            assert_file_content(&dst.join("foo/dar.md"), "Bar dar.");
-            assert_file_content(&dst.join("foo/bar/dar你好.txt"), "你好世界");
-        });
-    }
-}

crates/node_runtime/src/node_runtime.rs 🔗

@@ -1,24 +1,24 @@
-mod archive;
-
-use anyhow::{Context, Result, anyhow, bail};
-pub use archive::extract_zip;
+use anyhow::{Context as _, Result, anyhow, bail};
 use async_compression::futures::bufread::GzipDecoder;
 use async_tar::Archive;
 use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared};
 use http_client::{HttpClient, Url};
+use log::Level;
 use semver::Version;
 use serde::Deserialize;
 use smol::io::BufReader;
 use smol::{fs, lock::Mutex};
+use std::fmt::Display;
 use std::{
     env::{self, consts},
     ffi::OsString,
     io,
     path::{Path, PathBuf},
-    process::{Output, Stdio},
+    process::Output,
     sync::Arc,
 };
 use util::ResultExt;
+use util::archive::extract_zip;
 
 const NODE_CA_CERTS_ENV_VAR: &str = "NODE_EXTRA_CA_CERTS";
 
@@ -36,7 +36,7 @@ struct NodeRuntimeState {
     http: Arc<dyn HttpClient>,
     instance: Option<Box<dyn NodeRuntimeTrait>>,
     last_options: Option<NodeBinaryOptions>,
-    options: async_watch::Receiver<Option<NodeBinaryOptions>>,
+    options: watch::Receiver<Option<NodeBinaryOptions>>,
     shell_env_loaded: Shared<oneshot::Receiver<()>>,
 }
 
@@ -44,7 +44,7 @@ impl NodeRuntime {
     pub fn new(
         http: Arc<dyn HttpClient>,
         shell_env_loaded: Option<oneshot::Receiver<()>>,
-        options: async_watch::Receiver<Option<NodeBinaryOptions>>,
+        options: watch::Receiver<Option<NodeBinaryOptions>>,
     ) -> Self {
         NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {
             http,
@@ -60,51 +60,141 @@ impl NodeRuntime {
             http: Arc::new(http_client::BlockedHttpClient),
             instance: None,
             last_options: None,
-            options: async_watch::channel(Some(NodeBinaryOptions::default())).1,
+            options: watch::channel(Some(NodeBinaryOptions::default())).1,
             shell_env_loaded: oneshot::channel().1.shared(),
         })))
     }
 
-    async fn instance(&self) -> Result<Box<dyn NodeRuntimeTrait>> {
+    async fn instance(&self) -> Box<dyn NodeRuntimeTrait> {
         let mut state = self.0.lock().await;
 
-        while state.options.borrow().is_none() {
-            state.options.changed().await?;
-        }
-        let options = state.options.borrow().clone().unwrap();
+        let options = loop {
+            match state.options.borrow().as_ref() {
+                Some(options) => break options.clone(),
+                None => {}
+            }
+            match state.options.changed().await {
+                Ok(()) => {}
+                // failure case not cached
+                Err(err) => {
+                    return Box::new(UnavailableNodeRuntime {
+                        error_message: err.to_string().into(),
+                    });
+                }
+            }
+        };
+
         if state.last_options.as_ref() != Some(&options) {
             state.instance.take();
         }
         if let Some(instance) = state.instance.as_ref() {
-            return Ok(instance.boxed_clone());
+            return instance.boxed_clone();
         }
 
         if let Some((node, npm)) = options.use_paths.as_ref() {
-            let instance = SystemNodeRuntime::new(node.clone(), npm.clone()).await?;
+            let instance = match SystemNodeRuntime::new(node.clone(), npm.clone()).await {
+                Ok(instance) => {
+                    log::info!("using Node.js from `node.path` in settings: {:?}", instance);
+                    Box::new(instance)
+                }
+                Err(err) => {
+                    // failure case not cached, since it's cheap to check again
+                    return Box::new(UnavailableNodeRuntime {
+                        error_message: format!(
+                            "failure checking Node.js from `node.path` in settings ({}): {:?}",
+                            node.display(),
+                            err
+                        )
+                        .into(),
+                    });
+                }
+            };
             state.instance = Some(instance.boxed_clone());
-            return Ok(instance);
+            state.last_options = Some(options);
+            return instance;
         }
 
-        if options.allow_path_lookup {
+        let system_node_error = if options.allow_path_lookup {
             state.shell_env_loaded.clone().await.ok();
-            if let Some(instance) = SystemNodeRuntime::detect().await {
-                state.instance = Some(instance.boxed_clone());
-                return Ok(instance);
+            match SystemNodeRuntime::detect().await {
+                Ok(instance) => {
+                    log::info!("using Node.js found on PATH: {:?}", instance);
+                    state.instance = Some(instance.boxed_clone());
+                    state.last_options = Some(options);
+                    return Box::new(instance);
+                }
+                Err(err) => Some(err),
             }
-        }
+        } else {
+            None
+        };
 
         let instance = if options.allow_binary_download {
-            ManagedNodeRuntime::install_if_needed(&state.http).await?
+            let (log_level, why_using_managed) = match system_node_error {
+                Some(err @ DetectError::Other(_)) => (Level::Warn, err.to_string()),
+                Some(err @ DetectError::NotInPath(_)) => (Level::Info, err.to_string()),
+                None => (
+                    Level::Info,
+                    "`node.ignore_system_version` is `true` in settings".to_string(),
+                ),
+            };
+            match ManagedNodeRuntime::install_if_needed(&state.http).await {
+                Ok(instance) => {
+                    log::log!(
+                        log_level,
+                        "using Zed managed Node.js at {} since {}",
+                        instance.installation_path.display(),
+                        why_using_managed
+                    );
+                    Box::new(instance) as Box<dyn NodeRuntimeTrait>
+                }
+                Err(err) => {
+                    // failure case is cached, since downloading + installing may be expensive. The
+                    // downside of this is that it may fail due to an intermittent network issue.
+                    //
+                    // TODO: Have `install_if_needed` indicate which failure cases are retryable
+                    // and/or have shared tracking of when internet is available.
+                    Box::new(UnavailableNodeRuntime {
+                        error_message: format!(
+                            "failure while downloading and/or installing Zed managed Node.js, \
+                            restart Zed to retry: {}",
+                            err
+                        )
+                        .into(),
+                    }) as Box<dyn NodeRuntimeTrait>
+                }
+            }
+        } else if let Some(system_node_error) = system_node_error {
+            // failure case not cached, since it's cheap to check again
+            //
+            // TODO: When support is added for setting `options.allow_binary_download`, update this
+            // error message.
+            return Box::new(UnavailableNodeRuntime {
+                error_message: format!(
+                    "failure while checking system Node.js from PATH: {}",
+                    system_node_error
+                )
+                .into(),
+            });
         } else {
-            Box::new(UnavailableNodeRuntime)
+            // failure case is cached because it will always happen with these options
+            //
+            // TODO: When support is added for setting `options.allow_binary_download`, update this
+            // error message.
+            Box::new(UnavailableNodeRuntime {
+                error_message: "`node` settings do not allow any way to use Node.js"
+                    .to_string()
+                    .into(),
+            })
         };
 
         state.instance = Some(instance.boxed_clone());
-        return Ok(instance);
+        state.last_options = Some(options);
+        return instance;
     }
 
     pub async fn binary_path(&self) -> Result<PathBuf> {
-        self.instance().await?.binary_path()
+        self.instance().await.binary_path()
     }
 
     pub async fn run_npm_subcommand(
@@ -115,7 +205,7 @@ impl NodeRuntime {
     ) -> Result<Output> {
         let http = self.0.lock().await.http.clone();
         self.instance()
-            .await?
+            .await
             .run_npm_subcommand(Some(directory), http.proxy(), subcommand, args)
             .await
     }
@@ -126,7 +216,7 @@ impl NodeRuntime {
         name: &str,
     ) -> Result<Option<String>> {
         self.instance()
-            .await?
+            .await
             .npm_package_installed_version(local_package_directory, name)
             .await
     }
@@ -135,7 +225,7 @@ impl NodeRuntime {
         let http = self.0.lock().await.http.clone();
         let output = self
             .instance()
-            .await?
+            .await
             .run_npm_subcommand(
                 None,
                 http.proxy(),
@@ -157,7 +247,7 @@ impl NodeRuntime {
         info.dist_tags
             .latest
             .or_else(|| info.versions.pop())
-            .ok_or_else(|| anyhow!("no version found for npm package {}", name))
+            .with_context(|| format!("no version found for npm package {name}"))
     }
 
     pub async fn npm_install_packages(
@@ -281,7 +371,7 @@ impl ManagedNodeRuntime {
     #[cfg(windows)]
     const NPM_PATH: &str = "node_modules/npm/bin/npm-cli.js";
 
-    async fn install_if_needed(http: &Arc<dyn HttpClient>) -> Result<Box<dyn NodeRuntimeTrait>> {
+    async fn install_if_needed(http: &Arc<dyn HttpClient>) -> Result<Self> {
         log::info!("Node runtime install_if_needed");
 
         let os = match consts::OS {
@@ -305,20 +395,43 @@ impl ManagedNodeRuntime {
         let npm_file = node_dir.join(Self::NPM_PATH);
         let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new());
 
-        let result = util::command::new_smol_command(&node_binary)
-            .env_clear()
-            .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
-            .arg(npm_file)
-            .arg("--version")
-            .stdin(Stdio::null())
-            .stdout(Stdio::null())
-            .stderr(Stdio::null())
-            .args(["--cache".into(), node_dir.join("cache")])
-            .args(["--userconfig".into(), node_dir.join("blank_user_npmrc")])
-            .args(["--globalconfig".into(), node_dir.join("blank_global_npmrc")])
-            .status()
-            .await;
-        let valid = matches!(result, Ok(status) if status.success());
+        let valid = if fs::metadata(&node_binary).await.is_ok() {
+            let result = util::command::new_smol_command(&node_binary)
+                .env_clear()
+                .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
+                .arg(npm_file)
+                .arg("--version")
+                .args(["--cache".into(), node_dir.join("cache")])
+                .args(["--userconfig".into(), node_dir.join("blank_user_npmrc")])
+                .args(["--globalconfig".into(), node_dir.join("blank_global_npmrc")])
+                .output()
+                .await;
+            match result {
+                Ok(output) => {
+                    if output.status.success() {
+                        true
+                    } else {
+                        log::warn!(
+                            "Zed managed Node.js binary at {} failed check with output: {:?}",
+                            node_binary.display(),
+                            output
+                        );
+                        false
+                    }
+                }
+                Err(err) => {
+                    log::warn!(
+                        "Zed managed Node.js binary at {} failed check, so re-downloading it. \
+                        Error: {}",
+                        node_binary.display(),
+                        err
+                    );
+                    false
+                }
+            }
+        } else {
+            false
+        };
 
         if !valid {
             _ = fs::remove_dir_all(&node_containing_dir).await;
@@ -340,11 +453,14 @@ impl ManagedNodeRuntime {
                     ArchiveType::Zip => "zip",
                 }
             );
+
             let url = format!("https://nodejs.org/dist/{version}/{file_name}");
+            log::info!("Downloading Node.js binary from {url}");
             let mut response = http
                 .get(&url, Default::default(), true)
                 .await
                 .context("error downloading Node binary tarball")?;
+            log::info!("Download of Node.js complete, extracting...");
 
             let body = response.body_mut();
             match archive_type {
@@ -353,8 +469,9 @@ impl ManagedNodeRuntime {
                     let archive = Archive::new(decompressed_bytes);
                     archive.unpack(&node_containing_dir).await?;
                 }
-                ArchiveType::Zip => archive::extract_zip(&node_containing_dir, body).await?,
+                ArchiveType::Zip => extract_zip(&node_containing_dir, body).await?,
             }
+            log::info!("Extracted Node.js to {}", node_containing_dir.display())
         }
 
         // Note: Not in the `if !valid {}` so we can populate these for existing installations
@@ -362,9 +479,9 @@ impl ManagedNodeRuntime {
         _ = fs::write(node_dir.join("blank_user_npmrc"), []).await;
         _ = fs::write(node_dir.join("blank_global_npmrc"), []).await;
 
-        anyhow::Ok(Box::new(ManagedNodeRuntime {
+        anyhow::Ok(ManagedNodeRuntime {
             installation_path: node_dir,
-        }))
+        })
     }
 }
 
@@ -411,13 +528,14 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
             let npm_file = self.installation_path.join(Self::NPM_PATH);
             let env_path = path_with_node_binary_prepended(&node_binary).unwrap_or_default();
 
-            if smol::fs::metadata(&node_binary).await.is_err() {
-                return Err(anyhow!("missing node binary file"));
-            }
-
-            if smol::fs::metadata(&npm_file).await.is_err() {
-                return Err(anyhow!("missing npm file"));
-            }
+            anyhow::ensure!(
+                smol::fs::metadata(&node_binary).await.is_ok(),
+                "missing node binary file"
+            );
+            anyhow::ensure!(
+                smol::fs::metadata(&npm_file).await.is_ok(),
+                "missing npm file"
+            );
 
             let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new());
 
@@ -443,22 +561,20 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
         let mut output = attempt().await;
         if output.is_err() {
             output = attempt().await;
-            if output.is_err() {
-                return Err(anyhow!(
-                    "failed to launch npm subcommand {subcommand} subcommand\nerr: {:?}",
-                    output.err()
-                ));
-            }
+            anyhow::ensure!(
+                output.is_ok(),
+                "failed to launch npm subcommand {subcommand} subcommand\nerr: {:?}",
+                output.err()
+            );
         }
 
         if let Ok(output) = &output {
-            if !output.status.success() {
-                return Err(anyhow!(
-                    "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
-                    String::from_utf8_lossy(&output.stdout),
-                    String::from_utf8_lossy(&output.stderr)
-                ));
-            }
+            anyhow::ensure!(
+                output.status.success(),
+                "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
+                String::from_utf8_lossy(&output.stdout),
+                String::from_utf8_lossy(&output.stderr)
+            );
         }
 
         output.map_err(|e| anyhow!("{e}"))
@@ -472,7 +588,7 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
     }
 }
 
-#[derive(Clone)]
+#[derive(Debug, Clone)]
 pub struct SystemNodeRuntime {
     node: PathBuf,
     npm: PathBuf,
@@ -482,7 +598,7 @@ pub struct SystemNodeRuntime {
 
 impl SystemNodeRuntime {
     const MIN_VERSION: semver::Version = Version::new(20, 0, 0);
-    async fn new(node: PathBuf, npm: PathBuf) -> Result<Box<dyn NodeRuntimeTrait>> {
+    async fn new(node: PathBuf, npm: PathBuf) -> Result<Self> {
         let output = util::command::new_smol_command(&node)
             .arg("--version")
             .output()
@@ -520,13 +636,31 @@ impl SystemNodeRuntime {
         this.global_node_modules =
             PathBuf::from(String::from_utf8_lossy(&output.stdout).to_string());
 
-        Ok(Box::new(this))
+        Ok(this)
+    }
+
+    async fn detect() -> std::result::Result<Self, DetectError> {
+        let node = which::which("node").map_err(DetectError::NotInPath)?;
+        let npm = which::which("npm").map_err(DetectError::NotInPath)?;
+        Self::new(node, npm).await.map_err(DetectError::Other)
     }
+}
+
+enum DetectError {
+    NotInPath(which::Error),
+    Other(anyhow::Error),
+}
 
-    async fn detect() -> Option<Box<dyn NodeRuntimeTrait>> {
-        let node = which::which("node").ok()?;
-        let npm = which::which("npm").ok()?;
-        Self::new(node, npm).await.log_err()
+impl Display for DetectError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            DetectError::NotInPath(err) => {
+                write!(f, "system Node.js wasn't found on PATH: {}", err)
+            }
+            DetectError::Other(err) => {
+                write!(f, "checking system Node.js failed with error: {}", err)
+            }
+        }
     }
 }
 
@@ -559,14 +693,12 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
             .args(args);
         configure_npm_command(&mut command, directory, proxy);
         let output = command.output().await?;
-        if !output.status.success() {
-            return Err(anyhow!(
-                "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
-                String::from_utf8_lossy(&output.stdout),
-                String::from_utf8_lossy(&output.stderr)
-            ));
-        }
-
+        anyhow::ensure!(
+            output.status.success(),
+            "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
+            String::from_utf8_lossy(&output.stdout),
+            String::from_utf8_lossy(&output.stderr)
+        );
         Ok(output)
     }
 
@@ -608,15 +740,18 @@ pub async fn read_package_installed_version(
     Ok(Some(package_json.version))
 }
 
-pub struct UnavailableNodeRuntime;
+#[derive(Clone)]
+pub struct UnavailableNodeRuntime {
+    error_message: Arc<String>,
+}
 
 #[async_trait::async_trait]
 impl NodeRuntimeTrait for UnavailableNodeRuntime {
     fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
-        Box::new(UnavailableNodeRuntime)
+        Box::new(self.clone())
     }
     fn binary_path(&self) -> Result<PathBuf> {
-        bail!("binary_path: no node runtime available")
+        bail!("{}", self.error_message)
     }
 
     async fn run_npm_subcommand(
@@ -626,7 +761,7 @@ impl NodeRuntimeTrait for UnavailableNodeRuntime {
         _: &str,
         _: &[&str],
     ) -> anyhow::Result<Output> {
-        bail!("run_npm_subcommand: no node runtime available")
+        bail!("{}", self.error_message)
     }
 
     async fn npm_package_installed_version(
@@ -634,7 +769,7 @@ impl NodeRuntimeTrait for UnavailableNodeRuntime {
         _local_package_directory: &Path,
         _: &str,
     ) -> Result<Option<String>> {
-        bail!("npm_package_installed_version: no node runtime available")
+        bail!("{}", self.error_message)
     }
 }
 

crates/notifications/Cargo.toml 🔗

@@ -28,7 +28,6 @@ collections.workspace = true
 component.workspace = true
 db.workspace = true
 gpui.workspace = true
-linkme.workspace = true
 rpc.workspace = true
 sum_tree.workspace = true
 time.workspace = true
@@ -36,6 +35,7 @@ ui.workspace = true
 util.workspace = true
 workspace.workspace = true
 workspace-hack.workspace = true
+zed_actions.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }

crates/notifications/src/status_toast.rs 🔗

@@ -3,6 +3,7 @@ use std::rc::Rc;
 use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement};
 use ui::{Tooltip, prelude::*};
 use workspace::{ToastAction, ToastView};
+use zed_actions::toast;
 
 #[derive(Clone, Copy)]
 pub struct ToastIcon {
@@ -38,6 +39,7 @@ pub struct StatusToast {
     icon: Option<ToastIcon>,
     text: SharedString,
     action: Option<ToastAction>,
+    show_dismiss: bool,
     this_handle: Entity<Self>,
     focus_handle: FocusHandle,
 }
@@ -56,6 +58,7 @@ impl StatusToast {
                     text: text.into(),
                     icon: None,
                     action: None,
+                    show_dismiss: false,
                     this_handle: cx.entity(),
                     focus_handle,
                 },
@@ -86,20 +89,33 @@ impl StatusToast {
         ));
         self
     }
+
+    pub fn dismiss_button(mut self, show: bool) -> Self {
+        self.show_dismiss = show;
+        self
+    }
 }
 
 impl Render for StatusToast {
     fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let has_action_or_dismiss = self.action.is_some() || self.show_dismiss;
+
         h_flex()
             .id("status-toast")
             .elevation_3(cx)
             .gap_2()
             .py_1p5()
-            .px_2p5()
+            .pl_2p5()
+            .map(|this| {
+                if has_action_or_dismiss {
+                    this.pr_1p5()
+                } else {
+                    this.pr_2p5()
+                }
+            })
             .flex_none()
             .bg(cx.theme().colors().surface_background)
             .shadow_lg()
-            .items_center()
             .when_some(self.icon.as_ref(), |this, icon| {
                 this.child(Icon::new(icon.icon).color(icon.color))
             })
@@ -109,7 +125,7 @@ impl Render for StatusToast {
                     Button::new(action.id.clone(), action.label.clone())
                         .tooltip(Tooltip::for_action_title(
                             action.label.clone(),
-                            &workspace::RunAction,
+                            &toast::RunAction,
                         ))
                         .color(Color::Muted)
                         .when_some(action.on_click.clone(), |el, handler| {
@@ -117,6 +133,20 @@ impl Render for StatusToast {
                         }),
                 )
             })
+            .when(self.show_dismiss, |this| {
+                let handle = self.this_handle.clone();
+                this.child(
+                    IconButton::new("dismiss", IconName::Close)
+                        .icon_size(IconSize::XSmall)
+                        .icon_color(Color::Muted)
+                        .tooltip(Tooltip::text("Dismiss"))
+                        .on_click(move |_click_event, _window, cx| {
+                            handle.update(cx, |_, cx| {
+                                cx.emit(DismissEvent);
+                            });
+                        }),
+                )
+            })
     }
 }
 
@@ -146,6 +176,9 @@ impl Component for StatusToast {
             this.action("Restart", |_, _| {})
         });
 
+        let dismiss_button_example =
+            StatusToast::new("Dismiss Button", cx, |this, _| this.dismiss_button(true));
+
         let icon_example = StatusToast::new(
             "Nathan Sobo accepted your contact request",
             cx,
@@ -192,6 +225,10 @@ impl Component for StatusToast {
                                 div().child(action_example).into_any_element(),
                             ),
                             single_example("Icon", div().child(icon_example).into_any_element()),
+                            single_example(
+                                "Dismiss Button",
+                                div().child(dismiss_button_example).into_any_element(),
+                            ),
                         ],
                     ),
                     example_group_with_title(

crates/ollama/src/ollama.rs 🔗

@@ -1,9 +1,9 @@
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
 use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, http};
 use serde::{Deserialize, Serialize};
 use serde_json::Value;
-use std::{sync::Arc, time::Duration};
+use std::time::Duration;
 
 pub const OLLAMA_API_URL: &str = "http://localhost:11434";
 
@@ -35,16 +35,18 @@ impl Default for KeepAlive {
 pub struct Model {
     pub name: String,
     pub display_name: Option<String>,
-    pub max_tokens: usize,
+    pub max_tokens: u64,
     pub keep_alive: Option<KeepAlive>,
     pub supports_tools: Option<bool>,
+    pub supports_vision: Option<bool>,
+    pub supports_thinking: Option<bool>,
 }
 
-fn get_max_tokens(name: &str) -> usize {
+fn get_max_tokens(name: &str) -> u64 {
     /// Default context length for unknown models.
-    const DEFAULT_TOKENS: usize = 2048;
+    const DEFAULT_TOKENS: u64 = 4096;
     /// Magic number. Lets many Ollama models work with ~16GB of ram.
-    const MAXIMUM_TOKENS: usize = 16384;
+    const MAXIMUM_TOKENS: u64 = 16384;
 
     match name.split(':').next().unwrap() {
         "phi" | "tinyllama" | "granite-code" => 2048,
@@ -54,9 +56,8 @@ fn get_max_tokens(name: &str) -> usize {
         "mistral" | "codestral" | "mixstral" | "llava" | "qwen2" | "qwen2.5-coder"
         | "dolphin-mixtral" => 32768,
         "llama3.1" | "llama3.2" | "llama3.3" | "phi3" | "phi3.5" | "phi4" | "command-r"
-        | "qwen3" | "gemma3" | "deepseek-coder-v2" | "deepseek-v3" | "deepseek-r1" | "yi-coder" => {
-            128000
-        }
+        | "qwen3" | "gemma3" | "deepseek-coder-v2" | "deepseek-v3" | "deepseek-r1" | "yi-coder"
+        | "devstral" => 128000,
         _ => DEFAULT_TOKENS,
     }
     .clamp(1, MAXIMUM_TOKENS)
@@ -66,8 +67,10 @@ impl Model {
     pub fn new(
         name: &str,
         display_name: Option<&str>,
-        max_tokens: Option<usize>,
+        max_tokens: Option<u64>,
         supports_tools: Option<bool>,
+        supports_vision: Option<bool>,
+        supports_thinking: Option<bool>,
     ) -> Self {
         Self {
             name: name.to_owned(),
@@ -77,6 +80,8 @@ impl Model {
             max_tokens: max_tokens.unwrap_or_else(|| get_max_tokens(name)),
             keep_alive: Some(KeepAlive::indefinite()),
             supports_tools,
+            supports_vision,
+            supports_thinking,
         }
     }
 
@@ -88,7 +93,7 @@ impl Model {
         self.display_name.as_ref().unwrap_or(&self.name)
     }
 
-    pub fn max_token_count(&self) -> usize {
+    pub fn max_token_count(&self) -> u64 {
         self.max_tokens
     }
 }
@@ -99,9 +104,14 @@ pub enum ChatMessage {
     Assistant {
         content: String,
         tool_calls: Option<Vec<OllamaToolCall>>,
+        #[serde(skip_serializing_if = "Option::is_none")]
+        images: Option<Vec<String>>,
+        thinking: Option<String>,
     },
     User {
         content: String,
+        #[serde(skip_serializing_if = "Option::is_none")]
+        images: Option<Vec<String>>,
     },
     System {
         content: String,
@@ -141,6 +151,7 @@ pub struct ChatRequest {
     pub keep_alive: KeepAlive,
     pub options: Option<ChatOptions>,
     pub tools: Vec<OllamaTool>,
+    pub think: Option<bool>,
 }
 
 impl ChatRequest {
@@ -154,7 +165,7 @@ impl ChatRequest {
 // https://github.com/ollama/ollama/blob/main/docs/modelfile.md#valid-parameters-and-values
 #[derive(Serialize, Default, Debug)]
 pub struct ChatOptions {
-    pub num_ctx: Option<usize>,
+    pub num_ctx: Option<u64>,
     pub num_predict: Option<isize>,
     pub stop: Option<Vec<String>>,
     pub temperature: Option<f32>,
@@ -172,6 +183,8 @@ pub struct ChatResponseDelta {
     pub done_reason: Option<String>,
     #[allow(unused)]
     pub done: bool,
+    pub prompt_eval_count: Option<u64>,
+    pub eval_count: Option<u64>,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -216,6 +229,14 @@ impl ModelShow {
         // .contains expects &String, which would require an additional allocation
         self.capabilities.iter().any(|v| v == "tools")
     }
+
+    pub fn supports_vision(&self) -> bool {
+        self.capabilities.iter().any(|v| v == "vision")
+    }
+
+    pub fn supports_thinking(&self) -> bool {
+        self.capabilities.iter().any(|v| v == "thinking")
+    }
 }
 
 pub async fn complete(
@@ -242,11 +263,11 @@ pub async fn complete(
         Ok(response_message)
     } else {
         let body_str = std::str::from_utf8(&body)?;
-        Err(anyhow!(
+        anyhow::bail!(
             "Failed to connect to API: {} {}",
             response.status(),
             body_str
-        ))
+        );
     }
 }
 
@@ -276,12 +297,11 @@ pub async fn stream_chat_completion(
     } else {
         let mut body = String::new();
         response.body_mut().read_to_string(&mut body).await?;
-
-        Err(anyhow!(
+        anyhow::bail!(
             "Failed to connect to Ollama API: {} {}",
             response.status(),
             body,
-        ))
+        );
     }
 }
 
@@ -303,18 +323,15 @@ pub async fn get_models(
     let mut body = String::new();
     response.body_mut().read_to_string(&mut body).await?;
 
-    if response.status().is_success() {
-        let response: LocalModelsResponse =
-            serde_json::from_str(&body).context("Unable to parse Ollama tag listing")?;
-
-        Ok(response.models)
-    } else {
-        Err(anyhow!(
-            "Failed to connect to Ollama API: {} {}",
-            response.status(),
-            body,
-        ))
-    }
+    anyhow::ensure!(
+        response.status().is_success(),
+        "Failed to connect to Ollama API: {} {}",
+        response.status(),
+        body,
+    );
+    let response: LocalModelsResponse =
+        serde_json::from_str(&body).context("Unable to parse Ollama tag listing")?;
+    Ok(response.models)
 }
 
 /// Fetch details of a model, used to determine model capabilities
@@ -332,47 +349,14 @@ pub async fn show_model(client: &dyn HttpClient, api_url: &str, model: &str) ->
     let mut body = String::new();
     response.body_mut().read_to_string(&mut body).await?;
 
-    if response.status().is_success() {
-        let details: ModelShow = serde_json::from_str(body.as_str())?;
-        Ok(details)
-    } else {
-        Err(anyhow!(
-            "Failed to connect to Ollama API: {} {}",
-            response.status(),
-            body,
-        ))
-    }
-}
-
-/// Sends an empty request to Ollama to trigger loading the model
-pub async fn preload_model(client: Arc<dyn HttpClient>, api_url: &str, model: &str) -> Result<()> {
-    let uri = format!("{api_url}/api/generate");
-    let request = HttpRequest::builder()
-        .method(Method::POST)
-        .uri(uri)
-        .header("Content-Type", "application/json")
-        .body(AsyncBody::from(
-            serde_json::json!({
-                "model": model,
-                "keep_alive": "15m",
-            })
-            .to_string(),
-        ))?;
-
-    let mut response = client.send(request).await?;
-
-    if response.status().is_success() {
-        Ok(())
-    } else {
-        let mut body = String::new();
-        response.body_mut().read_to_string(&mut body).await?;
-
-        Err(anyhow!(
-            "Failed to connect to Ollama API: {} {}",
-            response.status(),
-            body,
-        ))
-    }
+    anyhow::ensure!(
+        response.status().is_success(),
+        "Failed to connect to Ollama API: {} {}",
+        response.status(),
+        body,
+    );
+    let details: ModelShow = serde_json::from_str(body.as_str())?;
+    Ok(details)
 }
 
 #[cfg(test)]
@@ -467,9 +451,12 @@ mod tests {
             ChatMessage::Assistant {
                 content,
                 tool_calls,
+                images: _,
+                thinking,
             } => {
                 assert!(content.is_empty());
                 assert!(tool_calls.is_some_and(|v| !v.is_empty()));
+                assert!(thinking.is_none());
             }
             _ => panic!("Deserialized wrong role"),
         }
@@ -531,4 +518,70 @@ mod tests {
         assert!(result.capabilities.contains(&"tools".to_string()));
         assert!(result.capabilities.contains(&"completion".to_string()));
     }
+
+    #[test]
+    fn serialize_chat_request_with_images() {
+        let base64_image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
+
+        let request = ChatRequest {
+            model: "llava".to_string(),
+            messages: vec![ChatMessage::User {
+                content: "What do you see in this image?".to_string(),
+                images: Some(vec![base64_image.to_string()]),
+            }],
+            stream: false,
+            keep_alive: KeepAlive::default(),
+            options: None,
+            think: None,
+            tools: vec![],
+        };
+
+        let serialized = serde_json::to_string(&request).unwrap();
+        assert!(serialized.contains("images"));
+        assert!(serialized.contains(base64_image));
+    }
+
+    #[test]
+    fn serialize_chat_request_without_images() {
+        let request = ChatRequest {
+            model: "llama3.2".to_string(),
+            messages: vec![ChatMessage::User {
+                content: "Hello, world!".to_string(),
+                images: None,
+            }],
+            stream: false,
+            keep_alive: KeepAlive::default(),
+            options: None,
+            think: None,
+            tools: vec![],
+        };
+
+        let serialized = serde_json::to_string(&request).unwrap();
+        assert!(!serialized.contains("images"));
+    }
+
+    #[test]
+    fn test_json_format_with_images() {
+        let base64_image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
+
+        let request = ChatRequest {
+            model: "llava".to_string(),
+            messages: vec![ChatMessage::User {
+                content: "What do you see?".to_string(),
+                images: Some(vec![base64_image.to_string()]),
+            }],
+            stream: false,
+            keep_alive: KeepAlive::default(),
+            options: None,
+            think: None,
+            tools: vec![],
+        };
+
+        let serialized = serde_json::to_string(&request).unwrap();
+
+        let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
+        let message_images = parsed["messages"][0]["images"].as_array().unwrap();
+        assert_eq!(message_images.len(), 1);
+        assert_eq!(message_images[0].as_str().unwrap(), base64_image);
+    }
 }

crates/open_ai/src/open_ai.rs 🔗

@@ -1,16 +1,9 @@
 use anyhow::{Context as _, Result, anyhow};
-use futures::{
-    AsyncBufReadExt, AsyncReadExt, StreamExt,
-    io::BufReader,
-    stream::{self, BoxStream},
-};
+use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
 use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
 use serde::{Deserialize, Serialize};
 use serde_json::Value;
-use std::{
-    convert::TryFrom,
-    future::{self, Future},
-};
+use std::{convert::TryFrom, future::Future};
 use strum::EnumIter;
 
 pub const OPEN_AI_API_URL: &str = "https://api.openai.com/v1";
@@ -37,7 +30,7 @@ impl TryFrom<String> for Role {
             "assistant" => Ok(Self::Assistant),
             "system" => Ok(Self::System),
             "tool" => Ok(Self::Tool),
-            _ => Err(anyhow!("invalid role '{value}'")),
+            _ => anyhow::bail!("invalid role '{value}'"),
         }
     }
 }
@@ -56,34 +49,30 @@ impl From<Role> for String {
 #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
 #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)]
 pub enum Model {
-    #[serde(rename = "gpt-3.5-turbo", alias = "gpt-3.5-turbo")]
+    #[serde(rename = "gpt-3.5-turbo")]
     ThreePointFiveTurbo,
-    #[serde(rename = "gpt-4", alias = "gpt-4")]
+    #[serde(rename = "gpt-4")]
     Four,
-    #[serde(rename = "gpt-4-turbo", alias = "gpt-4-turbo")]
+    #[serde(rename = "gpt-4-turbo")]
     FourTurbo,
-    #[serde(rename = "gpt-4o", alias = "gpt-4o")]
+    #[serde(rename = "gpt-4o")]
     #[default]
     FourOmni,
-    #[serde(rename = "gpt-4o-mini", alias = "gpt-4o-mini")]
+    #[serde(rename = "gpt-4o-mini")]
     FourOmniMini,
-    #[serde(rename = "gpt-4.1", alias = "gpt-4.1")]
+    #[serde(rename = "gpt-4.1")]
     FourPointOne,
-    #[serde(rename = "gpt-4.1-mini", alias = "gpt-4.1-mini")]
+    #[serde(rename = "gpt-4.1-mini")]
     FourPointOneMini,
-    #[serde(rename = "gpt-4.1-nano", alias = "gpt-4.1-nano")]
+    #[serde(rename = "gpt-4.1-nano")]
     FourPointOneNano,
-    #[serde(rename = "o1", alias = "o1")]
+    #[serde(rename = "o1")]
     O1,
-    #[serde(rename = "o1-preview", alias = "o1-preview")]
-    O1Preview,
-    #[serde(rename = "o1-mini", alias = "o1-mini")]
-    O1Mini,
-    #[serde(rename = "o3-mini", alias = "o3-mini")]
+    #[serde(rename = "o3-mini")]
     O3Mini,
-    #[serde(rename = "o3", alias = "o3")]
+    #[serde(rename = "o3")]
     O3,
-    #[serde(rename = "o4-mini", alias = "o4-mini")]
+    #[serde(rename = "o4-mini")]
     O4Mini,
 
     #[serde(rename = "custom")]
@@ -91,9 +80,9 @@ pub enum Model {
         name: String,
         /// The name displayed in the UI, such as in the assistant panel model dropdown menu.
         display_name: Option<String>,
-        max_tokens: usize,
-        max_output_tokens: Option<u32>,
-        max_completion_tokens: Option<u32>,
+        max_tokens: u64,
+        max_output_tokens: Option<u64>,
+        max_completion_tokens: Option<u64>,
     },
 }
 
@@ -113,12 +102,10 @@ impl Model {
             "gpt-4.1-mini" => Ok(Self::FourPointOneMini),
             "gpt-4.1-nano" => Ok(Self::FourPointOneNano),
             "o1" => Ok(Self::O1),
-            "o1-preview" => Ok(Self::O1Preview),
-            "o1-mini" => Ok(Self::O1Mini),
             "o3-mini" => Ok(Self::O3Mini),
             "o3" => Ok(Self::O3),
             "o4-mini" => Ok(Self::O4Mini),
-            _ => Err(anyhow!("invalid model id")),
+            invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"),
         }
     }
 
@@ -133,8 +120,6 @@ impl Model {
             Self::FourPointOneMini => "gpt-4.1-mini",
             Self::FourPointOneNano => "gpt-4.1-nano",
             Self::O1 => "o1",
-            Self::O1Preview => "o1-preview",
-            Self::O1Mini => "o1-mini",
             Self::O3Mini => "o3-mini",
             Self::O3 => "o3",
             Self::O4Mini => "o4-mini",
@@ -153,8 +138,6 @@ impl Model {
             Self::FourPointOneMini => "gpt-4.1-mini",
             Self::FourPointOneNano => "gpt-4.1-nano",
             Self::O1 => "o1",
-            Self::O1Preview => "o1-preview",
-            Self::O1Mini => "o1-mini",
             Self::O3Mini => "o3-mini",
             Self::O3 => "o3",
             Self::O4Mini => "o4-mini",
@@ -164,7 +147,7 @@ impl Model {
         }
     }
 
-    pub fn max_token_count(&self) -> usize {
+    pub fn max_token_count(&self) -> u64 {
         match self {
             Self::ThreePointFiveTurbo => 16_385,
             Self::Four => 8_192,
@@ -175,8 +158,6 @@ impl Model {
             Self::FourPointOneMini => 1_047_576,
             Self::FourPointOneNano => 1_047_576,
             Self::O1 => 200_000,
-            Self::O1Preview => 128_000,
-            Self::O1Mini => 128_000,
             Self::O3Mini => 200_000,
             Self::O3 => 200_000,
             Self::O4Mini => 200_000,
@@ -184,12 +165,23 @@ impl Model {
         }
     }
 
-    pub fn max_output_tokens(&self) -> Option<u32> {
+    pub fn max_output_tokens(&self) -> Option<u64> {
         match self {
             Self::Custom {
                 max_output_tokens, ..
             } => *max_output_tokens,
-            _ => None,
+            Self::ThreePointFiveTurbo => Some(4_096),
+            Self::Four => Some(8_192),
+            Self::FourTurbo => Some(4_096),
+            Self::FourOmni => Some(16_384),
+            Self::FourOmniMini => Some(16_384),
+            Self::FourPointOne => Some(32_768),
+            Self::FourPointOneMini => Some(32_768),
+            Self::FourPointOneNano => Some(32_768),
+            Self::O1 => Some(100_000),
+            Self::O3Mini => Some(100_000),
+            Self::O3 => Some(100_000),
+            Self::O4Mini => Some(100_000),
         }
     }
 
@@ -205,11 +197,8 @@ impl Model {
             | Self::FourOmniMini
             | Self::FourPointOne
             | Self::FourPointOneMini
-            | Self::FourPointOneNano
-            | Self::O1
-            | Self::O1Preview
-            | Self::O1Mini => true,
-            _ => false,
+            | Self::FourPointOneNano => true,
+            Self::O1 | Self::O3 | Self::O3Mini | Self::O4Mini | Model::Custom { .. } => false,
         }
     }
 }
@@ -220,7 +209,7 @@ pub struct Request {
     pub messages: Vec<RequestMessage>,
     pub stream: bool,
     #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub max_tokens: Option<u32>,
+    pub max_completion_tokens: Option<u64>,
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     pub stop: Vec<String>,
     pub temperature: f32,
@@ -233,24 +222,6 @@ pub struct Request {
     pub tools: Vec<ToolDefinition>,
 }
 
-#[derive(Debug, Serialize, Deserialize)]
-pub struct CompletionRequest {
-    pub model: String,
-    pub prompt: String,
-    pub max_tokens: u32,
-    pub temperature: f32,
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub prediction: Option<Prediction>,
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub rewrite_speculation: Option<bool>,
-}
-
-#[derive(Clone, Deserialize, Serialize, Debug)]
-#[serde(tag = "type", rename_all = "snake_case")]
-pub enum Prediction {
-    Content { content: String },
-}
-
 #[derive(Debug, Serialize, Deserialize)]
 #[serde(untagged)]
 pub enum ToolChoice {
@@ -278,22 +249,75 @@ pub struct FunctionDefinition {
 #[serde(tag = "role", rename_all = "lowercase")]
 pub enum RequestMessage {
     Assistant {
-        content: Option<String>,
+        content: Option<MessageContent>,
         #[serde(default, skip_serializing_if = "Vec::is_empty")]
         tool_calls: Vec<ToolCall>,
     },
     User {
-        content: String,
+        content: MessageContent,
     },
     System {
-        content: String,
+        content: MessageContent,
     },
     Tool {
-        content: String,
+        content: MessageContent,
         tool_call_id: String,
     },
 }
 
+#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
+#[serde(untagged)]
+pub enum MessageContent {
+    Plain(String),
+    Multipart(Vec<MessagePart>),
+}
+
+impl MessageContent {
+    pub fn empty() -> Self {
+        MessageContent::Multipart(vec![])
+    }
+
+    pub fn push_part(&mut self, part: MessagePart) {
+        match self {
+            MessageContent::Plain(text) => {
+                *self =
+                    MessageContent::Multipart(vec![MessagePart::Text { text: text.clone() }, part]);
+            }
+            MessageContent::Multipart(parts) if parts.is_empty() => match part {
+                MessagePart::Text { text } => *self = MessageContent::Plain(text),
+                MessagePart::Image { .. } => *self = MessageContent::Multipart(vec![part]),
+            },
+            MessageContent::Multipart(parts) => parts.push(part),
+        }
+    }
+}
+
+impl From<Vec<MessagePart>> for MessageContent {
+    fn from(mut parts: Vec<MessagePart>) -> Self {
+        if let [MessagePart::Text { text }] = parts.as_mut_slice() {
+            MessageContent::Plain(std::mem::take(text))
+        } else {
+            MessageContent::Multipart(parts)
+        }
+    }
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
+#[serde(tag = "type")]
+pub enum MessagePart {
+    #[serde(rename = "text")]
+    Text { text: String },
+    #[serde(rename = "image_url")]
+    Image { image_url: ImageUrl },
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
+pub struct ImageUrl {
+    pub url: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub detail: Option<String>,
+}
+
 #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
 pub struct ToolCall {
     pub id: String,
@@ -340,9 +364,9 @@ pub struct FunctionChunk {
 
 #[derive(Serialize, Deserialize, Debug)]
 pub struct Usage {
-    pub prompt_tokens: u32,
-    pub completion_tokens: u32,
-    pub total_tokens: u32,
+    pub prompt_tokens: u64,
+    pub completion_tokens: u64,
+    pub total_tokens: u64,
 }
 
 #[derive(Serialize, Deserialize, Debug)]
@@ -361,190 +385,17 @@ pub enum ResponseStreamResult {
 
 #[derive(Serialize, Deserialize, Debug)]
 pub struct ResponseStreamEvent {
-    pub created: u32,
     pub model: String,
     pub choices: Vec<ChoiceDelta>,
     pub usage: Option<Usage>,
 }
 
-#[derive(Serialize, Deserialize, Debug)]
-pub struct CompletionResponse {
-    pub id: String,
-    pub object: String,
-    pub created: u64,
-    pub model: String,
-    pub choices: Vec<CompletionChoice>,
-    pub usage: Usage,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct CompletionChoice {
-    pub text: String,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct Response {
-    pub id: String,
-    pub object: String,
-    pub created: u64,
-    pub model: String,
-    pub choices: Vec<Choice>,
-    pub usage: Usage,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct Choice {
-    pub index: u32,
-    pub message: RequestMessage,
-    pub finish_reason: Option<String>,
-}
-
-pub async fn complete(
-    client: &dyn HttpClient,
-    api_url: &str,
-    api_key: &str,
-    request: Request,
-) -> Result<Response> {
-    let uri = format!("{api_url}/chat/completions");
-    let request_builder = HttpRequest::builder()
-        .method(Method::POST)
-        .uri(uri)
-        .header("Content-Type", "application/json")
-        .header("Authorization", format!("Bearer {}", api_key));
-
-    let mut request_body = request;
-    request_body.stream = false;
-
-    let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request_body)?))?;
-    let mut response = client.send(request).await?;
-
-    if response.status().is_success() {
-        let mut body = String::new();
-        response.body_mut().read_to_string(&mut body).await?;
-        let response: Response = serde_json::from_str(&body)?;
-        Ok(response)
-    } else {
-        let mut body = String::new();
-        response.body_mut().read_to_string(&mut body).await?;
-
-        #[derive(Deserialize)]
-        struct OpenAiResponse {
-            error: OpenAiError,
-        }
-
-        #[derive(Deserialize)]
-        struct OpenAiError {
-            message: String,
-        }
-
-        match serde_json::from_str::<OpenAiResponse>(&body) {
-            Ok(response) if !response.error.message.is_empty() => Err(anyhow!(
-                "Failed to connect to OpenAI API: {}",
-                response.error.message,
-            )),
-
-            _ => Err(anyhow!(
-                "Failed to connect to OpenAI API: {} {}",
-                response.status(),
-                body,
-            )),
-        }
-    }
-}
-
-pub async fn complete_text(
-    client: &dyn HttpClient,
-    api_url: &str,
-    api_key: &str,
-    request: CompletionRequest,
-) -> Result<CompletionResponse> {
-    let uri = format!("{api_url}/completions");
-    let request_builder = HttpRequest::builder()
-        .method(Method::POST)
-        .uri(uri)
-        .header("Content-Type", "application/json")
-        .header("Authorization", format!("Bearer {}", api_key));
-
-    let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?;
-    let mut response = client.send(request).await?;
-
-    if response.status().is_success() {
-        let mut body = String::new();
-        response.body_mut().read_to_string(&mut body).await?;
-        let response = serde_json::from_str(&body)?;
-        Ok(response)
-    } else {
-        let mut body = String::new();
-        response.body_mut().read_to_string(&mut body).await?;
-
-        #[derive(Deserialize)]
-        struct OpenAiResponse {
-            error: OpenAiError,
-        }
-
-        #[derive(Deserialize)]
-        struct OpenAiError {
-            message: String,
-        }
-
-        match serde_json::from_str::<OpenAiResponse>(&body) {
-            Ok(response) if !response.error.message.is_empty() => Err(anyhow!(
-                "Failed to connect to OpenAI API: {}",
-                response.error.message,
-            )),
-
-            _ => Err(anyhow!(
-                "Failed to connect to OpenAI API: {} {}",
-                response.status(),
-                body,
-            )),
-        }
-    }
-}
-
-fn adapt_response_to_stream(response: Response) -> ResponseStreamEvent {
-    ResponseStreamEvent {
-        created: response.created as u32,
-        model: response.model,
-        choices: response
-            .choices
-            .into_iter()
-            .map(|choice| ChoiceDelta {
-                index: choice.index,
-                delta: ResponseMessageDelta {
-                    role: Some(match choice.message {
-                        RequestMessage::Assistant { .. } => Role::Assistant,
-                        RequestMessage::User { .. } => Role::User,
-                        RequestMessage::System { .. } => Role::System,
-                        RequestMessage::Tool { .. } => Role::Tool,
-                    }),
-                    content: match choice.message {
-                        RequestMessage::Assistant { content, .. } => content,
-                        RequestMessage::User { content } => Some(content),
-                        RequestMessage::System { content } => Some(content),
-                        RequestMessage::Tool { content, .. } => Some(content),
-                    },
-                    tool_calls: None,
-                },
-                finish_reason: choice.finish_reason,
-            })
-            .collect(),
-        usage: Some(response.usage),
-    }
-}
-
 pub async fn stream_completion(
     client: &dyn HttpClient,
     api_url: &str,
     api_key: &str,
     request: Request,
 ) -> Result<BoxStream<'static, Result<ResponseStreamEvent>>> {
-    if request.model.starts_with("o1") {
-        let response = complete(client, api_url, api_key, request).await;
-        let response_stream_event = response.map(adapt_response_to_stream);
-        return Ok(stream::once(future::ready(response_stream_event)).boxed());
-    }
-
     let uri = format!("{api_url}/chat/completions");
     let request_builder = HttpRequest::builder()
         .method(Method::POST)
@@ -594,15 +445,17 @@ pub async fn stream_completion(
 
         match serde_json::from_str::<OpenAiResponse>(&body) {
             Ok(response) if !response.error.message.is_empty() => Err(anyhow!(
-                "Failed to connect to OpenAI API: {}",
+                "API request to {} failed: {}",
+                api_url,
                 response.error.message,
             )),
 
-            _ => Err(anyhow!(
-                "Failed to connect to OpenAI API: {} {}",
+            _ => anyhow::bail!(
+                "API request to {} failed with status {}: {}",
+                api_url,
                 response.status(),
                 body,
-            )),
+            ),
         }
     }
 }
@@ -658,16 +511,14 @@ pub fn embed<'a>(
         let mut body = String::new();
         response.body_mut().read_to_string(&mut body).await?;
 
-        if response.status().is_success() {
-            let response: OpenAiEmbeddingResponse =
-                serde_json::from_str(&body).context("failed to parse OpenAI embedding response")?;
-            Ok(response)
-        } else {
-            Err(anyhow!(
-                "error during embedding, status: {:?}, body: {:?}",
-                response.status(),
-                body
-            ))
-        }
+        anyhow::ensure!(
+            response.status().is_success(),
+            "error during embedding, status: {:?}, body: {:?}",
+            response.status(),
+            body
+        );
+        let response: OpenAiEmbeddingResponse =
+            serde_json::from_str(&body).context("failed to parse OpenAI embedding response")?;
+        Ok(response)
     }
 }

crates/open_router/Cargo.toml 🔗

@@ -0,0 +1,25 @@
+[package]
+name = "open_router"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/open_router.rs"
+
+[features]
+default = []
+schemars = ["dep:schemars"]
+
+[dependencies]
+anyhow.workspace = true
+futures.workspace = true
+http_client.workspace = true
+schemars = { workspace = true, optional = true }
+serde.workspace = true
+serde_json.workspace = true
+workspace-hack.workspace = true

crates/open_router/src/open_router.rs 🔗

@@ -0,0 +1,645 @@
+use anyhow::{Context, Result, anyhow};
+use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
+use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::convert::TryFrom;
+
+pub const OPEN_ROUTER_API_URL: &str = "https://openrouter.ai/api/v1";
+
+fn is_none_or_empty<T: AsRef<[U]>, U>(opt: &Option<T>) -> bool {
+    opt.as_ref().map_or(true, |v| v.as_ref().is_empty())
+}
+
+#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
+#[serde(rename_all = "lowercase")]
+pub enum Role {
+    User,
+    Assistant,
+    System,
+    Tool,
+}
+
+impl TryFrom<String> for Role {
+    type Error = anyhow::Error;
+
+    fn try_from(value: String) -> Result<Self> {
+        match value.as_str() {
+            "user" => Ok(Self::User),
+            "assistant" => Ok(Self::Assistant),
+            "system" => Ok(Self::System),
+            "tool" => Ok(Self::Tool),
+            _ => Err(anyhow!("invalid role '{value}'")),
+        }
+    }
+}
+
+impl From<Role> for String {
+    fn from(val: Role) -> Self {
+        match val {
+            Role::User => "user".to_owned(),
+            Role::Assistant => "assistant".to_owned(),
+            Role::System => "system".to_owned(),
+            Role::Tool => "tool".to_owned(),
+        }
+    }
+}
+
+#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
+pub struct Model {
+    pub name: String,
+    pub display_name: Option<String>,
+    pub max_tokens: u64,
+    pub supports_tools: Option<bool>,
+    pub supports_images: Option<bool>,
+    #[serde(default)]
+    pub mode: ModelMode,
+}
+
+#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
+pub enum ModelMode {
+    #[default]
+    Default,
+    Thinking {
+        budget_tokens: Option<u32>,
+    },
+}
+
+impl Model {
+    pub fn default_fast() -> Self {
+        Self::new(
+            "openrouter/auto",
+            Some("Auto Router"),
+            Some(2000000),
+            Some(true),
+            Some(false),
+            Some(ModelMode::Default),
+        )
+    }
+
+    pub fn default() -> Self {
+        Self::default_fast()
+    }
+
+    pub fn new(
+        name: &str,
+        display_name: Option<&str>,
+        max_tokens: Option<u64>,
+        supports_tools: Option<bool>,
+        supports_images: Option<bool>,
+        mode: Option<ModelMode>,
+    ) -> Self {
+        Self {
+            name: name.to_owned(),
+            display_name: display_name.map(|s| s.to_owned()),
+            max_tokens: max_tokens.unwrap_or(2000000),
+            supports_tools,
+            supports_images,
+            mode: mode.unwrap_or(ModelMode::Default),
+        }
+    }
+
+    pub fn id(&self) -> &str {
+        &self.name
+    }
+
+    pub fn display_name(&self) -> &str {
+        self.display_name.as_ref().unwrap_or(&self.name)
+    }
+
+    pub fn max_token_count(&self) -> u64 {
+        self.max_tokens
+    }
+
+    pub fn max_output_tokens(&self) -> Option<u64> {
+        None
+    }
+
+    pub fn supports_tool_calls(&self) -> bool {
+        self.supports_tools.unwrap_or(false)
+    }
+
+    pub fn supports_parallel_tool_calls(&self) -> bool {
+        false
+    }
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct Request {
+    pub model: String,
+    pub messages: Vec<RequestMessage>,
+    pub stream: bool,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub max_tokens: Option<u64>,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub stop: Vec<String>,
+    pub temperature: f32,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub tool_choice: Option<ToolChoice>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub parallel_tool_calls: Option<bool>,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub tools: Vec<ToolDefinition>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub reasoning: Option<Reasoning>,
+    pub usage: RequestUsage,
+}
+
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct RequestUsage {
+    pub include: bool,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum ToolChoice {
+    Auto,
+    Required,
+    None,
+    Other(ToolDefinition),
+}
+
+#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+#[derive(Clone, Deserialize, Serialize, Debug)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum ToolDefinition {
+    #[allow(dead_code)]
+    Function { function: FunctionDefinition },
+}
+
+#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct FunctionDefinition {
+    pub name: String,
+    pub description: Option<String>,
+    pub parameters: Option<Value>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct Reasoning {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub effort: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub max_tokens: Option<u32>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub exclude: Option<bool>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub enabled: Option<bool>,
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+#[serde(tag = "role", rename_all = "lowercase")]
+pub enum RequestMessage {
+    Assistant {
+        content: Option<MessageContent>,
+        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+        tool_calls: Vec<ToolCall>,
+    },
+    User {
+        content: MessageContent,
+    },
+    System {
+        content: MessageContent,
+    },
+    Tool {
+        content: MessageContent,
+        tool_call_id: String,
+    },
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+#[serde(untagged)]
+pub enum MessageContent {
+    Plain(String),
+    Multipart(Vec<MessagePart>),
+}
+
+impl MessageContent {
+    pub fn empty() -> Self {
+        Self::Plain(String::new())
+    }
+
+    pub fn push_part(&mut self, part: MessagePart) {
+        match self {
+            Self::Plain(text) if text.is_empty() => {
+                *self = Self::Multipart(vec![part]);
+            }
+            Self::Plain(text) => {
+                let text_part = MessagePart::Text {
+                    text: std::mem::take(text),
+                };
+                *self = Self::Multipart(vec![text_part, part]);
+            }
+            Self::Multipart(parts) => parts.push(part),
+        }
+    }
+}
+
+impl From<Vec<MessagePart>> for MessageContent {
+    fn from(parts: Vec<MessagePart>) -> Self {
+        if parts.len() == 1 {
+            if let MessagePart::Text { text } = &parts[0] {
+                return Self::Plain(text.clone());
+            }
+        }
+        Self::Multipart(parts)
+    }
+}
+
+impl From<String> for MessageContent {
+    fn from(text: String) -> Self {
+        Self::Plain(text)
+    }
+}
+
+impl From<&str> for MessageContent {
+    fn from(text: &str) -> Self {
+        Self::Plain(text.to_string())
+    }
+}
+
+impl MessageContent {
+    pub fn as_text(&self) -> Option<&str> {
+        match self {
+            Self::Plain(text) => Some(text),
+            Self::Multipart(parts) if parts.len() == 1 => {
+                if let MessagePart::Text { text } = &parts[0] {
+                    Some(text)
+                } else {
+                    None
+                }
+            }
+            _ => None,
+        }
+    }
+
+    pub fn to_text(&self) -> String {
+        match self {
+            Self::Plain(text) => text.clone(),
+            Self::Multipart(parts) => parts
+                .iter()
+                .filter_map(|part| {
+                    if let MessagePart::Text { text } = part {
+                        Some(text.as_str())
+                    } else {
+                        None
+                    }
+                })
+                .collect::<Vec<_>>()
+                .join(""),
+        }
+    }
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum MessagePart {
+    Text {
+        text: String,
+    },
+    #[serde(rename = "image_url")]
+    Image {
+        image_url: String,
+    },
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+pub struct ToolCall {
+    pub id: String,
+    #[serde(flatten)]
+    pub content: ToolCallContent,
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+#[serde(tag = "type", rename_all = "lowercase")]
+pub enum ToolCallContent {
+    Function { function: FunctionContent },
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+pub struct FunctionContent {
+    pub name: String,
+    pub arguments: String,
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+pub struct ResponseMessageDelta {
+    pub role: Option<Role>,
+    pub content: Option<String>,
+    pub reasoning: Option<String>,
+    #[serde(default, skip_serializing_if = "is_none_or_empty")]
+    pub tool_calls: Option<Vec<ToolCallChunk>>,
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+pub struct ToolCallChunk {
+    pub index: usize,
+    pub id: Option<String>,
+    pub function: Option<FunctionChunk>,
+}
+
+#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
+pub struct FunctionChunk {
+    pub name: Option<String>,
+    pub arguments: Option<String>,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct Usage {
+    pub prompt_tokens: u64,
+    pub completion_tokens: u64,
+    pub total_tokens: u64,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct ChoiceDelta {
+    pub index: u32,
+    pub delta: ResponseMessageDelta,
+    pub finish_reason: Option<String>,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct ResponseStreamEvent {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub id: Option<String>,
+    pub created: u32,
+    pub model: String,
+    pub choices: Vec<ChoiceDelta>,
+    pub usage: Option<Usage>,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct Response {
+    pub id: String,
+    pub object: String,
+    pub created: u64,
+    pub model: String,
+    pub choices: Vec<Choice>,
+    pub usage: Usage,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct Choice {
+    pub index: u32,
+    pub message: RequestMessage,
+    pub finish_reason: Option<String>,
+}
+
+#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
+pub struct ListModelsResponse {
+    pub data: Vec<ModelEntry>,
+}
+
+#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
+pub struct ModelEntry {
+    pub id: String,
+    pub name: String,
+    pub created: usize,
+    pub description: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub context_length: Option<u64>,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub supported_parameters: Vec<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub architecture: Option<ModelArchitecture>,
+}
+
+#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
+pub struct ModelArchitecture {
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub input_modalities: Vec<String>,
+}
+
+pub async fn complete(
+    client: &dyn HttpClient,
+    api_url: &str,
+    api_key: &str,
+    request: Request,
+) -> Result<Response> {
+    let uri = format!("{api_url}/chat/completions");
+    let request_builder = HttpRequest::builder()
+        .method(Method::POST)
+        .uri(uri)
+        .header("Content-Type", "application/json")
+        .header("Authorization", format!("Bearer {}", api_key))
+        .header("HTTP-Referer", "https://zed.dev")
+        .header("X-Title", "Zed Editor");
+
+    let mut request_body = request;
+    request_body.stream = false;
+
+    let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request_body)?))?;
+    let mut response = client.send(request).await?;
+
+    if response.status().is_success() {
+        let mut body = String::new();
+        response.body_mut().read_to_string(&mut body).await?;
+        let response: Response = serde_json::from_str(&body)?;
+        Ok(response)
+    } else {
+        let mut body = String::new();
+        response.body_mut().read_to_string(&mut body).await?;
+
+        #[derive(Deserialize)]
+        struct OpenRouterResponse {
+            error: OpenRouterError,
+        }
+
+        #[derive(Deserialize)]
+        struct OpenRouterError {
+            message: String,
+            #[serde(default)]
+            code: String,
+        }
+
+        match serde_json::from_str::<OpenRouterResponse>(&body) {
+            Ok(response) if !response.error.message.is_empty() => {
+                let error_message = if !response.error.code.is_empty() {
+                    format!("{}: {}", response.error.code, response.error.message)
+                } else {
+                    response.error.message
+                };
+
+                Err(anyhow!(
+                    "Failed to connect to OpenRouter API: {}",
+                    error_message
+                ))
+            }
+            _ => Err(anyhow!(
+                "Failed to connect to OpenRouter API: {} {}",
+                response.status(),
+                body,
+            )),
+        }
+    }
+}
+
+pub async fn stream_completion(
+    client: &dyn HttpClient,
+    api_url: &str,
+    api_key: &str,
+    request: Request,
+) -> Result<BoxStream<'static, Result<ResponseStreamEvent>>> {
+    let uri = format!("{api_url}/chat/completions");
+    let request_builder = HttpRequest::builder()
+        .method(Method::POST)
+        .uri(uri)
+        .header("Content-Type", "application/json")
+        .header("Authorization", format!("Bearer {}", api_key))
+        .header("HTTP-Referer", "https://zed.dev")
+        .header("X-Title", "Zed Editor");
+
+    let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?;
+    let mut response = client.send(request).await?;
+
+    if response.status().is_success() {
+        let reader = BufReader::new(response.into_body());
+        Ok(reader
+            .lines()
+            .filter_map(|line| async move {
+                match line {
+                    Ok(line) => {
+                        if line.starts_with(':') {
+                            return None;
+                        }
+
+                        let line = line.strip_prefix("data: ")?;
+                        if line == "[DONE]" {
+                            None
+                        } else {
+                            match serde_json::from_str::<ResponseStreamEvent>(line) {
+                                Ok(response) => Some(Ok(response)),
+                                Err(error) => {
+                                    #[derive(Deserialize)]
+                                    struct ErrorResponse {
+                                        error: String,
+                                    }
+
+                                    match serde_json::from_str::<ErrorResponse>(line) {
+                                        Ok(err_response) => Some(Err(anyhow!(err_response.error))),
+                                        Err(_) => {
+                                            if line.trim().is_empty() {
+                                                None
+                                            } else {
+                                                Some(Err(anyhow!(
+                                                    "Failed to parse response: {}. Original content: '{}'",
+                                                    error, line
+                                                )))
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                    Err(error) => Some(Err(anyhow!(error))),
+                }
+            })
+            .boxed())
+    } else {
+        let mut body = String::new();
+        response.body_mut().read_to_string(&mut body).await?;
+
+        #[derive(Deserialize)]
+        struct OpenRouterResponse {
+            error: OpenRouterError,
+        }
+
+        #[derive(Deserialize)]
+        struct OpenRouterError {
+            message: String,
+            #[serde(default)]
+            code: String,
+        }
+
+        match serde_json::from_str::<OpenRouterResponse>(&body) {
+            Ok(response) if !response.error.message.is_empty() => {
+                let error_message = if !response.error.code.is_empty() {
+                    format!("{}: {}", response.error.code, response.error.message)
+                } else {
+                    response.error.message
+                };
+
+                Err(anyhow!(
+                    "Failed to connect to OpenRouter API: {}",
+                    error_message
+                ))
+            }
+            _ => Err(anyhow!(
+                "Failed to connect to OpenRouter API: {} {}",
+                response.status(),
+                body,
+            )),
+        }
+    }
+}
+
+pub async fn list_models(client: &dyn HttpClient, api_url: &str) -> Result<Vec<Model>> {
+    let uri = format!("{api_url}/models");
+    let request_builder = HttpRequest::builder()
+        .method(Method::GET)
+        .uri(uri)
+        .header("Accept", "application/json");
+
+    let request = request_builder.body(AsyncBody::default())?;
+    let mut response = client.send(request).await?;
+
+    let mut body = String::new();
+    response.body_mut().read_to_string(&mut body).await?;
+
+    if response.status().is_success() {
+        let response: ListModelsResponse =
+            serde_json::from_str(&body).context("Unable to parse OpenRouter models response")?;
+
+        let models = response
+            .data
+            .into_iter()
+            .map(|entry| Model {
+                name: entry.id,
+                // OpenRouter returns display names in the format "provider_name: model_name".
+                // When displayed in the UI, these names can get truncated from the right.
+                // Since users typically already know the provider, we extract just the model name
+                // portion (after the colon) to create a more concise and user-friendly label
+                // for the model dropdown in the agent panel.
+                display_name: Some(
+                    entry
+                        .name
+                        .split(':')
+                        .next_back()
+                        .unwrap_or(&entry.name)
+                        .trim()
+                        .to_string(),
+                ),
+                max_tokens: entry.context_length.unwrap_or(2000000),
+                supports_tools: Some(entry.supported_parameters.contains(&"tools".to_string())),
+                supports_images: Some(
+                    entry
+                        .architecture
+                        .as_ref()
+                        .map(|arch| arch.input_modalities.contains(&"image".to_string()))
+                        .unwrap_or(false),
+                ),
+                mode: if entry
+                    .supported_parameters
+                    .contains(&"reasoning".to_string())
+                {
+                    ModelMode::Thinking {
+                        budget_tokens: Some(4_096),
+                    }
+                } else {
+                    ModelMode::Default
+                },
+            })
+            .collect();
+
+        Ok(models)
+    } else {
+        Err(anyhow!(
+            "Failed to connect to OpenRouter API: {} {}",
+            response.status(),
+            body,
+        ))
+    }
+}

crates/outline/src/outline.rs 🔗

@@ -4,8 +4,8 @@ use std::{
     sync::Arc,
 };
 
-use editor::RowHighlightOptions;
 use editor::{Anchor, AnchorRangeExt, Editor, scroll::Autoscroll};
+use editor::{RowHighlightOptions, SelectionEffects};
 use fuzzy::StringMatch;
 use gpui::{
     App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle,
@@ -288,9 +288,12 @@ impl PickerDelegate for OutlineViewDelegate {
                 .highlighted_rows::<OutlineRowHighlights>()
                 .next();
             if let Some((rows, _)) = highlight {
-                active_editor.change_selections(Some(Autoscroll::center()), window, cx, |s| {
-                    s.select_ranges([rows.start..rows.start])
-                });
+                active_editor.change_selections(
+                    SelectionEffects::scroll(Autoscroll::center()),
+                    window,
+                    cx,
+                    |s| s.select_ranges([rows.start..rows.start]),
+                );
                 active_editor.clear_row_highlights::<OutlineRowHighlights>();
                 window.focus(&active_editor.focus_handle(cx));
             }
@@ -532,7 +535,7 @@ mod tests {
         outline_view: &Entity<Picker<OutlineViewDelegate>>,
         cx: &mut VisualTestContext,
     ) -> Vec<String> {
-        outline_view.update(cx, |outline_view, _| {
+        outline_view.read_with(cx, |outline_view, _| {
             let items = &outline_view.delegate.outline.items;
             outline_view
                 .delegate

crates/outline_panel/src/outline_panel.rs 🔗

@@ -19,10 +19,10 @@ use collections::{BTreeSet, HashMap, HashSet, hash_map};
 use db::kvp::KEY_VALUE_STORE;
 use editor::{
     AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, EditorSettings, ExcerptId,
-    ExcerptRange, MultiBufferSnapshot, RangeToAnchorExt, ShowScrollbar,
+    ExcerptRange, MultiBufferSnapshot, RangeToAnchorExt, SelectionEffects, ShowScrollbar,
     display_map::ToDisplayPoint,
     items::{entry_git_aware_label_color, entry_label_color},
-    scroll::{Autoscroll, AutoscrollStrategy, ScrollAnchor, ScrollbarAutoHide},
+    scroll::{Autoscroll, ScrollAnchor, ScrollbarAutoHide},
 };
 use file_icons::FileIcons;
 use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
@@ -680,16 +680,25 @@ impl OutlinePanel {
         workspace: WeakEntity<Workspace>,
         mut cx: AsyncWindowContext,
     ) -> anyhow::Result<Entity<Self>> {
-        let serialized_panel = cx
-            .background_spawn(async move { KEY_VALUE_STORE.read_kvp(OUTLINE_PANEL_KEY) })
-            .await
-            .context("loading outline panel")
-            .log_err()
+        let serialized_panel = match workspace
+            .read_with(&cx, |workspace, _| {
+                OutlinePanel::serialization_key(workspace)
+            })
+            .ok()
             .flatten()
-            .map(|panel| serde_json::from_str::<SerializedOutlinePanel>(&panel))
-            .transpose()
-            .log_err()
-            .flatten();
+        {
+            Some(serialization_key) => cx
+                .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
+                .await
+                .context("loading outline panel")
+                .log_err()
+                .flatten()
+                .map(|panel| serde_json::from_str::<SerializedOutlinePanel>(&panel))
+                .transpose()
+                .log_err()
+                .flatten(),
+            None => None,
+        };
 
         workspace.update_in(&mut cx, |workspace, window, cx| {
             let panel = Self::new(workspace, window, cx);
@@ -845,14 +854,32 @@ impl OutlinePanel {
         outline_panel
     }
 
+    fn serialization_key(workspace: &Workspace) -> Option<String> {
+        workspace
+            .database_id()
+            .map(|id| i64::from(id).to_string())
+            .or(workspace.session_id())
+            .map(|id| format!("{}-{:?}", OUTLINE_PANEL_KEY, id))
+    }
+
     fn serialize(&mut self, cx: &mut Context<Self>) {
+        let Some(serialization_key) = self
+            .workspace
+            .read_with(cx, |workspace, _| {
+                OutlinePanel::serialization_key(workspace)
+            })
+            .ok()
+            .flatten()
+        else {
+            return;
+        };
         let width = self.width;
         let active = Some(self.active);
         self.pending_serialization = cx.background_spawn(
             async move {
                 KEY_VALUE_STORE
                     .write_kvp(
-                        OUTLINE_PANEL_KEY.into(),
+                        serialization_key,
                         serde_json::to_string(&SerializedOutlinePanel { width, active })?,
                     )
                     .await?;
@@ -1072,7 +1099,7 @@ impl OutlinePanel {
                 if change_selection {
                     active_editor.update(cx, |editor, cx| {
                         editor.change_selections(
-                            Some(Autoscroll::Strategy(AutoscrollStrategy::Center, None)),
+                            SelectionEffects::scroll(Autoscroll::center()),
                             window,
                             cx,
                             |s| s.select_ranges(Some(anchor..anchor)),
@@ -1080,38 +1107,14 @@ impl OutlinePanel {
                     });
                 } else {
                     let mut offset = Point::default();
-                    let expand_excerpt_control_height = 1.0;
                     if let Some(buffer_id) = scroll_to_buffer {
-                        let current_folded = active_editor.read(cx).is_buffer_folded(buffer_id, cx);
-                        if current_folded {
-                            let previous_buffer_id = self
-                                .fs_entries
-                                .iter()
-                                .rev()
-                                .filter_map(|entry| match entry {
-                                    FsEntry::File(file) => Some(file.buffer_id),
-                                    FsEntry::ExternalFile(external_file) => {
-                                        Some(external_file.buffer_id)
-                                    }
-                                    FsEntry::Directory(..) => None,
-                                })
-                                .skip_while(|id| *id != buffer_id)
-                                .nth(1);
-                            if let Some(previous_buffer_id) = previous_buffer_id {
-                                if !active_editor
-                                    .read(cx)
-                                    .is_buffer_folded(previous_buffer_id, cx)
-                                {
-                                    offset.y += expand_excerpt_control_height;
-                                }
-                            }
-                        } else {
-                            if multi_buffer_snapshot.as_singleton().is_none() {
-                                offset.y = -(active_editor.read(cx).file_header_size() as f32);
-                            }
-                            offset.y -= expand_excerpt_control_height;
+                        if multi_buffer_snapshot.as_singleton().is_none()
+                            && !active_editor.read(cx).is_buffer_folded(buffer_id, cx)
+                        {
+                            offset.y = -(active_editor.read(cx).file_header_size() as f32);
                         }
                     }
+
                     active_editor.update(cx, |editor, cx| {
                         editor.set_scroll_anchor(ScrollAnchor { offset, anchor }, window, cx);
                     });
@@ -1593,7 +1596,7 @@ impl OutlinePanel {
                                     .get(&external_file.buffer_id)
                                     .into_iter()
                                     .flat_map(|excerpts| {
-                                        excerpts.iter().map(|(excerpt_id, _)| {
+                                        excerpts.keys().map(|excerpt_id| {
                                             CollapsedEntry::Excerpt(
                                                 external_file.buffer_id,
                                                 *excerpt_id,
@@ -1614,7 +1617,7 @@ impl OutlinePanel {
                             entries.extend(
                                 self.excerpts.get(&file.buffer_id).into_iter().flat_map(
                                     |excerpts| {
-                                        excerpts.iter().map(|(excerpt_id, _)| {
+                                        excerpts.keys().map(|excerpt_id| {
                                             CollapsedEntry::Excerpt(file.buffer_id, *excerpt_id)
                                         })
                                     },
@@ -3230,15 +3233,22 @@ impl OutlinePanel {
                 self.outline_fetch_tasks.insert(
                     (buffer_id, excerpt_id),
                     cx.spawn_in(window, async move |outline_panel, cx| {
+                        let buffer_language = buffer_snapshot.language().cloned();
                         let fetched_outlines = cx
                             .background_spawn(async move {
-                                buffer_snapshot
+                                let mut outlines = buffer_snapshot
                                     .outline_items_containing(
                                         excerpt_range.context,
                                         false,
                                         Some(&syntax_theme),
                                     )
-                                    .unwrap_or_default()
+                                    .unwrap_or_default();
+                                outlines.retain(|outline| {
+                                    buffer_language.is_none()
+                                        || buffer_language.as_ref()
+                                            == buffer_snapshot.language_at(outline.range.start)
+                                });
+                                outlines
                             })
                             .await;
                         outline_panel
@@ -3812,6 +3822,7 @@ impl OutlinePanel {
                 &generation_state.match_candidates,
                 &query,
                 true,
+                true,
                 usize::MAX,
                 &AtomicBool::default(),
                 cx.background_executor().clone(),
@@ -4308,19 +4319,7 @@ impl OutlinePanel {
         {
             return None;
         }
-
-        let scroll_handle = self.scroll_handle.0.borrow();
-        let longest_item_width = scroll_handle
-            .last_item_size
-            .filter(|size| size.contents.width > size.item.width)?
-            .contents
-            .width
-            .0 as f64;
-        if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
-            return None;
-        }
-
-        Some(
+        Scrollbar::horizontal(self.horizontal_scrollbar_state.clone()).map(|scrollbar| {
             div()
                 .occlude()
                 .id("project-panel-horizontal-scroll")
@@ -4357,12 +4356,8 @@ impl OutlinePanel {
                 .bottom_0()
                 .h(px(12.))
                 .cursor_default()
-                .when(self.width.is_some(), |this| {
-                    this.children(Scrollbar::horizontal(
-                        self.horizontal_scrollbar_state.clone(),
-                    ))
-                }),
-        )
+                .child(scrollbar)
+        })
     }
 
     fn should_show_scrollbar(cx: &App) -> bool {
@@ -4510,8 +4505,10 @@ impl OutlinePanel {
                 let multi_buffer_snapshot = self
                     .active_editor()
                     .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx));
-                uniform_list(cx.entity().clone(), "entries", items_len, {
-                    move |outline_panel, range, window, cx| {
+                uniform_list(
+                    "entries",
+                    items_len,
+                    cx.processor(move |outline_panel, range: Range<usize>, window, cx| {
                         let entries = outline_panel.cached_entries.get(range);
                         entries
                             .map(|entries| entries.to_vec())
@@ -4568,8 +4565,8 @@ impl OutlinePanel {
                                 ),
                             })
                             .collect()
-                    }
-                })
+                    }),
+                )
                 .with_sizing_behavior(ListSizingBehavior::Infer)
                 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
                 .with_width_from_item(self.max_width_item_index)
@@ -4803,10 +4800,12 @@ impl Panel for OutlinePanel {
             .unwrap_or_else(|| OutlinePanelSettings::get_global(cx).default_width)
     }
 
-    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
+    fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
         self.width = size;
-        self.serialize(cx);
         cx.notify();
+        cx.defer_in(window, |this, _, cx| {
+            this.serialize(cx);
+        });
     }
 
     fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
@@ -5629,7 +5628,7 @@ mod tests {
             .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
         cx.run_until_parked();
 
-        let active_editor = outline_panel.update(cx, |outline_panel, _| {
+        let active_editor = outline_panel.read_with(cx, |outline_panel, _| {
             outline_panel
                 .active_editor()
                 .expect("should have an active editor open")
@@ -5724,7 +5723,7 @@ mod tests {
         cx.executor()
             .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
         cx.run_until_parked();
-        let new_active_editor = outline_panel.update(cx, |outline_panel, _| {
+        let new_active_editor = outline_panel.read_with(cx, |outline_panel, _| {
             outline_panel
                 .active_editor()
                 .expect("should have an active editor open")

crates/paths/src/paths.rs 🔗

@@ -1,5 +1,6 @@
 //! Paths to locations used by Zed.
 
+use std::env;
 use std::path::{Path, PathBuf};
 use std::sync::OnceLock;
 
@@ -106,6 +107,7 @@ pub fn data_dir() -> &'static PathBuf {
         }
     })
 }
+
 /// Returns the path to the temp directory used by Zed.
 pub fn temp_dir() -> &'static PathBuf {
     static TEMP_DIR: OnceLock<PathBuf> = OnceLock::new();
@@ -191,6 +193,12 @@ pub fn settings_file() -> &'static PathBuf {
     SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
 }
 
+/// Returns the path to the global settings file.
+pub fn global_settings_file() -> &'static PathBuf {
+    static GLOBAL_SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
+    GLOBAL_SETTINGS_FILE.get_or_init(|| config_dir().join("global_settings.json"))
+}
+
 /// Returns the path to the `settings_backup.json` file.
 pub fn settings_backup_file() -> &'static PathBuf {
     static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
@@ -402,6 +410,7 @@ pub fn task_file_name() -> &'static str {
 }
 
 /// Returns the relative path to a `debug.json` file within a project.
+/// .zed/debug.json
 pub fn local_debug_file_relative_path() -> &'static Path {
     Path::new(".zed/debug.json")
 }
@@ -411,17 +420,82 @@ pub fn local_vscode_launch_file_relative_path() -> &'static Path {
     Path::new(".vscode/launch.json")
 }
 
-/// Returns the path to the vscode user settings file
-pub fn vscode_settings_file() -> &'static PathBuf {
-    static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
-    let rel_path = "Code/User/settings.json";
-    LOGS_DIR.get_or_init(|| {
-        if cfg!(target_os = "macos") {
+pub fn user_ssh_config_file() -> PathBuf {
+    home_dir().join(".ssh/config")
+}
+
+pub fn global_ssh_config_file() -> &'static Path {
+    Path::new("/etc/ssh/ssh_config")
+}
+
+/// Returns candidate paths for the vscode user settings file
+pub fn vscode_settings_file_paths() -> Vec<PathBuf> {
+    let mut paths = vscode_user_data_paths();
+    for path in paths.iter_mut() {
+        path.push("User/settings.json");
+    }
+    paths
+}
+
+/// Returns candidate paths for the cursor user settings file
+pub fn cursor_settings_file_paths() -> Vec<PathBuf> {
+    let mut paths = cursor_user_data_paths();
+    for path in paths.iter_mut() {
+        path.push("User/settings.json");
+    }
+    paths
+}
+
+fn vscode_user_data_paths() -> Vec<PathBuf> {
+    // https://github.com/microsoft/vscode/blob/23e7148cdb6d8a27f0109ff77e5b1e019f8da051/src/vs/platform/environment/node/userDataPath.ts#L45
+    const VSCODE_PRODUCT_NAMES: &[&str] = &[
+        "Code",
+        "Code - OSS",
+        "VSCodium",
+        "Code Dev",
+        "Code - OSS Dev",
+        "code-oss-dev",
+    ];
+    let mut paths = Vec::new();
+    if let Ok(portable_path) = env::var("VSCODE_PORTABLE") {
+        paths.push(Path::new(&portable_path).join("user-data"));
+    }
+    if let Ok(vscode_appdata) = env::var("VSCODE_APPDATA") {
+        for product_name in VSCODE_PRODUCT_NAMES {
+            paths.push(Path::new(&vscode_appdata).join(product_name));
+        }
+    }
+    for product_name in VSCODE_PRODUCT_NAMES {
+        add_vscode_user_data_paths(&mut paths, product_name);
+    }
+    paths
+}
+
+fn cursor_user_data_paths() -> Vec<PathBuf> {
+    let mut paths = Vec::new();
+    add_vscode_user_data_paths(&mut paths, "Cursor");
+    paths
+}
+
+fn add_vscode_user_data_paths(paths: &mut Vec<PathBuf>, product_name: &str) {
+    if cfg!(target_os = "macos") {
+        paths.push(
             home_dir()
                 .join("Library/Application Support")
-                .join(rel_path)
-        } else {
-            config_dir().join(rel_path)
+                .join(product_name),
+        );
+    } else if cfg!(target_os = "windows") {
+        if let Some(data_local_dir) = dirs::data_local_dir() {
+            paths.push(data_local_dir.join(product_name));
         }
-    })
+        if let Some(data_dir) = dirs::data_dir() {
+            paths.push(data_dir.join(product_name));
+        }
+    } else {
+        paths.push(
+            dirs::config_dir()
+                .unwrap_or(home_dir().join(".config"))
+                .join(product_name),
+        );
+    }
 }

crates/picker/src/picker.rs 🔗

@@ -1,28 +1,29 @@
+mod head;
+pub mod highlighted_match_with_paths;
+pub mod popover_menu;
+
 use anyhow::Result;
 use editor::{
-    Editor,
+    Editor, SelectionEffects,
     actions::{MoveDown, MoveUp},
     scroll::Autoscroll,
 };
 use gpui::{
-    AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle,
+    Action, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle,
     Focusable, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, Render,
-    ScrollStrategy, Stateful, Task, UniformListScrollHandle, Window, actions, div, impl_actions,
-    list, prelude::*, uniform_list,
+    ScrollStrategy, Stateful, Task, UniformListScrollHandle, Window, actions, div, list,
+    prelude::*, uniform_list,
 };
 use head::Head;
 use schemars::JsonSchema;
 use serde::Deserialize;
-use std::{sync::Arc, time::Duration};
+use std::{ops::Range, sync::Arc, time::Duration};
 use ui::{
     Color, Divider, Label, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, prelude::*, v_flex,
 };
 use util::ResultExt;
 use workspace::ModalView;
 
-mod head;
-pub mod highlighted_match_with_paths;
-
 enum ElementContainer {
     List(ListState),
     UniformList(UniformListScrollHandle),
@@ -37,14 +38,13 @@ actions!(picker, [ConfirmCompletion]);
 
 /// ConfirmInput is an alternative editor action which - instead of selecting active picker entry - treats pickers editor input literally,
 /// performing some kind of action on it.
-#[derive(Clone, PartialEq, Deserialize, JsonSchema, Default)]
+#[derive(Clone, PartialEq, Deserialize, JsonSchema, Default, Action)]
+#[action(namespace = picker)]
 #[serde(deny_unknown_fields)]
 pub struct ConfirmInput {
     pub secondary: bool,
 }
 
-impl_actions!(picker, [ConfirmInput]);
-
 struct PendingUpdateMatches {
     delegate_update_matches: Option<Task<()>>,
     _task: Task<Result<()>>,
@@ -189,7 +189,7 @@ pub trait PickerDelegate: Sized + 'static {
                     .overflow_hidden()
                     .flex_none()
                     .h_9()
-                    .px_3()
+                    .px_2p5()
                     .child(editor.clone()),
             )
             .when(
@@ -205,6 +205,7 @@ pub trait PickerDelegate: Sized + 'static {
         window: &mut Window,
         cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem>;
+
     fn render_header(
         &self,
         _window: &mut Window,
@@ -212,6 +213,7 @@ pub trait PickerDelegate: Sized + 'static {
     ) -> Option<AnyElement> {
         None
     }
+
     fn render_footer(
         &self,
         _window: &mut Window,
@@ -693,9 +695,12 @@ impl<D: PickerDelegate> Picker<D> {
             editor.update(cx, |editor, cx| {
                 editor.set_text(query, window, cx);
                 let editor_offset = editor.buffer().read(cx).len(cx);
-                editor.change_selections(Some(Autoscroll::Next), window, cx, |s| {
-                    s.select_ranges(Some(editor_offset..editor_offset))
-                });
+                editor.change_selections(
+                    SelectionEffects::scroll(Autoscroll::Next),
+                    window,
+                    cx,
+                    |s| s.select_ranges(Some(editor_offset..editor_offset)),
+                );
             });
         }
     }
@@ -759,14 +764,13 @@ impl<D: PickerDelegate> Picker<D> {
 
         match &self.element_container {
             ElementContainer::UniformList(scroll_handle) => uniform_list(
-                cx.entity().clone(),
                 "candidates",
                 self.delegate.match_count(),
-                move |picker, visible_range, window, cx| {
+                cx.processor(move |picker, visible_range: Range<usize>, window, cx| {
                     visible_range
                         .map(|ix| picker.render_element(window, cx, ix))
                         .collect()
-                },
+                }),
             )
             .with_sizing_behavior(sizing_behavior)
             .when_some(self.widest_item, |el, widest_item| {

crates/picker/src/popover_menu.rs 🔗

@@ -0,0 +1,93 @@
+use gpui::{
+    AnyView, Corner, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription,
+};
+use ui::{
+    App, ButtonCommon, FluentBuilder as _, IntoElement, PopoverMenu, PopoverMenuHandle,
+    PopoverTrigger, RenderOnce, Window, px,
+};
+
+use crate::{Picker, PickerDelegate};
+
+pub struct PickerPopoverMenu<T, TT, P>
+where
+    T: PopoverTrigger + ButtonCommon,
+    TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
+    P: PickerDelegate,
+{
+    picker: Entity<Picker<P>>,
+    trigger: T,
+    tooltip: TT,
+    handle: Option<PopoverMenuHandle<Picker<P>>>,
+    anchor: Corner,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl<T, TT, P> PickerPopoverMenu<T, TT, P>
+where
+    T: PopoverTrigger + ButtonCommon,
+    TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
+    P: PickerDelegate,
+{
+    pub fn new(
+        picker: Entity<Picker<P>>,
+        trigger: T,
+        tooltip: TT,
+        anchor: Corner,
+        cx: &mut App,
+    ) -> Self {
+        Self {
+            _subscriptions: vec![cx.subscribe(&picker, |picker, &DismissEvent, cx| {
+                picker.update(cx, |_, cx| cx.emit(DismissEvent));
+            })],
+            picker,
+            trigger,
+            tooltip,
+            handle: None,
+            anchor,
+        }
+    }
+
+    pub fn with_handle(mut self, handle: PopoverMenuHandle<Picker<P>>) -> Self {
+        self.handle = Some(handle);
+        self
+    }
+}
+
+impl<T, TT, P> EventEmitter<DismissEvent> for PickerPopoverMenu<T, TT, P>
+where
+    T: PopoverTrigger + ButtonCommon,
+    TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
+    P: PickerDelegate,
+{
+}
+
+impl<T, TT, P> Focusable for PickerPopoverMenu<T, TT, P>
+where
+    T: PopoverTrigger + ButtonCommon,
+    TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
+    P: PickerDelegate,
+{
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.picker.focus_handle(cx)
+    }
+}
+
+impl<T, TT, P> RenderOnce for PickerPopoverMenu<T, TT, P>
+where
+    T: PopoverTrigger + ButtonCommon,
+    TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
+    P: PickerDelegate,
+{
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let picker = self.picker.clone();
+        PopoverMenu::new("popover-menu")
+            .menu(move |_window, _cx| Some(picker.clone()))
+            .trigger_with_tooltip(self.trigger, self.tooltip)
+            .anchor(self.anchor)
+            .when_some(self.handle.clone(), |menu, handle| menu.with_handle(handle))
+            .offset(gpui::Point {
+                x: px(0.0),
+                y: px(-2.0),
+            })
+    }
+}

crates/prettier/src/prettier.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Context as _, anyhow};
+use anyhow::Context as _;
 use collections::{HashMap, HashSet};
 use fs::Fs;
 use gpui::{AsyncApp, Entity};
@@ -292,7 +292,7 @@ impl Prettier {
 
         let server = cx
             .update(|cx| {
-                let params = server.default_initialize_params(cx);
+                let params = server.default_initialize_params(false, cx);
                 let configuration = lsp::DidChangeConfigurationParams {
                     settings: Default::default(),
                 };
@@ -343,6 +343,8 @@ impl Prettier {
                                 prettier_plugin_dir.join("plugin.js"),
                                 // this one is for @prettier/plugin-php
                                 prettier_plugin_dir.join("standalone.js"),
+                                // this one is for prettier-plugin-latex
+                                prettier_plugin_dir.join("dist").join("prettier-plugin-latex.js"),
                                 prettier_plugin_dir,
                             ]
                             .into_iter()
@@ -421,7 +423,7 @@ impl Prettier {
                             prettier_parser = prettier_parser.or_else(|| buffer_language.and_then(|language| language.prettier_parser_name()));
                             if prettier_parser.is_none() {
                                 log::error!("Formatting unsaved file with prettier failed. No prettier parser configured for language {buffer_language:?}");
-                                return Err(anyhow!("Cannot determine prettier parser for unsaved file"));
+                                anyhow::bail!("Cannot determine prettier parser for unsaved file");
                             }
 
                         }
@@ -450,14 +452,13 @@ impl Prettier {
                             },
                         })
                     })?
-                    .context("prettier params calculation")?;
+                    .context("building prettier request")?;
 
                 let response = local
                     .server
                     .request::<Format>(params)
                     .await
-                    .into_response()
-                    .context("prettier format")?;
+                    .into_response()?;
                 let diff_task = buffer.update(cx, |buffer, cx| buffer.diff(response.text, cx))?;
                 Ok(diff_task.await)
             }

crates/prettier/src/prettier_server.js 🔗

@@ -17,9 +17,7 @@ fs.stat(prettierContainerPath, (err, stats) => {
   }
 
   if (!stats.isDirectory()) {
-    process.stderr.write(
-      `Path '${prettierContainerPath}' exists but is not a directory\n`,
-    );
+    process.stderr.write(`Path '${prettierContainerPath}' exists but is not a directory\n`);
     process.exit(1);
   }
 });
@@ -43,11 +41,7 @@ class Prettier {
     process.stderr.write(`Failed to load prettier: ${e}\n`);
     process.exit(1);
   }
-  process.stderr.write(
-    `Prettier at path '${prettierPath}' loaded successfully, config: ${JSON.stringify(
-      config,
-    )}\n`,
-  );
+  process.stderr.write(`Prettier at path '${prettierPath}' loaded successfully, config: ${JSON.stringify(config)}\n`);
   process.stdin.resume();
   handleBuffer(new Prettier(prettierPath, prettier, config));
 })();
@@ -58,22 +52,17 @@ async function handleBuffer(prettier) {
     try {
       message = JSON.parse(messageText);
     } catch (e) {
-      sendResponse(makeError(`Failed to parse message '${messageText}': ${e}`));
+      sendResponse(makeError(`Parse error in request message: ${e}\nMessage: ${messageText}`));
       continue;
     }
     // allow concurrent request handling by not `await`ing the message handling promise (async function)
     handleMessage(message, prettier).catch((e) => {
-      const errorMessage = message;
-      if ((errorMessage.params || {}).text !== undefined) {
-        errorMessage.params.text = "..snip..";
+      if ((message.params || {}).text !== undefined) {
+        message.params.text = "..snip..";
       }
       sendResponse({
         id: message.id,
-        ...makeError(
-          `error during message '${JSON.stringify(
-            errorMessage,
-          )}' handling: ${e}`,
-        ),
+        ...makeError(`${e}\nWhile handling prettier request: ${JSON.stringify(message)}`),
       });
     });
   }
@@ -107,9 +96,7 @@ async function* readStdin() {
       if (messageLength === null) {
         while (buffer.indexOf(`${headerSeparator}${headerSeparator}`) === -1) {
           if (streamEnded) {
-            await handleStreamEnded(
-              "Unexpected end of stream: headers not found",
-            );
+            await handleStreamEnded("Unexpected end of stream: headers not found");
             continue main_loop;
           } else if (buffer.length > contentLengthHeaderName.length * 10) {
             await handleStreamEnded(
@@ -119,22 +106,16 @@ async function* readStdin() {
           }
           await once(process.stdin, "readable");
         }
-        const headers = buffer
-          .subarray(0, buffer.indexOf(`${headerSeparator}${headerSeparator}`))
-          .toString("ascii");
+        const headers = buffer.subarray(0, buffer.indexOf(`${headerSeparator}${headerSeparator}`)).toString("ascii");
         const contentLengthHeader = headers
           .split(headerSeparator)
           .map((header) => header.split(":"))
           .filter((header) => header[2] === undefined)
           .filter((header) => (header[1] || "").length > 0)
-          .find(
-            (header) => (header[0] || "").trim() === contentLengthHeaderName,
-          );
+          .find((header) => (header[0] || "").trim() === contentLengthHeaderName);
         const contentLength = (contentLengthHeader || [])[1];
         if (contentLength === undefined) {
-          await handleStreamEnded(
-            `Missing or incorrect ${contentLengthHeaderName} header: ${headers}`,
-          );
+          await handleStreamEnded(`Missing or incorrect ${contentLengthHeaderName} header: ${headers}`);
           continue main_loop;
         }
         headersLength = headers.length + headerSeparator.length * 2;
@@ -179,28 +160,20 @@ async function handleMessage(message, prettier) {
 
   if (method === "prettier/format") {
     if (params === undefined || params.text === undefined) {
-      throw new Error(
-        `Message params.text is undefined: ${JSON.stringify(message)}`,
-      );
+      throw new Error(`Message params.text is undefined: ${JSON.stringify(message)}`);
     }
     if (params.options === undefined) {
-      throw new Error(
-        `Message params.options is undefined: ${JSON.stringify(message)}`,
-      );
+      throw new Error(`Message params.options is undefined: ${JSON.stringify(message)}`);
     }
 
     let resolvedConfig = {};
     if (params.options.filepath) {
-      resolvedConfig =
-        (await prettier.prettier.resolveConfig(params.options.filepath)) || {};
+      resolvedConfig = (await prettier.prettier.resolveConfig(params.options.filepath)) || {};
 
       if (params.options.ignorePath) {
-        const fileInfo = await prettier.prettier.getFileInfo(
-          params.options.filepath,
-          {
-            ignorePath: params.options.ignorePath,
-          },
-        );
+        const fileInfo = await prettier.prettier.getFileInfo(params.options.filepath, {
+          ignorePath: params.options.ignorePath,
+        });
         if (fileInfo.ignored) {
           process.stderr.write(
             `Ignoring file '${params.options.filepath}' based on rules in '${params.options.ignorePath}'\n`,
@@ -218,8 +191,7 @@ async function handleMessage(message, prettier) {
     }
 
     const plugins =
-      Array.isArray(resolvedConfig?.plugins) &&
-      resolvedConfig.plugins.length > 0
+      Array.isArray(resolvedConfig?.plugins) && resolvedConfig.plugins.length > 0
         ? resolvedConfig.plugins
         : params.options.plugins;
 
@@ -239,8 +211,7 @@ async function handleMessage(message, prettier) {
     sendResponse({ id, result: { text: formattedText } });
   } else if (method === "prettier/clear_cache") {
     prettier.prettier.clearConfigCache();
-    prettier.config =
-      (await prettier.prettier.resolveConfig(prettier.path)) || {};
+    prettier.config = (await prettier.prettier.resolveConfig(prettier.path)) || {};
     sendResponse({ id, result: null });
   } else if (method === "initialize") {
     sendResponse({
@@ -283,9 +254,7 @@ function loadPrettier(prettierPath) {
         try {
           resolve(require(prettierPath));
         } catch (err) {
-          reject(
-            `Error requiring prettier module from path '${prettierPath}'.Error: ${err}`,
-          );
+          reject(`Error requiring prettier module from path '${prettierPath}'.Error: ${err}`);
         }
       }
     });

crates/project/Cargo.toml 🔗

@@ -90,10 +90,10 @@ workspace-hack.workspace = true
 [dev-dependencies]
 client = { 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"] }
 dap = { workspace = true, features = ["test-support"] }
 dap_adapters = { workspace = true, features = ["test-support"] }
-env_logger.workspace = true
 fs = { workspace = true, features = ["test-support"] }
 git2.workspace = true
 gpui = { workspace = true, features = ["test-support"] }

crates/project/src/buffer_store.rs 🔗

@@ -39,6 +39,7 @@ pub struct BufferStore {
     path_to_buffer_id: HashMap<ProjectPath, BufferId>,
     downstream_client: Option<(AnyProtoClient, u64)>,
     shared_buffers: HashMap<proto::PeerId, HashMap<BufferId, SharedBuffer>>,
+    non_searchable_buffers: HashSet<BufferId>,
 }
 
 #[derive(Hash, Eq, PartialEq, Clone)]
@@ -58,7 +59,7 @@ struct RemoteBufferStore {
     project_id: u64,
     loading_remote_buffers_by_id: HashMap<BufferId, Entity<Buffer>>,
     remote_buffer_listeners:
-        HashMap<BufferId, Vec<oneshot::Sender<Result<Entity<Buffer>, anyhow::Error>>>>,
+        HashMap<BufferId, Vec<oneshot::Sender<anyhow::Result<Entity<Buffer>>>>>,
     worktree_store: Entity<WorktreeStore>,
 }
 
@@ -152,11 +153,7 @@ impl RemoteBufferStore {
         capability: Capability,
         cx: &mut Context<BufferStore>,
     ) -> Result<Option<Entity<Buffer>>> {
-        match envelope
-            .payload
-            .variant
-            .ok_or_else(|| anyhow!("missing variant"))?
-        {
+        match envelope.payload.variant.context("missing variant")? {
             proto::create_buffer_for_peer::Variant::State(mut state) => {
                 let buffer_id = BufferId::new(state.id)?;
 
@@ -168,8 +165,8 @@ impl RemoteBufferStore {
                             .worktree_store
                             .read(cx)
                             .worktree_for_id(worktree_id, cx)
-                            .ok_or_else(|| {
-                                anyhow!("no worktree found for id {}", file.worktree_id)
+                            .with_context(|| {
+                                format!("no worktree found for id {}", file.worktree_id)
                             })?;
                         buffer_file = Some(Arc::new(File::from_proto(file, worktree.clone(), cx)?)
                             as Arc<dyn language::File>);
@@ -197,8 +194,8 @@ impl RemoteBufferStore {
                     .loading_remote_buffers_by_id
                     .get(&buffer_id)
                     .cloned()
-                    .ok_or_else(|| {
-                        anyhow!(
+                    .with_context(|| {
+                        format!(
                             "received chunk for buffer {} without initial state",
                             chunk.buffer_id
                         )
@@ -341,10 +338,7 @@ impl RemoteBufferStore {
         });
 
         cx.spawn(async move |this, cx| {
-            let response = request
-                .await?
-                .transaction
-                .ok_or_else(|| anyhow!("missing transaction"))?;
+            let response = request.await?.transaction.context("missing transaction")?;
             this.update(cx, |this, cx| {
                 this.deserialize_project_transaction(response, push_to_history, cx)
             })?
@@ -629,7 +623,7 @@ impl LocalBufferStore {
                 Ok(buffer) => Ok(buffer),
                 Err(error) if is_not_found_error(&error) => cx.new(|cx| {
                     let buffer_id = BufferId::from(cx.entity_id().as_non_zero_u64());
-                    let text_buffer = text::Buffer::new(0, buffer_id, "".into());
+                    let text_buffer = text::Buffer::new(0, buffer_id, "");
                     Buffer::build(
                         text_buffer,
                         Some(Arc::new(File {
@@ -733,6 +727,7 @@ impl BufferStore {
             path_to_buffer_id: Default::default(),
             shared_buffers: Default::default(),
             loading_buffers: Default::default(),
+            non_searchable_buffers: Default::default(),
             worktree_store,
         }
     }
@@ -757,6 +752,7 @@ impl BufferStore {
             path_to_buffer_id: Default::default(),
             loading_buffers: Default::default(),
             shared_buffers: Default::default(),
+            non_searchable_buffers: Default::default(),
             worktree_store,
         }
     }
@@ -787,7 +783,7 @@ impl BufferStore {
         project_path: ProjectPath,
         cx: &mut Context<Self>,
     ) -> Task<Result<Entity<Buffer>>> {
-        if let Some(buffer) = self.get_by_path(&project_path, cx) {
+        if let Some(buffer) = self.get_by_path(&project_path) {
             cx.emit(BufferStoreEvent::BufferOpened {
                 buffer: buffer.clone(),
                 project_path,
@@ -913,8 +909,8 @@ impl BufferStore {
                     if is_remote {
                         return Ok(());
                     } else {
-                        debug_panic!("buffer {} was already registered", remote_id);
-                        Err(anyhow!("buffer {} was already registered", remote_id))?;
+                        debug_panic!("buffer {remote_id} was already registered");
+                        anyhow::bail!("buffer {remote_id} was already registered");
                     }
                 }
                 entry.insert(open_buffer);
@@ -950,7 +946,7 @@ impl BufferStore {
         self.path_to_buffer_id.get(project_path)
     }
 
-    pub fn get_by_path(&self, path: &ProjectPath, _cx: &App) -> Option<Entity<Buffer>> {
+    pub fn get_by_path(&self, path: &ProjectPath) -> Option<Entity<Buffer>> {
         self.path_to_buffer_id.get(path).and_then(|buffer_id| {
             let buffer = self.get(*buffer_id);
             buffer
@@ -963,7 +959,7 @@ impl BufferStore {
 
     pub fn get_existing(&self, buffer_id: BufferId) -> Result<Entity<Buffer>> {
         self.get(buffer_id)
-            .ok_or_else(|| anyhow!("unknown buffer id {}", buffer_id))
+            .with_context(|| format!("unknown buffer id {buffer_id}"))
     }
 
     pub fn get_possibly_incomplete(&self, buffer_id: BufferId) -> Option<Entity<Buffer>> {
@@ -1066,7 +1062,9 @@ impl BufferStore {
         let mut unnamed_buffers = Vec::new();
         for handle in self.buffers() {
             let buffer = handle.read(cx);
-            if let Some(entry_id) = buffer.entry_id(cx) {
+            if self.non_searchable_buffers.contains(&buffer.remote_id()) {
+                continue;
+            } else if let Some(entry_id) = buffer.entry_id(cx) {
                 open_buffers.insert(entry_id);
             } else {
                 limit = limit.saturating_sub(1);
@@ -1279,9 +1277,9 @@ impl BufferStore {
         capability: Capability,
         cx: &mut Context<Self>,
     ) -> Result<()> {
-        let Some(remote) = self.as_remote_mut() else {
-            return Err(anyhow!("buffer store is not a remote"));
-        };
+        let remote = self
+            .as_remote_mut()
+            .context("buffer store is not a remote")?;
 
         if let Some(buffer) =
             remote.handle_create_buffer_for_peer(envelope, replica_id, capability, cx)?
@@ -1303,12 +1301,12 @@ impl BufferStore {
         this.update(&mut cx, |this, cx| {
             let payload = envelope.payload.clone();
             if let Some(buffer) = this.get_possibly_incomplete(buffer_id) {
-                let file = payload.file.ok_or_else(|| anyhow!("invalid file"))?;
+                let file = payload.file.context("invalid file")?;
                 let worktree = this
                     .worktree_store
                     .read(cx)
                     .worktree_for_id(WorktreeId::from_proto(file.worktree_id), cx)
-                    .ok_or_else(|| anyhow!("no such worktree"))?;
+                    .context("no such worktree")?;
                 let file = File::from_proto(file, worktree, cx)?;
                 let old_file = buffer.update(cx, |buffer, cx| {
                     let old_file = buffer.file().cloned();
@@ -1347,7 +1345,7 @@ impl BufferStore {
         mut cx: AsyncApp,
     ) -> Result<proto::BufferSaved> {
         let buffer_id = BufferId::new(envelope.payload.buffer_id)?;
-        let (buffer, project_id) = this.update(&mut cx, |this, _| {
+        let (buffer, project_id) = this.read_with(&mut cx, |this, _| {
             anyhow::Ok((
                 this.get_existing(buffer_id)?,
                 this.downstream_client
@@ -1361,7 +1359,7 @@ impl BufferStore {
                 buffer.wait_for_version(deserialize_version(&envelope.payload.version))
             })?
             .await?;
-        let buffer_id = buffer.update(&mut cx, |buffer, _| buffer.remote_id())?;
+        let buffer_id = buffer.read_with(&mut cx, |buffer, _| buffer.remote_id())?;
 
         if let Some(new_path) = envelope.payload.new_path {
             let new_path = ProjectPath::from_proto(new_path);
@@ -1374,7 +1372,7 @@ impl BufferStore {
                 .await?;
         }
 
-        buffer.update(&mut cx, |buffer, _| proto::BufferSaved {
+        buffer.read_with(&mut cx, |buffer, _| proto::BufferSaved {
             project_id,
             buffer_id: buffer_id.into(),
             version: serialize_version(buffer.saved_version()),
@@ -1445,7 +1443,7 @@ impl BufferStore {
         let mtime = envelope.payload.mtime.clone().map(|time| time.into());
         let line_ending = deserialize_line_ending(
             proto::LineEnding::from_i32(envelope.payload.line_ending)
-                .ok_or_else(|| anyhow!("missing line ending"))?,
+                .context("missing line ending")?,
         );
         this.update(&mut cx, |this, cx| {
             if let Some(buffer) = this.get_possibly_incomplete(buffer_id) {
@@ -1495,7 +1493,7 @@ impl BufferStore {
                 let buffer_id = BufferId::new(*buffer_id)?;
                 buffers.insert(this.get_existing(buffer_id)?);
             }
-            Ok::<_, anyhow::Error>(this.reload_buffers(buffers, false, cx))
+            anyhow::Ok(this.reload_buffers(buffers, false, cx))
         })??;
 
         let project_transaction = reload.await?;
@@ -1531,7 +1529,7 @@ impl BufferStore {
         };
 
         cx.spawn(async move |this, cx| {
-            let Some(buffer) = this.update(cx, |this, _| this.get(buffer_id))? else {
+            let Some(buffer) = this.read_with(cx, |this, _| this.get(buffer_id))? else {
                 return anyhow::Ok(());
             };
 
@@ -1672,6 +1670,10 @@ impl BufferStore {
         }
         serialized_transaction
     }
+
+    pub(crate) fn mark_buffer_as_non_searchable(&mut self, buffer_id: BufferId) {
+        self.non_searchable_buffers.insert(buffer_id);
+    }
 }
 
 impl OpenBuffer {

crates/project/src/context_server_store.rs 🔗

@@ -5,14 +5,15 @@ use std::{path::Path, sync::Arc};
 
 use anyhow::{Context as _, Result};
 use collections::{HashMap, HashSet};
-use context_server::{ContextServer, ContextServerId};
+use context_server::{ContextServer, ContextServerCommand, ContextServerId};
+use futures::{FutureExt as _, future::join_all};
 use gpui::{App, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity, actions};
 use registry::ContextServerDescriptorRegistry;
 use settings::{Settings as _, SettingsStore};
 use util::ResultExt as _;
 
 use crate::{
-    project_settings::{ContextServerConfiguration, ProjectSettings},
+    project_settings::{ContextServerSettings, ProjectSettings},
     worktree_store::WorktreeStore,
 };
 
@@ -35,13 +36,8 @@ impl ContextServerStatus {
         match state {
             ContextServerState::Starting { .. } => ContextServerStatus::Starting,
             ContextServerState::Running { .. } => ContextServerStatus::Running,
-            ContextServerState::Stopped { error, .. } => {
-                if let Some(error) = error {
-                    ContextServerStatus::Error(error.clone())
-                } else {
-                    ContextServerStatus::Stopped
-                }
-            }
+            ContextServerState::Stopped { .. } => ContextServerStatus::Stopped,
+            ContextServerState::Error { error, .. } => ContextServerStatus::Error(error.clone()),
         }
     }
 }
@@ -59,7 +55,11 @@ enum ContextServerState {
     Stopped {
         server: Arc<ContextServer>,
         configuration: Arc<ContextServerConfiguration>,
-        error: Option<Arc<str>>,
+    },
+    Error {
+        server: Arc<ContextServer>,
+        configuration: Arc<ContextServerConfiguration>,
+        error: Arc<str>,
     },
 }
 
@@ -69,6 +69,7 @@ impl ContextServerState {
             ContextServerState::Starting { server, .. } => server.clone(),
             ContextServerState::Running { server, .. } => server.clone(),
             ContextServerState::Stopped { server, .. } => server.clone(),
+            ContextServerState::Error { server, .. } => server.clone(),
         }
     }
 
@@ -77,6 +78,55 @@ impl ContextServerState {
             ContextServerState::Starting { configuration, .. } => configuration.clone(),
             ContextServerState::Running { configuration, .. } => configuration.clone(),
             ContextServerState::Stopped { configuration, .. } => configuration.clone(),
+            ContextServerState::Error { configuration, .. } => configuration.clone(),
+        }
+    }
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum ContextServerConfiguration {
+    Custom {
+        command: ContextServerCommand,
+    },
+    Extension {
+        command: ContextServerCommand,
+        settings: serde_json::Value,
+    },
+}
+
+impl ContextServerConfiguration {
+    pub fn command(&self) -> &ContextServerCommand {
+        match self {
+            ContextServerConfiguration::Custom { command } => command,
+            ContextServerConfiguration::Extension { command, .. } => command,
+        }
+    }
+
+    pub async fn from_settings(
+        settings: ContextServerSettings,
+        id: ContextServerId,
+        registry: Entity<ContextServerDescriptorRegistry>,
+        worktree_store: Entity<WorktreeStore>,
+        cx: &AsyncApp,
+    ) -> Option<Self> {
+        match settings {
+            ContextServerSettings::Custom {
+                enabled: _,
+                command,
+            } => Some(ContextServerConfiguration::Custom { command }),
+            ContextServerSettings::Extension {
+                enabled: _,
+                settings,
+            } => {
+                let descriptor = cx
+                    .update(|cx| registry.read(cx).context_server_descriptor(&id.0))
+                    .ok()
+                    .flatten()?;
+
+                let command = descriptor.command(worktree_store, cx).await.log_err()?;
+
+                Some(ContextServerConfiguration::Extension { command, settings })
+            }
         }
     }
 }
@@ -190,6 +240,13 @@ impl ContextServerStore {
         self.servers.get(id).map(ContextServerStatus::from_state)
     }
 
+    pub fn configuration_for_server(
+        &self,
+        id: &ContextServerId,
+    ) -> Option<Arc<ContextServerConfiguration>> {
+        self.servers.get(id).map(|state| state.configuration())
+    }
+
     pub fn all_server_ids(&self) -> Vec<ContextServerId> {
         self.servers.keys().cloned().collect()
     }
@@ -207,35 +264,55 @@ impl ContextServerStore {
             .collect()
     }
 
-    pub fn start_server(
-        &mut self,
-        server: Arc<ContextServer>,
-        cx: &mut Context<Self>,
-    ) -> Result<()> {
-        let location = self
-            .worktree_store
-            .read(cx)
-            .visible_worktrees(cx)
-            .next()
-            .map(|worktree| settings::SettingsLocation {
-                worktree_id: worktree.read(cx).id(),
-                path: Path::new(""),
-            });
-        let settings = ProjectSettings::get(location, cx);
-        let configuration = settings
-            .context_servers
-            .get(&server.id().0)
-            .context("Failed to load context server configuration from settings")?
-            .clone();
-
-        self.run_server(server, Arc::new(configuration), cx);
-        Ok(())
+    pub fn start_server(&mut self, server: Arc<ContextServer>, cx: &mut Context<Self>) {
+        cx.spawn(async move |this, cx| {
+            let this = this.upgrade().context("Context server store dropped")?;
+            let settings = this
+                .update(cx, |this, cx| {
+                    this.context_server_settings(cx)
+                        .get(&server.id().0)
+                        .cloned()
+                })
+                .ok()
+                .flatten()
+                .context("Failed to get context server settings")?;
+
+            if !settings.enabled() {
+                return Ok(());
+            }
+
+            let (registry, worktree_store) = this.update(cx, |this, _| {
+                (this.registry.clone(), this.worktree_store.clone())
+            })?;
+            let configuration = ContextServerConfiguration::from_settings(
+                settings,
+                server.id(),
+                registry,
+                worktree_store,
+                cx,
+            )
+            .await
+            .context("Failed to create context server configuration")?;
+
+            this.update(cx, |this, cx| {
+                this.run_server(server, Arc::new(configuration), cx)
+            })
+        })
+        .detach_and_log_err(cx);
     }
 
     pub fn stop_server(&mut self, id: &ContextServerId, cx: &mut Context<Self>) -> Result<()> {
-        let Some(state) = self.servers.remove(id) else {
-            return Err(anyhow::anyhow!("Context server not found"));
-        };
+        if matches!(
+            self.servers.get(id),
+            Some(ContextServerState::Stopped { .. })
+        ) {
+            return Ok(());
+        }
+
+        let state = self
+            .servers
+            .remove(id)
+            .context("Context server not found")?;
 
         let server = state.server();
         let configuration = state.configuration();
@@ -250,7 +327,6 @@ impl ContextServerStore {
             ContextServerState::Stopped {
                 configuration,
                 server,
-                error: None,
             },
             cx,
         );
@@ -310,10 +386,10 @@ impl ContextServerStore {
                         this.update(cx, |this, cx| {
                             this.update_server_state(
                                 id.clone(),
-                                ContextServerState::Stopped {
+                                ContextServerState::Error {
                                     configuration,
                                     server,
-                                    error: Some(err.to_string().into()),
+                                    error: err.to_string().into(),
                                 },
                                 cx,
                             )
@@ -336,9 +412,10 @@ impl ContextServerStore {
     }
 
     fn remove_server(&mut self, id: &ContextServerId, cx: &mut Context<Self>) -> Result<()> {
-        let Some(state) = self.servers.remove(id) else {
-            return Err(anyhow::anyhow!("Context server not found"));
-        };
+        let state = self
+            .servers
+            .remove(id)
+            .context("Context server not found")?;
         drop(state);
         cx.emit(Event::ServerStatusChanged {
             server_id: id.clone(),
@@ -347,11 +424,6 @@ impl ContextServerStore {
         Ok(())
     }
 
-    fn is_configuration_valid(&self, configuration: &ContextServerConfiguration) -> bool {
-        // Command must be some when we are running in stdio mode.
-        self.context_server_factory.as_ref().is_some() || configuration.command.is_some()
-    }
-
     fn create_context_server(
         &self,
         id: ContextServerId,
@@ -360,14 +432,29 @@ impl ContextServerStore {
         if let Some(factory) = self.context_server_factory.as_ref() {
             Ok(factory(id, configuration))
         } else {
-            let command = configuration
-                .command
-                .clone()
-                .context("Missing command to run context server")?;
-            Ok(Arc::new(ContextServer::stdio(id, command)))
+            Ok(Arc::new(ContextServer::stdio(
+                id,
+                configuration.command().clone(),
+            )))
         }
     }
 
+    fn context_server_settings<'a>(
+        &'a self,
+        cx: &'a App,
+    ) -> &'a HashMap<Arc<str>, ContextServerSettings> {
+        let location = self
+            .worktree_store
+            .read(cx)
+            .visible_worktrees(cx)
+            .next()
+            .map(|worktree| settings::SettingsLocation {
+                worktree_id: worktree.read(cx).id(),
+                path: Path::new(""),
+            });
+        &ProjectSettings::get(location, cx).context_servers
+    }
+
     fn update_server_state(
         &mut self,
         id: ContextServerId,
@@ -405,43 +492,42 @@ impl ContextServerStore {
     }
 
     async fn maintain_servers(this: WeakEntity<Self>, cx: &mut AsyncApp) -> Result<()> {
-        let mut desired_servers = HashMap::default();
-
-        let (registry, worktree_store) = this.update(cx, |this, cx| {
-            let location = this
-                .worktree_store
-                .read(cx)
-                .visible_worktrees(cx)
-                .next()
-                .map(|worktree| settings::SettingsLocation {
-                    worktree_id: worktree.read(cx).id(),
-                    path: Path::new(""),
-                });
-            let settings = ProjectSettings::get(location, cx);
-            desired_servers = settings.context_servers.clone();
-
-            (this.registry.clone(), this.worktree_store.clone())
+        let (mut configured_servers, registry, worktree_store) = this.update(cx, |this, cx| {
+            (
+                this.context_server_settings(cx).clone(),
+                this.registry.clone(),
+                this.worktree_store.clone(),
+            )
         })?;
 
-        for (id, descriptor) in
+        for (id, _) in
             registry.read_with(cx, |registry, _| registry.context_server_descriptors())?
         {
-            let config = desired_servers.entry(id.clone()).or_default();
-            if config.command.is_none() {
-                if let Some(extension_command) = descriptor
-                    .command(worktree_store.clone(), &cx)
-                    .await
-                    .log_err()
-                {
-                    config.command = Some(extension_command);
-                }
-            }
+            configured_servers
+                .entry(id)
+                .or_insert(ContextServerSettings::default_extension());
         }
 
-        this.update(cx, |this, _| {
-            // Filter out configurations without commands, the user uninstalled an extension.
-            desired_servers.retain(|_, configuration| this.is_configuration_valid(configuration));
-        })?;
+        let (enabled_servers, disabled_servers): (HashMap<_, _>, HashMap<_, _>) =
+            configured_servers
+                .into_iter()
+                .partition(|(_, settings)| settings.enabled());
+
+        let configured_servers = join_all(enabled_servers.into_iter().map(|(id, settings)| {
+            let id = ContextServerId(id);
+            ContextServerConfiguration::from_settings(
+                settings,
+                id.clone(),
+                registry.clone(),
+                worktree_store.clone(),
+                cx,
+            )
+            .map(|config| (id, config))
+        }))
+        .await
+        .into_iter()
+        .filter_map(|(id, config)| config.map(|config| (id, config)))
+        .collect::<HashMap<_, _>>();
 
         let mut servers_to_start = Vec::new();
         let mut servers_to_remove = HashSet::default();
@@ -450,18 +536,21 @@ impl ContextServerStore {
         this.update(cx, |this, _cx| {
             for server_id in this.servers.keys() {
                 // All servers that are not in desired_servers should be removed from the store.
-                // E.g. this can happen if the user removed a server from the configuration,
-                // or the user uninstalled an extension.
-                if !desired_servers.contains_key(&server_id.0) {
-                    servers_to_remove.insert(server_id.clone());
+                // This can happen if the user removed a server from the context server settings.
+                if !configured_servers.contains_key(&server_id) {
+                    if disabled_servers.contains_key(&server_id.0) {
+                        servers_to_stop.insert(server_id.clone());
+                    } else {
+                        servers_to_remove.insert(server_id.clone());
+                    }
                 }
             }
 
-            for (id, config) in desired_servers {
-                let id = ContextServerId(id.clone());
-
-                let existing_config = this.servers.get(&id).map(|state| state.configuration());
-                if existing_config.as_deref() != Some(&config) {
+            for (id, config) in configured_servers {
+                let state = this.servers.get(&id);
+                let is_stopped = matches!(state, Some(ContextServerState::Stopped { .. }));
+                let existing_config = state.as_ref().map(|state| state.configuration());
+                if existing_config.as_deref() != Some(&config) || is_stopped {
                     let config = Arc::new(config);
                     if let Some(server) = this
                         .create_context_server(id.clone(), config.clone())
@@ -476,38 +565,32 @@ impl ContextServerStore {
             }
         })?;
 
-        for id in servers_to_stop {
-            this.update(cx, |this, cx| this.stop_server(&id, cx).ok())?;
-        }
-
-        for id in servers_to_remove {
-            this.update(cx, |this, cx| this.remove_server(&id, cx).ok())?;
-        }
-
-        for (server, config) in servers_to_start {
-            this.update(cx, |this, cx| this.run_server(server, config, cx))
-                .log_err();
-        }
-
-        Ok(())
+        this.update(cx, |this, cx| {
+            for id in servers_to_stop {
+                this.stop_server(&id, cx)?;
+            }
+            for id in servers_to_remove {
+                this.remove_server(&id, cx)?;
+            }
+            for (server, config) in servers_to_start {
+                this.run_server(server, config, cx);
+            }
+            anyhow::Ok(())
+        })?
     }
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::{FakeFs, Project, project_settings::ProjectSettings};
-    use context_server::{
-        transport::Transport,
-        types::{
-            self, Implementation, InitializeResponse, ProtocolVersion, RequestType,
-            ServerCapabilities,
-        },
+    use crate::{
+        FakeFs, Project, context_server_store::registry::ContextServerDescriptor,
+        project_settings::ProjectSettings,
     };
-    use futures::{Stream, StreamExt as _, lock::Mutex};
-    use gpui::{AppContext, BackgroundExecutor, TestAppContext, UpdateGlobal as _};
+    use context_server::test::create_fake_transport;
+    use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
     use serde_json::json;
-    use std::{cell::RefCell, pin::Pin, rc::Rc};
+    use std::{cell::RefCell, rc::Rc};
     use util::path;
 
     #[gpui::test]
@@ -519,8 +602,8 @@ mod tests {
             cx,
             json!({"code.rs": ""}),
             vec![
-                (SERVER_1_ID.into(), ContextServerConfiguration::default()),
-                (SERVER_2_ID.into(), ContextServerConfiguration::default()),
+                (SERVER_1_ID.into(), dummy_server_settings()),
+                (SERVER_2_ID.into(), dummy_server_settings()),
             ],
         )
         .await;
@@ -530,37 +613,19 @@ mod tests {
             ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx)
         });
 
-        let server_1_id = ContextServerId("mcp-1".into());
-        let server_2_id = ContextServerId("mcp-2".into());
-
-        let transport_1 =
-            Arc::new(FakeTransport::new(
-                cx.executor(),
-                |_, request_type, _| match request_type {
-                    Some(RequestType::Initialize) => {
-                        Some(create_initialize_response("mcp-1".to_string()))
-                    }
-                    _ => None,
-                },
-            ));
-
-        let transport_2 =
-            Arc::new(FakeTransport::new(
-                cx.executor(),
-                |_, request_type, _| match request_type {
-                    Some(RequestType::Initialize) => {
-                        Some(create_initialize_response("mcp-2".to_string()))
-                    }
-                    _ => None,
-                },
-            ));
+        let server_1_id = ContextServerId(SERVER_1_ID.into());
+        let server_2_id = ContextServerId(SERVER_2_ID.into());
 
-        let server_1 = Arc::new(ContextServer::new(server_1_id.clone(), transport_1.clone()));
-        let server_2 = Arc::new(ContextServer::new(server_2_id.clone(), transport_2.clone()));
+        let server_1 = Arc::new(ContextServer::new(
+            server_1_id.clone(),
+            Arc::new(create_fake_transport(SERVER_1_ID, cx.executor())),
+        ));
+        let server_2 = Arc::new(ContextServer::new(
+            server_2_id.clone(),
+            Arc::new(create_fake_transport(SERVER_2_ID, cx.executor())),
+        ));
 
-        store
-            .update(cx, |store, cx| store.start_server(server_1, cx))
-            .unwrap();
+        store.update(cx, |store, cx| store.start_server(server_1, cx));
 
         cx.run_until_parked();
 
@@ -572,9 +637,7 @@ mod tests {
             assert_eq!(store.read(cx).status_for_server(&server_2_id), None);
         });
 
-        store
-            .update(cx, |store, cx| store.start_server(server_2.clone(), cx))
-            .unwrap();
+        store.update(cx, |store, cx| store.start_server(server_2.clone(), cx));
 
         cx.run_until_parked();
 
@@ -614,8 +677,8 @@ mod tests {
             cx,
             json!({"code.rs": ""}),
             vec![
-                (SERVER_1_ID.into(), ContextServerConfiguration::default()),
-                (SERVER_2_ID.into(), ContextServerConfiguration::default()),
+                (SERVER_1_ID.into(), dummy_server_settings()),
+                (SERVER_2_ID.into(), dummy_server_settings()),
             ],
         )
         .await;
@@ -625,33 +688,17 @@ mod tests {
             ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx)
         });
 
-        let server_1_id = ContextServerId("mcp-1".into());
-        let server_2_id = ContextServerId("mcp-2".into());
-
-        let transport_1 =
-            Arc::new(FakeTransport::new(
-                cx.executor(),
-                |_, request_type, _| match request_type {
-                    Some(RequestType::Initialize) => {
-                        Some(create_initialize_response("mcp-1".to_string()))
-                    }
-                    _ => None,
-                },
-            ));
-
-        let transport_2 =
-            Arc::new(FakeTransport::new(
-                cx.executor(),
-                |_, request_type, _| match request_type {
-                    Some(RequestType::Initialize) => {
-                        Some(create_initialize_response("mcp-2".to_string()))
-                    }
-                    _ => None,
-                },
-            ));
+        let server_1_id = ContextServerId(SERVER_1_ID.into());
+        let server_2_id = ContextServerId(SERVER_2_ID.into());
 
-        let server_1 = Arc::new(ContextServer::new(server_1_id.clone(), transport_1.clone()));
-        let server_2 = Arc::new(ContextServer::new(server_2_id.clone(), transport_2.clone()));
+        let server_1 = Arc::new(ContextServer::new(
+            server_1_id.clone(),
+            Arc::new(create_fake_transport(SERVER_1_ID, cx.executor())),
+        ));
+        let server_2 = Arc::new(ContextServer::new(
+            server_2_id.clone(),
+            Arc::new(create_fake_transport(SERVER_2_ID, cx.executor())),
+        ));
 
         let _server_events = assert_server_events(
             &store,
@@ -665,15 +712,11 @@ mod tests {
             cx,
         );
 
-        store
-            .update(cx, |store, cx| store.start_server(server_1, cx))
-            .unwrap();
+        store.update(cx, |store, cx| store.start_server(server_1, cx));
 
         cx.run_until_parked();
 
-        store
-            .update(cx, |store, cx| store.start_server(server_2.clone(), cx))
-            .unwrap();
+        store.update(cx, |store, cx| store.start_server(server_2.clone(), cx));
 
         cx.run_until_parked();
 
@@ -689,7 +732,7 @@ mod tests {
         let (_fs, project) = setup_context_server_test(
             cx,
             json!({"code.rs": ""}),
-            vec![(SERVER_1_ID.into(), ContextServerConfiguration::default())],
+            vec![(SERVER_1_ID.into(), dummy_server_settings())],
         )
         .await;
 
@@ -700,30 +743,14 @@ mod tests {
 
         let server_id = ContextServerId(SERVER_1_ID.into());
 
-        let transport_1 =
-            Arc::new(FakeTransport::new(
-                cx.executor(),
-                |_, request_type, _| match request_type {
-                    Some(RequestType::Initialize) => {
-                        Some(create_initialize_response(SERVER_1_ID.to_string()))
-                    }
-                    _ => None,
-                },
-            ));
-
-        let transport_2 =
-            Arc::new(FakeTransport::new(
-                cx.executor(),
-                |_, request_type, _| match request_type {
-                    Some(RequestType::Initialize) => {
-                        Some(create_initialize_response(SERVER_1_ID.to_string()))
-                    }
-                    _ => None,
-                },
-            ));
-
-        let server_with_same_id_1 = Arc::new(ContextServer::new(server_id.clone(), transport_1));
-        let server_with_same_id_2 = Arc::new(ContextServer::new(server_id.clone(), transport_2));
+        let server_with_same_id_1 = Arc::new(ContextServer::new(
+            server_id.clone(),
+            Arc::new(create_fake_transport(SERVER_1_ID, cx.executor())),
+        ));
+        let server_with_same_id_2 = Arc::new(ContextServer::new(
+            server_id.clone(),
+            Arc::new(create_fake_transport(SERVER_1_ID, cx.executor())),
+        ));
 
         // If we start another server with the same id, we should report that we stopped the previous one
         let _server_events = assert_server_events(
@@ -737,21 +764,11 @@ mod tests {
             cx,
         );
 
-        store
-            .update(cx, |store, cx| {
-                store.start_server(server_with_same_id_1.clone(), cx)
-            })
-            .unwrap();
-        store
-            .update(cx, |store, cx| {
-                store.start_server(server_with_same_id_2.clone(), cx)
-            })
-            .unwrap();
-        cx.update(|cx| {
-            assert_eq!(
-                store.read(cx).status_for_server(&server_id),
-                Some(ContextServerStatus::Starting)
-            );
+        store.update(cx, |store, cx| {
+            store.start_server(server_with_same_id_1.clone(), cx)
+        });
+        store.update(cx, |store, cx| {
+            store.start_server(server_with_same_id_2.clone(), cx)
         });
 
         cx.run_until_parked();
@@ -772,36 +789,36 @@ mod tests {
         let server_1_id = ContextServerId(SERVER_1_ID.into());
         let server_2_id = ContextServerId(SERVER_2_ID.into());
 
+        let fake_descriptor_1 = Arc::new(FakeContextServerDescriptor::new(SERVER_1_ID));
+
         let (_fs, project) = setup_context_server_test(
             cx,
             json!({"code.rs": ""}),
             vec![(
                 SERVER_1_ID.into(),
-                ContextServerConfiguration {
-                    command: None,
-                    settings: Some(json!({
+                ContextServerSettings::Extension {
+                    enabled: true,
+                    settings: json!({
                         "somevalue": true
-                    })),
+                    }),
                 },
             )],
         )
         .await;
 
         let executor = cx.executor();
-        let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
+        let registry = cx.new(|_| {
+            let mut registry = ContextServerDescriptorRegistry::new();
+            registry.register_context_server_descriptor(SERVER_1_ID.into(), fake_descriptor_1);
+            registry
+        });
         let store = cx.new(|cx| {
             ContextServerStore::test_maintain_server_loop(
                 Box::new(move |id, _| {
-                    let transport = FakeTransport::new(executor.clone(), {
-                        let id = id.0.clone();
-                        move |_, request_type, _| match request_type {
-                            Some(RequestType::Initialize) => {
-                                Some(create_initialize_response(id.clone().to_string()))
-                            }
-                            _ => None,
-                        }
-                    });
-                    Arc::new(ContextServer::new(id.clone(), Arc::new(transport)))
+                    Arc::new(ContextServer::new(
+                        id.clone(),
+                        Arc::new(create_fake_transport(id.0.to_string(), executor.clone())),
+                    ))
                 }),
                 registry.clone(),
                 project.read(cx).worktree_store(),
@@ -836,11 +853,11 @@ mod tests {
             set_context_server_configuration(
                 vec![(
                     server_1_id.0.clone(),
-                    ContextServerConfiguration {
-                        command: None,
-                        settings: Some(json!({
+                    ContextServerSettings::Extension {
+                        enabled: true,
+                        settings: json!({
                             "somevalue": false
-                        })),
+                        }),
                     },
                 )],
                 cx,
@@ -855,11 +872,11 @@ mod tests {
             set_context_server_configuration(
                 vec![(
                     server_1_id.0.clone(),
-                    ContextServerConfiguration {
-                        command: None,
-                        settings: Some(json!({
+                    ContextServerSettings::Extension {
+                        enabled: true,
+                        settings: json!({
                             "somevalue": false
-                        })),
+                        }),
                     },
                 )],
                 cx,
@@ -882,20 +899,62 @@ mod tests {
                 vec![
                     (
                         server_1_id.0.clone(),
-                        ContextServerConfiguration {
-                            command: None,
-                            settings: Some(json!({
+                        ContextServerSettings::Extension {
+                            enabled: true,
+                            settings: json!({
+                                "somevalue": false
+                            }),
+                        },
+                    ),
+                    (
+                        server_2_id.0.clone(),
+                        ContextServerSettings::Custom {
+                            enabled: true,
+                            command: ContextServerCommand {
+                                path: "somebinary".to_string(),
+                                args: vec!["arg".to_string()],
+                                env: None,
+                            },
+                        },
+                    ),
+                ],
+                cx,
+            );
+
+            cx.run_until_parked();
+        }
+
+        // Ensure that mcp-2 is restarted once the args have changed
+        {
+            let _server_events = assert_server_events(
+                &store,
+                vec![
+                    (server_2_id.clone(), ContextServerStatus::Stopped),
+                    (server_2_id.clone(), ContextServerStatus::Starting),
+                    (server_2_id.clone(), ContextServerStatus::Running),
+                ],
+                cx,
+            );
+            set_context_server_configuration(
+                vec![
+                    (
+                        server_1_id.0.clone(),
+                        ContextServerSettings::Extension {
+                            enabled: true,
+                            settings: json!({
                                 "somevalue": false
-                            })),
+                            }),
                         },
                     ),
                     (
                         server_2_id.0.clone(),
-                        ContextServerConfiguration {
-                            command: None,
-                            settings: Some(json!({
-                                "somevalue": true
-                            })),
+                        ContextServerSettings::Custom {
+                            enabled: true,
+                            command: ContextServerCommand {
+                                path: "somebinary".to_string(),
+                                args: vec!["anotherArg".to_string()],
+                                env: None,
+                            },
                         },
                     ),
                 ],
@@ -915,11 +974,11 @@ mod tests {
             set_context_server_configuration(
                 vec![(
                     server_1_id.0.clone(),
-                    ContextServerConfiguration {
-                        command: None,
-                        settings: Some(json!({
+                    ContextServerSettings::Extension {
+                        enabled: true,
+                        settings: json!({
                             "somevalue": false
-                        })),
+                        }),
                     },
                 )],
                 cx,
@@ -933,8 +992,114 @@ mod tests {
         }
     }
 
+    #[gpui::test]
+    async fn test_context_server_enabled_disabled(cx: &mut TestAppContext) {
+        const SERVER_1_ID: &'static str = "mcp-1";
+
+        let server_1_id = ContextServerId(SERVER_1_ID.into());
+
+        let (_fs, project) = setup_context_server_test(
+            cx,
+            json!({"code.rs": ""}),
+            vec![(
+                SERVER_1_ID.into(),
+                ContextServerSettings::Custom {
+                    enabled: true,
+                    command: ContextServerCommand {
+                        path: "somebinary".to_string(),
+                        args: vec!["arg".to_string()],
+                        env: None,
+                    },
+                },
+            )],
+        )
+        .await;
+
+        let executor = cx.executor();
+        let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
+        let store = cx.new(|cx| {
+            ContextServerStore::test_maintain_server_loop(
+                Box::new(move |id, _| {
+                    Arc::new(ContextServer::new(
+                        id.clone(),
+                        Arc::new(create_fake_transport(id.0.to_string(), executor.clone())),
+                    ))
+                }),
+                registry.clone(),
+                project.read(cx).worktree_store(),
+                cx,
+            )
+        });
+
+        // Ensure that mcp-1 starts up
+        {
+            let _server_events = assert_server_events(
+                &store,
+                vec![
+                    (server_1_id.clone(), ContextServerStatus::Starting),
+                    (server_1_id.clone(), ContextServerStatus::Running),
+                ],
+                cx,
+            );
+            cx.run_until_parked();
+        }
+
+        // Ensure that mcp-1 is stopped once it is disabled.
+        {
+            let _server_events = assert_server_events(
+                &store,
+                vec![(server_1_id.clone(), ContextServerStatus::Stopped)],
+                cx,
+            );
+            set_context_server_configuration(
+                vec![(
+                    server_1_id.0.clone(),
+                    ContextServerSettings::Custom {
+                        enabled: false,
+                        command: ContextServerCommand {
+                            path: "somebinary".to_string(),
+                            args: vec!["arg".to_string()],
+                            env: None,
+                        },
+                    },
+                )],
+                cx,
+            );
+
+            cx.run_until_parked();
+        }
+
+        // Ensure that mcp-1 is started once it is enabled again.
+        {
+            let _server_events = assert_server_events(
+                &store,
+                vec![
+                    (server_1_id.clone(), ContextServerStatus::Starting),
+                    (server_1_id.clone(), ContextServerStatus::Running),
+                ],
+                cx,
+            );
+            set_context_server_configuration(
+                vec![(
+                    server_1_id.0.clone(),
+                    ContextServerSettings::Custom {
+                        enabled: true,
+                        command: ContextServerCommand {
+                            path: "somebinary".to_string(),
+                            args: vec!["arg".to_string()],
+                            env: None,
+                        },
+                    },
+                )],
+                cx,
+            );
+
+            cx.run_until_parked();
+        }
+    }
+
     fn set_context_server_configuration(
-        context_servers: Vec<(Arc<str>, ContextServerConfiguration)>,
+        context_servers: Vec<(Arc<str>, ContextServerSettings)>,
         cx: &mut TestAppContext,
     ) {
         cx.update(|cx| {
@@ -968,6 +1133,17 @@ mod tests {
         }
     }
 
+    fn dummy_server_settings() -> ContextServerSettings {
+        ContextServerSettings::Custom {
+            enabled: true,
+            command: ContextServerCommand {
+                path: "somebinary".to_string(),
+                args: vec!["arg".to_string()],
+                env: None,
+            },
+        }
+    }
+
     fn assert_server_events(
         store: &Entity<ContextServerStore>,
         expected_events: Vec<(ContextServerId, ContextServerStatus)>,
@@ -1012,7 +1188,7 @@ mod tests {
     async fn setup_context_server_test(
         cx: &mut TestAppContext,
         files: serde_json::Value,
-        context_server_configurations: Vec<(Arc<str>, ContextServerConfiguration)>,
+        context_server_configurations: Vec<(Arc<str>, ContextServerSettings)>,
     ) -> (Arc<FakeFs>, Entity<Project>) {
         cx.update(|cx| {
             let settings_store = SettingsStore::test(cx);
@@ -1032,98 +1208,35 @@ mod tests {
         (fs, project)
     }
 
-    fn create_initialize_response(server_name: String) -> serde_json::Value {
-        serde_json::to_value(&InitializeResponse {
-            protocol_version: ProtocolVersion(types::LATEST_PROTOCOL_VERSION.to_string()),
-            server_info: Implementation {
-                name: server_name,
-                version: "1.0.0".to_string(),
-            },
-            capabilities: ServerCapabilities::default(),
-            meta: None,
-        })
-        .unwrap()
-    }
-
-    struct FakeTransport {
-        on_request: Arc<
-            dyn Fn(u64, Option<RequestType>, serde_json::Value) -> Option<serde_json::Value>
-                + Send
-                + Sync,
-        >,
-        tx: futures::channel::mpsc::UnboundedSender<String>,
-        rx: Arc<Mutex<futures::channel::mpsc::UnboundedReceiver<String>>>,
-        executor: BackgroundExecutor,
+    struct FakeContextServerDescriptor {
+        path: String,
     }
 
-    impl FakeTransport {
-        fn new(
-            executor: BackgroundExecutor,
-            on_request: impl Fn(
-                u64,
-                Option<RequestType>,
-                serde_json::Value,
-            ) -> Option<serde_json::Value>
-            + 'static
-            + Send
-            + Sync,
-        ) -> Self {
-            let (tx, rx) = futures::channel::mpsc::unbounded();
-            Self {
-                on_request: Arc::new(on_request),
-                tx,
-                rx: Arc::new(Mutex::new(rx)),
-                executor,
-            }
+    impl FakeContextServerDescriptor {
+        fn new(path: impl Into<String>) -> Self {
+            Self { path: path.into() }
         }
     }
 
-    #[async_trait::async_trait]
-    impl Transport for FakeTransport {
-        async fn send(&self, message: String) -> Result<()> {
-            if let Ok(msg) = serde_json::from_str::<serde_json::Value>(&message) {
-                let id = msg.get("id").and_then(|id| id.as_u64()).unwrap_or(0);
-
-                if let Some(method) = msg.get("method") {
-                    let request_type = method
-                        .as_str()
-                        .and_then(|method| types::RequestType::try_from(method).ok());
-                    if let Some(payload) = (self.on_request.as_ref())(id, request_type, msg) {
-                        let response = serde_json::json!({
-                            "jsonrpc": "2.0",
-                            "id": id,
-                            "result": payload
-                        });
-
-                        self.tx
-                            .unbounded_send(response.to_string())
-                            .map_err(|e| anyhow::anyhow!("Failed to send message: {}", e))?;
-                    }
-                }
-            }
-            Ok(())
-        }
-
-        fn receive(&self) -> Pin<Box<dyn Stream<Item = String> + Send>> {
-            let rx = self.rx.clone();
-            let executor = self.executor.clone();
-            Box::pin(futures::stream::unfold(rx, move |rx| {
-                let executor = executor.clone();
-                async move {
-                    let mut rx_guard = rx.lock().await;
-                    executor.simulate_random_delay().await;
-                    if let Some(message) = rx_guard.next().await {
-                        drop(rx_guard);
-                        Some((message, rx))
-                    } else {
-                        None
-                    }
-                }
+    impl ContextServerDescriptor for FakeContextServerDescriptor {
+        fn command(
+            &self,
+            _worktree_store: Entity<WorktreeStore>,
+            _cx: &AsyncApp,
+        ) -> Task<Result<ContextServerCommand>> {
+            Task::ready(Ok(ContextServerCommand {
+                path: self.path.clone(),
+                args: vec!["arg1".to_string(), "arg2".to_string()],
+                env: None,
             }))
         }
 
-        fn receive_err(&self) -> Pin<Box<dyn Stream<Item = String> + Send>> {
-            Box::pin(futures::stream::empty())
+        fn configuration(
+            &self,
+            _worktree_store: Entity<WorktreeStore>,
+            _cx: &AsyncApp,
+        ) -> Task<Result<Option<::extension::ContextServerConfiguration>>> {
+            Task::ready(Ok(None))
         }
     }
 }

crates/project/src/debugger/README.md 🔗

@@ -1,354 +0,0 @@
-# Debugger
-
-Zed uses the Debug Adapter Protocol (DAP) to provide debugging functionality across multiple programming languages.
-DAP is a standardized protocol that defines how debuggers, editors, and IDEs communicate with each other.
-It allows Zed to support various debuggers without needing to implement language-specific debugging logic.
-This protocol enables features like setting breakpoints, stepping through code, inspecting variables,
-and more, in a consistent manner across different programming languages and runtime environments.
-
-## Supported Debug Adapters
-
-Zed supports a variety of debug adapters for different programming languages:
-
-- JavaScript (node): Enables debugging of Node.js applications, including setting breakpoints, stepping through code, and inspecting variables in JavaScript.
-
-- Python (debugpy): Provides debugging capabilities for Python applications, supporting features like remote debugging, multi-threaded debugging, and Django/Flask application debugging.
-
-- LLDB: A powerful debugger for C, C++, Objective-C, and Swift, offering low-level debugging features and support for Apple platforms.
-
-- GDB: The GNU Debugger, which supports debugging for multiple programming languages including C, C++, Go, and Rust, across various platforms.
-
-- Go (dlv): Delve, a debugger for the Go programming language, offering both local and remote debugging capabilities with full support for Go's runtime and standard library.
-
-- PHP (xdebug): Provides debugging and profiling capabilities for PHP applications, including remote debugging and code coverage analysis.
-
-- Custom: Allows you to configure any debug adapter that supports the Debug Adapter Protocol, enabling debugging for additional languages or specialized environments not natively supported by Zed.
-
-These adapters enable Zed to provide a consistent debugging experience across multiple languages while leveraging the specific features and capabilities of each debugger.
-
-## How To Get Started
-
-To start a debug session, we added few default debug configurations for each supported language that supports generic configuration options. To see all the available debug configurations, you can use the command palette `debugger: start` action, this should list all the available debug configurations.
-
-### Configuration
-
-To create a custom debug configuration you have to create a `.zed/debug.json` file in your project root directory. This file should contain an array of debug configurations, each with a unique label and adapter the other option are optional/required based on the adapter.
-
-```json
-[
-  {
-    // The label for the debug configuration and used to identify the debug session inside the debug panel
-    "label": "Example Start debugger config",
-    // The debug adapter that Zed should use to debug the program
-    "adapter": "custom",
-    // Request: defaults to launch
-    //  - launch: Zed will launch the program if specified or shows a debug terminal with the right configuration
-    //  - attach: Zed will attach to a running program to debug it or when the process_id is not specified we will show a process picker (only supported for node currently)
-    "request": "launch",
-    // cwd: defaults to the current working directory of your project ($ZED_WORKTREE_ROOT)
-    // this field also supports task variables e.g. $ZED_WORKTREE_ROOT
-    "cwd": "$ZED_WORKTREE_ROOT",
-    // program: The program that you want to debug
-    // this fields also support task variables e.g. $ZED_FILE
-    // Note: this field should only contain the path to the program you want to debug
-    "program": "path_to_program",
-    // initialize_args: This field should contain all the adapter specific initialization arguments that are directly send to the debug adapter
-    "initialize_args": {
-      // "stopOnEntry": true // e.g. to stop on the first line of the program (These args are DAP specific)
-    },
-    // connection: the connection that a custom debugger should use
-    "connection": "stdio",
-    // The cli command used to start the debug adapter e.g. `python3`, `node` or the adapter binary
-    "command": "path_to_cli"
-  }
-]
-```
-
-### Using Attach [WIP]
-
-Only javascript and lldb supports starting a debug session using attach.
-
-When using the attach request with a process ID the syntax is as follows:
-
-```json
-{
-  "label": "Attach to Process",
-  "adapter": "javascript",
-  "request": {
-    "attach": {
-      "process_id": "12345"
-    }
-  }
-}
-```
-
-Without process ID the syntax is as follows:
-
-```json
-{
-  "label": "Attach to Process",
-  "adapter": "javascript",
-  "request": {
-    "attach": {}
-  }
-}
-```
-
-#### JavaScript Configuration
-
-##### Debug Active File
-
-This configuration allows you to debug a JavaScript file in your project.
-
-```json
-{
-  "label": "JavaScript: Debug Active File",
-  "adapter": "javascript",
-  "program": "$ZED_FILE",
-  "request": "launch",
-  "cwd": "$ZED_WORKTREE_ROOT"
-}
-```
-
-##### Debug Terminal
-
-This configuration will spawn a debug terminal where you could start you program by typing `node test.js`, and the debug adapter will automatically attach to the process.
-
-```json
-{
-  "label": "JavaScript: Debug Terminal",
-  "adapter": "javascript",
-  "request": "launch",
-  "cwd": "$ZED_WORKTREE_ROOT",
-  // "program": "$ZED_FILE", // optional if you pass this in, you will see the output inside the terminal itself
-  "initialize_args": {
-    "console": "integratedTerminal"
-  }
-}
-```
-
-#### PHP Configuration
-
-##### Debug Active File
-
-This configuration allows you to debug a PHP file in your project.
-
-```json
-{
-  "label": "PHP: Debug Active File",
-  "adapter": "php",
-  "program": "$ZED_FILE",
-  "request": "launch",
-  "cwd": "$ZED_WORKTREE_ROOT"
-}
-```
-
-#### Python Configuration
-
-##### Debug Active File
-
-This configuration allows you to debug a Python file in your project.
-
-```json
-{
-  "label": "Python: Debug Active File",
-  "adapter": "python",
-  "program": "$ZED_FILE",
-  "request": "launch",
-  "cwd": "$ZED_WORKTREE_ROOT"
-}
-```
-
-#### GDB Configuration
-
-**NOTE:** This configuration is for Linux systems only & intel macbooks.
-
-##### Debug Program
-
-This configuration allows you to debug a program using GDB e.g. Zed itself.
-
-```json
-{
-  "label": "GDB: Debug program",
-  "adapter": "gdb",
-  "program": "$ZED_WORKTREE_ROOT/target/debug/zed",
-  "request": "launch",
-  "cwd": "$ZED_WORKTREE_ROOT"
-}
-```
-
-#### LLDB Configuration
-
-##### Debug Program
-
-This configuration allows you to debug a program using LLDB e.g. Zed itself.
-
-```json
-{
-  "label": "LLDB: Debug program",
-  "adapter": "lldb",
-  "program": "$ZED_WORKTREE_ROOT/target/debug/zed",
-  "request": "launch",
-  "cwd": "$ZED_WORKTREE_ROOT"
-}
-```
-
-## Breakpoints
-
-Zed currently supports these types of breakpoints
-
-- Log Breakpoints: Output a log message instead of stopping at the breakpoint when it's hit
-- Standard Breakpoints: Stop at the breakpoint when it's hit
-
-Standard breakpoints can be toggled by left clicking on the editor gutter or using the Toggle Breakpoint action. Right clicking on a breakpoint, code action symbol, or code runner symbol brings up the breakpoint context menu. That has options for toggling breakpoints and editing log breakpoints.
-
-Log breakpoints can also be edited/added through the edit log breakpoint action
-
-## Settings
-
-- `stepping_granularity`: Determines the stepping granularity.
-- `save_breakpoints`: Whether the breakpoints should be reused across Zed sessions.
-- `button`: Whether to show the debug button in the status bar.
-- `timeout`: Time in milliseconds until timeout error when connecting to a TCP debug adapter.
-- `log_dap_communications`: Whether to log messages between active debug adapters and Zed
-- `format_dap_log_messages`: Whether to format dap messages in when adding them to debug adapter logger
-
-### Stepping granularity
-
-- Description: The Step granularity that the debugger will use
-- Default: line
-- Setting: debugger.stepping_granularity
-
-**Options**
-
-1. Statement - The step should allow the program to run until the current statement has finished executing.
-   The meaning of a statement is determined by the adapter and it may be considered equivalent to a line.
-   For example 'for(int i = 0; i < 10; i++)' could be considered to have 3 statements 'int i = 0', 'i < 10', and 'i++'.
-
-```json
-{
-  "debugger": {
-    "stepping_granularity": "statement"
-  }
-}
-```
-
-2. Line - The step should allow the program to run until the current source line has executed.
-
-```json
-{
-  "debugger": {
-    "stepping_granularity": "line"
-  }
-}
-```
-
-3. Instruction - The step should allow one instruction to execute (e.g. one x86 instruction).
-
-```json
-{
-  "debugger": {
-    "stepping_granularity": "instruction"
-  }
-}
-```
-
-### Save Breakpoints
-
-- Description: Whether the breakpoints should be saved across Zed sessions.
-- Default: true
-- Setting: debugger.save_breakpoints
-
-**Options**
-
-`boolean` values
-
-```json
-{
-  "debugger": {
-    "save_breakpoints": true
-  }
-}
-```
-
-### Button
-
-- Description: Whether the button should be displayed in the debugger toolbar.
-- Default: true
-- Setting: debugger.show_button
-
-**Options**
-
-`boolean` values
-
-```json
-{
-  "debugger": {
-    "show_button": true
-  }
-}
-```
-
-### Timeout
-
-- Description: Time in milliseconds until timeout error when connecting to a TCP debug adapter.
-- Default: 2000ms
-- Setting: debugger.timeout
-
-**Options**
-
-`integer` values
-
-```json
-{
-  "debugger": {
-    "timeout": 3000
-  }
-}
-```
-
-### Log Dap Communications
-
-- Description: Whether to log messages between active debug adapters and Zed. (Used for DAP development)
-- Default: false
-- Setting: debugger.log_dap_communications
-
-**Options**
-
-`boolean` values
-
-```json
-{
-  "debugger": {
-    "log_dap_communications": true
-  }
-}
-```
-
-### Format Dap Log Messages
-
-- Description: Whether to format dap messages in when adding them to debug adapter logger. (Used for DAP development)
-- Default: false
-- Setting: debugger.format_dap_log_messages
-
-**Options**
-
-`boolean` values
-
-```json
-{
-  "debugger": {
-    "format_dap_log_messages": true
-  }
-}
-```
-
-## Theme
-
-The Debugger supports the following theme options
-
-    /// Color used to accent some of the debuggers elements
-    /// Only accent breakpoint & breakpoint related symbols right now
-
-**debugger.accent**: Color used to accent breakpoint & breakpoint related symbols
-**editor.debugger_active_line.background**: Background color of active debug line

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

@@ -1,9 +1,10 @@
 //! Module for managing breakpoints in a project.
 //!
 //! Breakpoints are separate from a session because they're not associated with any particular debug session. They can also be set up without a session running.
-use anyhow::{Result, anyhow};
-use breakpoints_in_file::BreakpointsInFile;
-use collections::BTreeMap;
+use anyhow::{Context as _, Result};
+pub use breakpoints_in_file::{BreakpointSessionState, BreakpointWithPosition};
+use breakpoints_in_file::{BreakpointsInFile, StatefulBreakpoint};
+use collections::{BTreeMap, HashMap};
 use dap::{StackFrameId, client::SessionId};
 use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Subscription, Task};
 use itertools::Itertools;
@@ -14,21 +15,54 @@ use rpc::{
 };
 use std::{hash::Hash, ops::Range, path::Path, sync::Arc, u32};
 use text::{Point, PointUtf16};
+use util::maybe;
 
 use crate::{Project, ProjectPath, buffer_store::BufferStore, worktree_store::WorktreeStore};
 
 use super::session::ThreadId;
 
 mod breakpoints_in_file {
+    use collections::HashMap;
     use language::{BufferEvent, DiskState};
 
     use super::*;
 
+    #[derive(Clone, Debug, PartialEq, Eq)]
+    pub struct BreakpointWithPosition {
+        pub position: text::Anchor,
+        pub bp: Breakpoint,
+    }
+
+    /// A breakpoint with per-session data about it's state (as seen by the Debug Adapter).
+    #[derive(Clone, Debug)]
+    pub struct StatefulBreakpoint {
+        pub bp: BreakpointWithPosition,
+        pub session_state: HashMap<SessionId, BreakpointSessionState>,
+    }
+
+    impl StatefulBreakpoint {
+        pub(super) fn new(bp: BreakpointWithPosition) -> Self {
+            Self {
+                bp,
+                session_state: Default::default(),
+            }
+        }
+        pub(super) fn position(&self) -> &text::Anchor {
+            &self.bp.position
+        }
+    }
+
+    #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
+    pub struct BreakpointSessionState {
+        /// Session-specific identifier for the breakpoint, as assigned by Debug Adapter.
+        pub id: u64,
+        pub verified: bool,
+    }
     #[derive(Clone)]
     pub(super) struct BreakpointsInFile {
         pub(super) buffer: Entity<Buffer>,
         // TODO: This is.. less than ideal, as it's O(n) and does not return entries in order. We'll have to change TreeMap to support passing in the context for comparisons
-        pub(super) breakpoints: Vec<(text::Anchor, Breakpoint)>,
+        pub(super) breakpoints: Vec<StatefulBreakpoint>,
         _subscription: Arc<Subscription>,
     }
 
@@ -185,7 +219,7 @@ impl BreakpointStore {
             })
             .ok()
             .flatten()
-            .ok_or_else(|| anyhow!("Invalid project path"))?
+            .context("Invalid project path")?
             .await?;
 
         breakpoints.update(&mut cx, move |this, cx| {
@@ -199,9 +233,26 @@ impl BreakpointStore {
                 .breakpoints
                 .into_iter()
                 .filter_map(|breakpoint| {
-                    let anchor = language::proto::deserialize_anchor(breakpoint.position.clone()?)?;
+                    let position =
+                        language::proto::deserialize_anchor(breakpoint.position.clone()?)?;
+                    let session_state = breakpoint
+                        .session_state
+                        .iter()
+                        .map(|(session_id, state)| {
+                            let state = BreakpointSessionState {
+                                id: state.id,
+                                verified: state.verified,
+                            };
+                            (SessionId::from_proto(*session_id), state)
+                        })
+                        .collect();
                     let breakpoint = Breakpoint::from_proto(breakpoint)?;
-                    Some((anchor, breakpoint))
+                    let bp = BreakpointWithPosition {
+                        position,
+                        bp: breakpoint,
+                    };
+
+                    Some(StatefulBreakpoint { bp, session_state })
                 })
                 .collect();
 
@@ -216,35 +267,38 @@ impl BreakpointStore {
         message: TypedEnvelope<proto::ToggleBreakpoint>,
         mut cx: AsyncApp,
     ) -> Result<proto::Ack> {
-        let breakpoints = this.update(&mut cx, |this, _| this.breakpoint_store())?;
+        let breakpoints = this.read_with(&mut cx, |this, _| this.breakpoint_store())?;
         let path = this
             .update(&mut cx, |this, cx| {
                 this.project_path_for_absolute_path(message.payload.path.as_ref(), cx)
             })?
-            .ok_or_else(|| anyhow!("Could not resolve provided abs path"))?;
+            .context("Could not resolve provided abs path")?;
         let buffer = this
             .update(&mut cx, |this, cx| {
-                this.buffer_store().read(cx).get_by_path(&path, cx)
+                this.buffer_store().read(cx).get_by_path(&path)
             })?
-            .ok_or_else(|| anyhow!("Could not find buffer for a given path"))?;
+            .context("Could not find buffer for a given path")?;
         let breakpoint = message
             .payload
             .breakpoint
-            .ok_or_else(|| anyhow!("Breakpoint not present in RPC payload"))?;
-        let anchor = language::proto::deserialize_anchor(
+            .context("Breakpoint not present in RPC payload")?;
+        let position = language::proto::deserialize_anchor(
             breakpoint
                 .position
                 .clone()
-                .ok_or_else(|| anyhow!("Anchor not present in RPC payload"))?,
+                .context("Anchor not present in RPC payload")?,
         )
-        .ok_or_else(|| anyhow!("Anchor deserialization failed"))?;
-        let breakpoint = Breakpoint::from_proto(breakpoint)
-            .ok_or_else(|| anyhow!("Could not deserialize breakpoint"))?;
+        .context("Anchor deserialization failed")?;
+        let breakpoint =
+            Breakpoint::from_proto(breakpoint).context("Could not deserialize breakpoint")?;
 
         breakpoints.update(&mut cx, |this, cx| {
             this.toggle_breakpoint(
                 buffer,
-                (anchor, breakpoint),
+                BreakpointWithPosition {
+                    position,
+                    bp: breakpoint,
+                },
                 BreakpointEditAction::Toggle,
                 cx,
             );
@@ -261,13 +315,76 @@ impl BreakpointStore {
                     breakpoints: breakpoint_set
                         .breakpoints
                         .iter()
-                        .filter_map(|(anchor, bp)| bp.to_proto(&path, anchor))
+                        .filter_map(|breakpoint| {
+                            breakpoint.bp.bp.to_proto(
+                                &path,
+                                &breakpoint.position(),
+                                &breakpoint.session_state,
+                            )
+                        })
                         .collect(),
                 });
             }
         }
     }
 
+    pub(crate) fn update_session_breakpoint(
+        &mut self,
+        session_id: SessionId,
+        _: dap::BreakpointEventReason,
+        breakpoint: dap::Breakpoint,
+    ) {
+        maybe!({
+            let event_id = breakpoint.id?;
+
+            let state = self
+                .breakpoints
+                .values_mut()
+                .find_map(|breakpoints_in_file| {
+                    breakpoints_in_file
+                        .breakpoints
+                        .iter_mut()
+                        .find_map(|state| {
+                            let state = state.session_state.get_mut(&session_id)?;
+
+                            if state.id == event_id {
+                                Some(state)
+                            } else {
+                                None
+                            }
+                        })
+                })?;
+
+            state.verified = breakpoint.verified;
+            Some(())
+        });
+    }
+
+    pub(super) fn mark_breakpoints_verified(
+        &mut self,
+        session_id: SessionId,
+        abs_path: &Path,
+
+        it: impl Iterator<Item = (BreakpointWithPosition, BreakpointSessionState)>,
+    ) {
+        maybe!({
+            let breakpoints = self.breakpoints.get_mut(abs_path)?;
+            for (breakpoint, state) in it {
+                if let Some(to_update) = breakpoints
+                    .breakpoints
+                    .iter_mut()
+                    .find(|bp| *bp.position() == breakpoint.position)
+                {
+                    to_update
+                        .session_state
+                        .entry(session_id)
+                        .insert_entry(state);
+                }
+            }
+            Some(())
+        });
+    }
+
     pub fn abs_path_from_buffer(buffer: &Entity<Buffer>, cx: &App) -> Option<Arc<Path>> {
         worktree::File::from_dyn(buffer.read(cx).file())
             .and_then(|file| file.worktree.read(cx).absolutize(&file.path).ok())
@@ -277,7 +394,7 @@ impl BreakpointStore {
     pub fn toggle_breakpoint(
         &mut self,
         buffer: Entity<Buffer>,
-        mut breakpoint: (text::Anchor, Breakpoint),
+        mut breakpoint: BreakpointWithPosition,
         edit_action: BreakpointEditAction,
         cx: &mut Context<Self>,
     ) {
@@ -295,54 +412,57 @@ impl BreakpointStore {
                 let len_before = breakpoint_set.breakpoints.len();
                 breakpoint_set
                     .breakpoints
-                    .retain(|value| &breakpoint != value);
+                    .retain(|value| breakpoint != value.bp);
                 if len_before == breakpoint_set.breakpoints.len() {
                     // We did not remove any breakpoint, hence let's toggle one.
-                    breakpoint_set.breakpoints.push(breakpoint.clone());
+                    breakpoint_set
+                        .breakpoints
+                        .push(StatefulBreakpoint::new(breakpoint.clone()));
                 }
             }
             BreakpointEditAction::InvertState => {
-                if let Some((_, bp)) = breakpoint_set
+                if let Some(bp) = breakpoint_set
                     .breakpoints
                     .iter_mut()
-                    .find(|value| breakpoint == **value)
+                    .find(|value| breakpoint == value.bp)
                 {
+                    let bp = &mut bp.bp.bp;
                     if bp.is_enabled() {
                         bp.state = BreakpointState::Disabled;
                     } else {
                         bp.state = BreakpointState::Enabled;
                     }
                 } else {
-                    breakpoint.1.state = BreakpointState::Disabled;
-                    breakpoint_set.breakpoints.push(breakpoint.clone());
+                    breakpoint.bp.state = BreakpointState::Disabled;
+                    breakpoint_set
+                        .breakpoints
+                        .push(StatefulBreakpoint::new(breakpoint.clone()));
                 }
             }
             BreakpointEditAction::EditLogMessage(log_message) => {
                 if !log_message.is_empty() {
-                    let found_bp =
-                        breakpoint_set
-                            .breakpoints
-                            .iter_mut()
-                            .find_map(|(other_pos, other_bp)| {
-                                if breakpoint.0 == *other_pos {
-                                    Some(other_bp)
-                                } else {
-                                    None
-                                }
-                            });
+                    let found_bp = breakpoint_set.breakpoints.iter_mut().find_map(|bp| {
+                        if breakpoint.position == *bp.position() {
+                            Some(&mut bp.bp.bp)
+                        } else {
+                            None
+                        }
+                    });
 
                     if let Some(found_bp) = found_bp {
                         found_bp.message = Some(log_message.clone());
                     } else {
-                        breakpoint.1.message = Some(log_message.clone());
+                        breakpoint.bp.message = Some(log_message.clone());
                         // We did not remove any breakpoint, hence let's toggle one.
-                        breakpoint_set.breakpoints.push(breakpoint.clone());
+                        breakpoint_set
+                            .breakpoints
+                            .push(StatefulBreakpoint::new(breakpoint.clone()));
                     }
-                } else if breakpoint.1.message.is_some() {
+                } else if breakpoint.bp.message.is_some() {
                     if let Some(position) = breakpoint_set
                         .breakpoints
                         .iter()
-                        .find_position(|(pos, bp)| &breakpoint.0 == pos && bp == &breakpoint.1)
+                        .find_position(|other| breakpoint == other.bp)
                         .map(|res| res.0)
                     {
                         breakpoint_set.breakpoints.remove(position);
@@ -353,30 +473,28 @@ impl BreakpointStore {
             }
             BreakpointEditAction::EditHitCondition(hit_condition) => {
                 if !hit_condition.is_empty() {
-                    let found_bp =
-                        breakpoint_set
-                            .breakpoints
-                            .iter_mut()
-                            .find_map(|(other_pos, other_bp)| {
-                                if breakpoint.0 == *other_pos {
-                                    Some(other_bp)
-                                } else {
-                                    None
-                                }
-                            });
+                    let found_bp = breakpoint_set.breakpoints.iter_mut().find_map(|other| {
+                        if breakpoint.position == *other.position() {
+                            Some(&mut other.bp.bp)
+                        } else {
+                            None
+                        }
+                    });
 
                     if let Some(found_bp) = found_bp {
                         found_bp.hit_condition = Some(hit_condition.clone());
                     } else {
-                        breakpoint.1.hit_condition = Some(hit_condition.clone());
+                        breakpoint.bp.hit_condition = Some(hit_condition.clone());
                         // We did not remove any breakpoint, hence let's toggle one.
-                        breakpoint_set.breakpoints.push(breakpoint.clone());
+                        breakpoint_set
+                            .breakpoints
+                            .push(StatefulBreakpoint::new(breakpoint.clone()))
                     }
-                } else if breakpoint.1.hit_condition.is_some() {
+                } else if breakpoint.bp.hit_condition.is_some() {
                     if let Some(position) = breakpoint_set
                         .breakpoints
                         .iter()
-                        .find_position(|(pos, bp)| &breakpoint.0 == pos && bp == &breakpoint.1)
+                        .find_position(|bp| breakpoint == bp.bp)
                         .map(|res| res.0)
                     {
                         breakpoint_set.breakpoints.remove(position);
@@ -387,30 +505,28 @@ impl BreakpointStore {
             }
             BreakpointEditAction::EditCondition(condition) => {
                 if !condition.is_empty() {
-                    let found_bp =
-                        breakpoint_set
-                            .breakpoints
-                            .iter_mut()
-                            .find_map(|(other_pos, other_bp)| {
-                                if breakpoint.0 == *other_pos {
-                                    Some(other_bp)
-                                } else {
-                                    None
-                                }
-                            });
+                    let found_bp = breakpoint_set.breakpoints.iter_mut().find_map(|other| {
+                        if breakpoint.position == *other.position() {
+                            Some(&mut other.bp.bp)
+                        } else {
+                            None
+                        }
+                    });
 
                     if let Some(found_bp) = found_bp {
                         found_bp.condition = Some(condition.clone());
                     } else {
-                        breakpoint.1.condition = Some(condition.clone());
+                        breakpoint.bp.condition = Some(condition.clone());
                         // We did not remove any breakpoint, hence let's toggle one.
-                        breakpoint_set.breakpoints.push(breakpoint.clone());
+                        breakpoint_set
+                            .breakpoints
+                            .push(StatefulBreakpoint::new(breakpoint.clone()));
                     }
-                } else if breakpoint.1.condition.is_some() {
+                } else if breakpoint.bp.condition.is_some() {
                     if let Some(position) = breakpoint_set
                         .breakpoints
                         .iter()
-                        .find_position(|(pos, bp)| &breakpoint.0 == pos && bp == &breakpoint.1)
+                        .find_position(|bp| breakpoint == bp.bp)
                         .map(|res| res.0)
                     {
                         breakpoint_set.breakpoints.remove(position);
@@ -425,7 +541,11 @@ impl BreakpointStore {
             self.breakpoints.remove(&abs_path);
         }
         if let BreakpointStoreMode::Remote(remote) = &self.mode {
-            if let Some(breakpoint) = breakpoint.1.to_proto(&abs_path, &breakpoint.0) {
+            if let Some(breakpoint) =
+                breakpoint
+                    .bp
+                    .to_proto(&abs_path, &breakpoint.position, &HashMap::default())
+            {
                 cx.background_spawn(remote.upstream_client.request(proto::ToggleBreakpoint {
                     project_id: remote._upstream_project_id,
                     path: abs_path.to_str().map(ToOwned::to_owned).unwrap(),
@@ -441,7 +561,11 @@ impl BreakpointStore {
                     breakpoint_set
                         .breakpoints
                         .iter()
-                        .filter_map(|(anchor, bp)| bp.to_proto(&abs_path, anchor))
+                        .filter_map(|bp| {
+                            bp.bp
+                                .bp
+                                .to_proto(&abs_path, bp.position(), &bp.session_state)
+                        })
                         .collect()
                 })
                 .unwrap_or_default();
@@ -485,21 +609,31 @@ impl BreakpointStore {
         range: Option<Range<text::Anchor>>,
         buffer_snapshot: &'a BufferSnapshot,
         cx: &App,
-    ) -> impl Iterator<Item = &'a (text::Anchor, Breakpoint)> + 'a {
+    ) -> impl Iterator<Item = (&'a BreakpointWithPosition, Option<BreakpointSessionState>)> + 'a
+    {
         let abs_path = Self::abs_path_from_buffer(buffer, cx);
+        let active_session_id = self
+            .active_stack_frame
+            .as_ref()
+            .map(|frame| frame.session_id);
         abs_path
             .and_then(|path| self.breakpoints.get(&path))
             .into_iter()
             .flat_map(move |file_breakpoints| {
-                file_breakpoints.breakpoints.iter().filter({
+                file_breakpoints.breakpoints.iter().filter_map({
                     let range = range.clone();
-                    move |(position, _)| {
+                    move |bp| {
                         if let Some(range) = &range {
-                            position.cmp(&range.start, buffer_snapshot).is_ge()
-                                && position.cmp(&range.end, buffer_snapshot).is_le()
-                        } else {
-                            true
+                            if bp.position().cmp(&range.start, buffer_snapshot).is_lt()
+                                || bp.position().cmp(&range.end, buffer_snapshot).is_gt()
+                            {
+                                return None;
+                            }
                         }
+                        let session_state = active_session_id
+                            .and_then(|id| bp.session_state.get(&id))
+                            .copied();
+                        Some((&bp.bp, session_state))
                     }
                 })
             })
@@ -531,6 +665,7 @@ impl BreakpointStore {
             .as_ref()
             .is_some_and(|active_position| active_position == &position)
         {
+            cx.emit(BreakpointStoreEvent::SetDebugLine);
             return;
         }
 
@@ -549,34 +684,46 @@ impl BreakpointStore {
         path: &Path,
         row: u32,
         cx: &App,
-    ) -> Option<(Entity<Buffer>, (text::Anchor, Breakpoint))> {
+    ) -> Option<(Entity<Buffer>, BreakpointWithPosition)> {
         self.breakpoints.get(path).and_then(|breakpoints| {
             let snapshot = breakpoints.buffer.read(cx).text_snapshot();
 
             breakpoints
                 .breakpoints
                 .iter()
-                .find(|(anchor, _)| anchor.summary::<Point>(&snapshot).row == row)
-                .map(|breakpoint| (breakpoints.buffer.clone(), breakpoint.clone()))
+                .find(|bp| bp.position().summary::<Point>(&snapshot).row == row)
+                .map(|breakpoint| (breakpoints.buffer.clone(), breakpoint.bp.clone()))
         })
     }
 
-    pub fn breakpoints_from_path(&self, path: &Arc<Path>, cx: &App) -> Vec<SourceBreakpoint> {
+    pub fn breakpoints_from_path(&self, path: &Arc<Path>) -> Vec<BreakpointWithPosition> {
+        self.breakpoints
+            .get(path)
+            .map(|bp| bp.breakpoints.iter().map(|bp| bp.bp.clone()).collect())
+            .unwrap_or_default()
+    }
+
+    pub fn source_breakpoints_from_path(
+        &self,
+        path: &Arc<Path>,
+        cx: &App,
+    ) -> Vec<SourceBreakpoint> {
         self.breakpoints
             .get(path)
             .map(|bp| {
                 let snapshot = bp.buffer.read(cx).snapshot();
                 bp.breakpoints
                     .iter()
-                    .map(|(position, breakpoint)| {
-                        let position = snapshot.summary_for_anchor::<PointUtf16>(position).row;
+                    .map(|bp| {
+                        let position = snapshot.summary_for_anchor::<PointUtf16>(bp.position()).row;
+                        let bp = &bp.bp;
                         SourceBreakpoint {
                             row: position,
                             path: path.clone(),
-                            state: breakpoint.state,
-                            message: breakpoint.message.clone(),
-                            condition: breakpoint.condition.clone(),
-                            hit_condition: breakpoint.hit_condition.clone(),
+                            state: bp.bp.state,
+                            message: bp.bp.message.clone(),
+                            condition: bp.bp.condition.clone(),
+                            hit_condition: bp.bp.hit_condition.clone(),
                         }
                     })
                     .collect()
@@ -584,7 +731,18 @@ impl BreakpointStore {
             .unwrap_or_default()
     }
 
-    pub fn all_breakpoints(&self, cx: &App) -> BTreeMap<Arc<Path>, Vec<SourceBreakpoint>> {
+    pub fn all_breakpoints(&self) -> BTreeMap<Arc<Path>, Vec<BreakpointWithPosition>> {
+        self.breakpoints
+            .iter()
+            .map(|(path, bp)| {
+                (
+                    path.clone(),
+                    bp.breakpoints.iter().map(|bp| bp.bp.clone()).collect(),
+                )
+            })
+            .collect()
+    }
+    pub fn all_source_breakpoints(&self, cx: &App) -> BTreeMap<Arc<Path>, Vec<SourceBreakpoint>> {
         self.breakpoints
             .iter()
             .map(|(path, bp)| {
@@ -593,15 +751,18 @@ impl BreakpointStore {
                     path.clone(),
                     bp.breakpoints
                         .iter()
-                        .map(|(position, breakpoint)| {
-                            let position = snapshot.summary_for_anchor::<PointUtf16>(position).row;
+                        .map(|breakpoint| {
+                            let position = snapshot
+                                .summary_for_anchor::<PointUtf16>(&breakpoint.position())
+                                .row;
+                            let breakpoint = &breakpoint.bp;
                             SourceBreakpoint {
                                 row: position,
                                 path: path.clone(),
-                                message: breakpoint.message.clone(),
-                                state: breakpoint.state,
-                                hit_condition: breakpoint.hit_condition.clone(),
-                                condition: breakpoint.condition.clone(),
+                                message: breakpoint.bp.message.clone(),
+                                state: breakpoint.bp.state,
+                                hit_condition: breakpoint.bp.hit_condition.clone(),
+                                condition: breakpoint.bp.condition.clone(),
                             }
                         })
                         .collect(),
@@ -643,7 +804,7 @@ impl BreakpointStore {
                         log::error!("Todo: Serialized breakpoints which do not have buffer (yet)");
                         continue;
                     };
-                    let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?;
+                    let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
 
                     let mut breakpoints_for_file =
                         this.update(cx, |_, cx| BreakpointsInFile::new(buffer, cx))?;
@@ -656,15 +817,17 @@ impl BreakpointStore {
                             continue;
                         }
                         let position = snapshot.anchor_after(point);
-                        breakpoints_for_file.breakpoints.push((
-                            position,
-                            Breakpoint {
-                                message: bp.message,
-                                state: bp.state,
-                                condition: bp.condition,
-                                hit_condition: bp.hit_condition,
-                            },
-                        ))
+                        breakpoints_for_file
+                            .breakpoints
+                            .push(StatefulBreakpoint::new(BreakpointWithPosition {
+                                position,
+                                bp: Breakpoint {
+                                    message: bp.message,
+                                    state: bp.state,
+                                    condition: bp.condition,
+                                    hit_condition: bp.hit_condition,
+                                },
+                            }))
                     }
                     new_breakpoints.insert(path, breakpoints_for_file);
                 }
@@ -678,7 +841,7 @@ impl BreakpointStore {
                         } else {
                             "breakpoint"
                         };
-                        log::info!("Deserialized {count} {breakpoint_str} at path: {path}");
+                        log::debug!("Deserialized {count} {breakpoint_str} at path: {path}");
                     }
 
                     this.breakpoints = new_breakpoints;
@@ -755,7 +918,7 @@ impl BreakpointState {
 pub struct Breakpoint {
     pub message: Option<BreakpointMessage>,
     /// How many times do we hit the breakpoint until we actually stop at it e.g. (2 = 2 times of the breakpoint action)
-    pub hit_condition: Option<BreakpointMessage>,
+    pub hit_condition: Option<Arc<str>>,
     pub condition: Option<BreakpointMessage>,
     pub state: BreakpointState,
 }
@@ -788,7 +951,12 @@ impl Breakpoint {
         }
     }
 
-    fn to_proto(&self, _path: &Path, position: &text::Anchor) -> Option<client::proto::Breakpoint> {
+    fn to_proto(
+        &self,
+        _path: &Path,
+        position: &text::Anchor,
+        session_states: &HashMap<SessionId, BreakpointSessionState>,
+    ) -> Option<client::proto::Breakpoint> {
         Some(client::proto::Breakpoint {
             position: Some(serialize_text_anchor(position)),
             state: match self.state {
@@ -801,6 +969,18 @@ impl Breakpoint {
                 .hit_condition
                 .as_ref()
                 .map(|s| String::from(s.as_ref())),
+            session_state: session_states
+                .iter()
+                .map(|(session_id, state)| {
+                    (
+                        session_id.to_proto(),
+                        proto::BreakpointSessionState {
+                            id: state.id,
+                            verified: state.verified,
+                        },
+                    )
+                })
+                .collect(),
         })
     }
 

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

@@ -1,6 +1,6 @@
 use std::sync::Arc;
 
-use anyhow::{Ok, Result, anyhow};
+use anyhow::{Context as _, Ok, Result};
 use dap::{
     Capabilities, ContinueArguments, ExceptionFilterOptions, InitializeRequestArguments,
     InitializeRequestArgumentsPathFormat, NextArguments, SetVariableResponse, SourceBreakpoint,
@@ -1547,7 +1547,7 @@ fn dap_client_capabilities(adapter_id: String) -> InitializeRequestArguments {
         supports_memory_event: Some(false),
         supports_args_can_be_interpreted_by_shell: Some(false),
         supports_start_debugging_request: Some(true),
-        supports_ansistyling: Some(false),
+        supports_ansistyling: Some(true),
     }
 }
 
@@ -1766,7 +1766,7 @@ impl DapCommand for LocationsCommand {
             source: response
                 .source
                 .map(<dap::Source as ProtoConversion>::from_proto)
-                .ok_or_else(|| anyhow!("Missing `source` field in Locations proto"))?,
+                .context("Missing `source` field in Locations proto")?,
             line: response.line,
             column: response.column,
             end_line: response.end_line,

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

@@ -10,13 +10,15 @@ use crate::{
     terminals::{SshCommand, wrap_for_ssh},
     worktree_store::WorktreeStore,
 };
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use async_trait::async_trait;
 use collections::HashMap;
 use dap::{
     Capabilities, CompletionItem, CompletionsArguments, DapRegistry, DebugRequest,
     EvaluateArguments, EvaluateArgumentsContext, EvaluateResponse, Source, StackFrameId,
-    adapters::{DebugAdapterBinary, DebugAdapterName, DebugTaskDefinition, TcpArguments},
+    adapters::{
+        DapDelegate, DebugAdapterBinary, DebugAdapterName, DebugTaskDefinition, TcpArguments,
+    },
     client::SessionId,
     inline_value::VariableLookupKind,
     messages::Message,
@@ -38,7 +40,7 @@ use rpc::{
     AnyProtoClient, TypedEnvelope,
     proto::{self},
 };
-use settings::{Settings, WorktreeId};
+use settings::{Settings, SettingsLocation, WorktreeId};
 use std::{
     borrow::Borrow,
     collections::BTreeMap,
@@ -47,8 +49,8 @@ use std::{
     path::{Path, PathBuf},
     sync::{Arc, Once},
 };
-use task::{DebugScenario, SpawnInTerminal, TaskTemplate};
-use util::{ResultExt as _, merge_json_value_into};
+use task::{DebugScenario, SpawnInTerminal, TaskContext, TaskTemplate};
+use util::ResultExt as _;
 use worktree::Worktree;
 
 #[derive(Debug)]
@@ -64,7 +66,6 @@ pub enum DapStoreEvent {
     RemoteHasInitialized,
 }
 
-#[allow(clippy::large_enum_variant)]
 enum DapStoreMode {
     Local(LocalDapStore),
     Ssh(SshDapStore),
@@ -100,7 +101,11 @@ impl DapStore {
     pub fn init(client: &AnyProtoClient, cx: &mut App) {
         static ADD_LOCATORS: Once = Once::new();
         ADD_LOCATORS.call_once(|| {
-            DapRegistry::global(cx).add_locator(Arc::new(locators::cargo::CargoLocator {}))
+            let registry = DapRegistry::global(cx);
+            registry.add_locator(Arc::new(locators::cargo::CargoLocator {}));
+            registry.add_locator(Arc::new(locators::go::GoLocator {}));
+            registry.add_locator(Arc::new(locators::node::NodeLocator));
+            registry.add_locator(Arc::new(locators::python::PythonLocator));
         });
         client.add_entity_request_handler(Self::handle_run_debug_locator);
         client.add_entity_request_handler(Self::handle_get_debug_adapter_binary);
@@ -175,33 +180,33 @@ impl DapStore {
         &mut self,
         definition: DebugTaskDefinition,
         session_id: SessionId,
+        worktree: &Entity<Worktree>,
         console: UnboundedSender<String>,
         cx: &mut Context<Self>,
     ) -> Task<Result<DebugAdapterBinary>> {
         match &self.mode {
             DapStoreMode::Local(_) => {
-                let Some(worktree) = self.worktree_store.read(cx).visible_worktrees(cx).next()
-                else {
-                    return Task::ready(Err(anyhow!("Failed to find a worktree")));
-                };
                 let Some(adapter) = DapRegistry::global(cx).adapter(&definition.adapter) else {
                     return Task::ready(Err(anyhow!("Failed to find a debug adapter")));
                 };
 
-                let user_installed_path = ProjectSettings::get_global(cx)
+                let settings_location = SettingsLocation {
+                    worktree_id: worktree.read(cx).id(),
+                    path: Path::new(""),
+                };
+                let dap_settings = ProjectSettings::get(Some(settings_location), cx)
                     .dap
-                    .get(&adapter.name())
-                    .and_then(|s| s.binary.as_ref().map(PathBuf::from));
+                    .get(&adapter.name());
+                let user_installed_path =
+                    dap_settings.and_then(|s| s.binary.as_ref().map(PathBuf::from));
+                let user_args = dap_settings.map(|s| s.args.clone());
 
                 let delegate = self.delegate(&worktree, console, cx);
-                let cwd: Arc<Path> = definition
-                    .cwd()
-                    .unwrap_or(worktree.read(cx).abs_path().as_ref())
-                    .into();
+                let cwd: Arc<Path> = worktree.read(cx).abs_path().as_ref().into();
 
                 cx.spawn(async move |this, cx| {
                     let mut binary = adapter
-                        .get_binary(&delegate, &definition, user_installed_path, cx)
+                        .get_binary(&delegate, &definition, user_installed_path, user_args, cx)
                         .await?;
 
                     let env = this
@@ -227,6 +232,7 @@ impl DapStore {
                 let request = ssh.upstream_client.request(proto::GetDebugAdapterBinary {
                     session_id: session_id.to_proto(),
                     project_id: ssh.upstream_project_id,
+                    worktree_id: worktree.read(cx).id().to_proto(),
                     definition: Some(definition.to_proto()),
                 });
                 let ssh_client = ssh.ssh_client.clone();
@@ -234,23 +240,21 @@ impl DapStore {
                 cx.spawn(async move |_, cx| {
                     let response = request.await?;
                     let binary = DebugAdapterBinary::from_proto(response)?;
-                    let mut ssh_command = ssh_client.update(cx, |ssh, _| {
+                    let mut ssh_command = ssh_client.read_with(cx, |ssh, _| {
                         anyhow::Ok(SshCommand {
-                            arguments: ssh
-                                .ssh_args()
-                                .ok_or_else(|| anyhow!("SSH arguments not found"))?,
+                            arguments: ssh.ssh_args().context("SSH arguments not found")?,
                         })
                     })??;
 
                     let mut connection = None;
                     if let Some(c) = binary.connection {
-                        let local_bind_addr = Ipv4Addr::new(127, 0, 0, 1);
+                        let local_bind_addr = Ipv4Addr::LOCALHOST;
                         let port =
                             dap::transport::TcpTransport::unused_port(local_bind_addr).await?;
 
                         ssh_command.add_port_forwarding(port, c.host.to_string(), c.port);
                         connection = Some(TcpArguments {
-                            port: c.port,
+                            port,
                             host: local_bind_addr,
                             timeout: c.timeout,
                         })
@@ -258,14 +262,17 @@ impl DapStore {
 
                     let (program, args) = wrap_for_ssh(
                         &ssh_command,
-                        Some((&binary.command, &binary.arguments)),
+                        binary
+                            .command
+                            .as_ref()
+                            .map(|command| (command, &binary.arguments)),
                         binary.cwd.as_deref(),
                         binary.envs,
                         None,
                     );
 
                     Ok(DebugAdapterBinary {
-                        command: program,
+                        command: Some(program),
                         arguments: args,
                         envs: HashMap::default(),
                         cwd: None,
@@ -286,11 +293,17 @@ impl DapStore {
         adapter: DebugAdapterName,
         label: SharedString,
         cx: &mut App,
-    ) -> Option<DebugScenario> {
-        DapRegistry::global(cx)
-            .locators()
-            .values()
-            .find_map(|locator| locator.create_scenario(&build, &label, adapter.clone()))
+    ) -> Task<Option<DebugScenario>> {
+        let locators = DapRegistry::global(cx).locators();
+
+        cx.background_spawn(async move {
+            for locator in locators.values() {
+                if let Some(scenario) = locator.create_scenario(&build, &label, &adapter).await {
+                    return Some(scenario);
+                }
+            }
+            None
+        })
     }
 
     pub fn run_debug_locator(
@@ -315,10 +328,10 @@ impl DapStore {
                             return Ok(result);
                         }
 
-                        Err(anyhow!(
+                        anyhow::bail!(
                             "None of the locators for task `{}` completed successfully",
                             build_command.label
-                        ))
+                        )
                     })
                 } else {
                     Task::ready(Err(anyhow!(
@@ -355,6 +368,7 @@ impl DapStore {
         &mut self,
         label: SharedString,
         adapter: DebugAdapterName,
+        task_context: TaskContext,
         parent_session: Option<Entity<Session>>,
         cx: &mut Context<Self>,
     ) -> Entity<Session> {
@@ -372,6 +386,7 @@ impl DapStore {
             parent_session,
             label,
             adapter,
+            task_context,
             cx,
         );
 
@@ -398,12 +413,9 @@ impl DapStore {
         &self,
         session: Entity<Session>,
         definition: DebugTaskDefinition,
+        worktree: Entity<Worktree>,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        let Some(worktree) = self.worktree_store.read(cx).visible_worktrees(cx).next() else {
-            return Task::ready(Err(anyhow!("Failed to find a worktree")));
-        };
-
         let dap_store = cx.weak_entity();
         let console = session.update(cx, |session, cx| session.console_output(cx));
         let session_id = session.read(cx).session_id();
@@ -411,16 +423,17 @@ impl DapStore {
         cx.spawn({
             let session = session.clone();
             async move |this, cx| {
-                let mut binary = this
+                let binary = this
                     .update(cx, |this, cx| {
-                        this.get_debug_adapter_binary(definition.clone(), session_id, console, cx)
+                        this.get_debug_adapter_binary(
+                            definition.clone(),
+                            session_id,
+                            &worktree,
+                            console,
+                            cx,
+                        )
                     })?
                     .await?;
-
-                if let Some(args) = definition.initialize_args {
-                    merge_json_value_into(args, &mut binary.request_args.configuration);
-                }
-
                 session
                     .update(cx, |session, cx| {
                         session.boot(binary, worktree, dap_store, cx)
@@ -489,14 +502,14 @@ impl DapStore {
         worktree: &Entity<Worktree>,
         console: UnboundedSender<String>,
         cx: &mut App,
-    ) -> DapAdapterDelegate {
+    ) -> Arc<dyn DapDelegate> {
         let Some(local_store) = self.as_local() else {
             unimplemented!("Starting session on remote side");
         };
 
-        DapAdapterDelegate::new(
+        Arc::new(DapAdapterDelegate::new(
             local_store.fs.clone(),
-            worktree.read(cx).id(),
+            worktree.read(cx).snapshot(),
             console,
             local_store.node_runtime.clone(),
             local_store.http_client.clone(),
@@ -504,7 +517,7 @@ impl DapStore {
             local_store.environment.update(cx, |env, cx| {
                 env.get_worktree_environment(worktree.clone(), cx)
             }),
-        )
+        ))
     }
 
     pub fn evaluate(
@@ -575,7 +588,30 @@ impl DapStore {
         cx: &mut Context<Self>,
     ) -> Task<Result<Vec<InlayHint>>> {
         let snapshot = buffer_handle.read(cx).snapshot();
-        let all_variables = session.read(cx).variables_by_stack_frame_id(stack_frame_id);
+        let local_variables =
+            session
+                .read(cx)
+                .variables_by_stack_frame_id(stack_frame_id, false, true);
+        let global_variables =
+            session
+                .read(cx)
+                .variables_by_stack_frame_id(stack_frame_id, true, false);
+
+        fn format_value(mut value: String) -> String {
+            const LIMIT: usize = 100;
+
+            if value.len() > LIMIT {
+                let mut index = LIMIT;
+                // If index isn't a char boundary truncate will cause a panic
+                while !value.is_char_boundary(index) {
+                    index -= 1;
+                }
+                value.truncate(index);
+                value.push_str("...");
+            }
+
+            format!(": {}", value)
+        }
 
         cx.spawn(async move |_, cx| {
             let mut inlay_hints = Vec::with_capacity(inline_value_locations.len());
@@ -588,16 +624,26 @@ impl DapStore {
 
                 match inline_value_location.lookup {
                     VariableLookupKind::Variable => {
-                        let Some(variable) = all_variables
-                            .iter()
-                            .find(|variable| variable.name == inline_value_location.variable_name)
-                        else {
+                        let variable_search =
+                            if inline_value_location.scope
+                                == dap::inline_value::VariableScope::Local
+                            {
+                                local_variables.iter().chain(global_variables.iter()).find(
+                                    |variable| variable.name == inline_value_location.variable_name,
+                                )
+                            } else {
+                                global_variables.iter().find(|variable| {
+                                    variable.name == inline_value_location.variable_name
+                                })
+                            };
+
+                        let Some(variable) = variable_search else {
                             continue;
                         };
 
                         inlay_hints.push(InlayHint {
                             position,
-                            label: InlayHintLabel::String(format!(": {}", variable.value)),
+                            label: InlayHintLabel::String(format_value(variable.value.clone())),
                             kind: Some(InlayHintKind::Type),
                             padding_left: false,
                             padding_right: false,
@@ -606,7 +652,7 @@ impl DapStore {
                         });
                     }
                     VariableLookupKind::Expression => {
-                        let Ok(eval_task) = session.update(cx, |session, _| {
+                        let Ok(eval_task) = session.read_with(cx, |session, _| {
                             session.mode.request_dap(EvaluateCommand {
                                 expression: inline_value_location.variable_name.clone(),
                                 frame_id: Some(stack_frame_id),
@@ -620,7 +666,7 @@ impl DapStore {
                         if let Some(response) = eval_task.await.log_err() {
                             inlay_hints.push(InlayHint {
                                 position,
-                                label: InlayHintLabel::String(format!(": {}", response.result)),
+                                label: InlayHintLabel::String(format_value(response.result)),
                                 kind: Some(InlayHintKind::Type),
                                 padding_left: false,
                                 padding_right: false,
@@ -685,6 +731,8 @@ impl DapStore {
 
         let shutdown_task = session.update(cx, |this, cx| this.shutdown(cx));
 
+        cx.emit(DapStoreEvent::DebugClientShutdown(session_id));
+
         cx.background_spawn(async move {
             if shutdown_children.len() > 0 {
                 let _ = join_all(shutdown_children).await;
@@ -723,7 +771,7 @@ impl DapStore {
         let task = envelope
             .payload
             .build_command
-            .ok_or_else(|| anyhow!("missing definition"))?;
+            .context("missing definition")?;
         let build_task = SpawnInTerminal::from_proto(task);
         let locator = envelope.payload.locator;
         let request = this
@@ -741,10 +789,7 @@ impl DapStore {
         mut cx: AsyncApp,
     ) -> Result<proto::DebugAdapterBinary> {
         let definition = DebugTaskDefinition::from_proto(
-            envelope
-                .payload
-                .definition
-                .ok_or_else(|| anyhow!("missing definition"))?,
+            envelope.payload.definition.context("missing definition")?,
         )?;
         let (tx, mut rx) = mpsc::unbounded();
         let session_id = envelope.payload.session_id;
@@ -752,7 +797,7 @@ impl DapStore {
             let this = this.clone();
             async move |cx| {
                 while let Some(message) = rx.next().await {
-                    this.update(cx, |this, _| {
+                    this.read_with(cx, |this, _| {
                         if let Some((downstream, project_id)) = this.downstream_client.clone() {
                             downstream
                                 .send(proto::LogToDebugConsole {
@@ -769,9 +814,22 @@ impl DapStore {
         })
         .detach();
 
+        let worktree = this
+            .update(&mut cx, |this, cx| {
+                this.worktree_store
+                    .read(cx)
+                    .worktree_for_id(WorktreeId::from_proto(envelope.payload.worktree_id), cx)
+            })?
+            .context("Failed to find worktree with a given ID")?;
         let binary = this
             .update(&mut cx, |this, cx| {
-                this.get_debug_adapter_binary(definition, SessionId::from_proto(session_id), tx, cx)
+                this.get_debug_adapter_binary(
+                    definition,
+                    SessionId::from_proto(session_id),
+                    &worktree,
+                    tx,
+                    cx,
+                )
             })?
             .await?;
         Ok(binary.to_proto())
@@ -801,7 +859,7 @@ impl DapStore {
 pub struct DapAdapterDelegate {
     fs: Arc<dyn Fs>,
     console: mpsc::UnboundedSender<String>,
-    worktree_id: WorktreeId,
+    worktree: worktree::Snapshot,
     node_runtime: NodeRuntime,
     http_client: Arc<dyn HttpClient>,
     toolchain_store: Arc<dyn LanguageToolchainStore>,
@@ -811,7 +869,7 @@ pub struct DapAdapterDelegate {
 impl DapAdapterDelegate {
     pub fn new(
         fs: Arc<dyn Fs>,
-        worktree_id: WorktreeId,
+        worktree: worktree::Snapshot,
         status: mpsc::UnboundedSender<String>,
         node_runtime: NodeRuntime,
         http_client: Arc<dyn HttpClient>,
@@ -821,7 +879,7 @@ impl DapAdapterDelegate {
         Self {
             fs,
             console: status,
-            worktree_id,
+            worktree,
             http_client,
             node_runtime,
             toolchain_store,
@@ -830,12 +888,15 @@ impl DapAdapterDelegate {
     }
 }
 
-#[async_trait(?Send)]
+#[async_trait]
 impl dap::adapters::DapDelegate for DapAdapterDelegate {
     fn worktree_id(&self) -> WorktreeId {
-        self.worktree_id
+        self.worktree.id()
     }
 
+    fn worktree_root_path(&self) -> &Path {
+        &self.worktree.abs_path()
+    }
     fn http_client(&self) -> Arc<dyn HttpClient> {
         self.http_client.clone()
     }
@@ -852,8 +913,10 @@ impl dap::adapters::DapDelegate for DapAdapterDelegate {
         self.console.unbounded_send(msg).ok();
     }
 
-    fn which(&self, command: &OsStr) -> Option<PathBuf> {
-        which::which(command).ok()
+    async fn which(&self, command: &OsStr) -> Option<PathBuf> {
+        let worktree_abs_path = self.worktree.abs_path();
+        let shell_path = self.shell_env().await.get("PATH").cloned();
+        which::which_in(command, shell_path.as_ref(), worktree_abs_path).ok()
     }
 
     async fn shell_env(&self) -> HashMap<String, String> {
@@ -864,4 +927,16 @@ impl dap::adapters::DapDelegate for DapAdapterDelegate {
     fn toolchain_store(&self) -> Arc<dyn LanguageToolchainStore> {
         self.toolchain_store.clone()
     }
+    async fn read_text_file(&self, path: PathBuf) -> Result<String> {
+        let entry = self
+            .worktree
+            .entry_for_path(&path)
+            .with_context(|| format!("no worktree entry for path {path:?}"))?;
+        let abs_path = self
+            .worktree
+            .absolutize(&entry.path)
+            .with_context(|| format!("cannot absolutize path {path:?}"))?;
+
+        self.fs.load(&abs_path).await
+    }
 }

crates/project/src/debugger/locators/cargo.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use async_trait::async_trait;
 use dap::{DapLocator, DebugRequest, adapters::DebugAdapterName};
 use gpui::SharedString;
@@ -41,26 +41,26 @@ impl DapLocator for CargoLocator {
     fn name(&self) -> SharedString {
         SharedString::new_static("rust-cargo-locator")
     }
-    fn create_scenario(
+    async fn create_scenario(
         &self,
         build_config: &TaskTemplate,
         resolved_label: &str,
-        adapter: DebugAdapterName,
+        adapter: &DebugAdapterName,
     ) -> Option<DebugScenario> {
         if build_config.command != "cargo" {
             return None;
         }
         let mut task_template = build_config.clone();
         let cargo_action = task_template.args.first_mut()?;
-        if cargo_action == "check" {
+        if cargo_action == "check" || cargo_action == "clean" {
             return None;
         }
 
         match cargo_action.as_ref() {
-            "run" => {
+            "run" | "r" => {
                 *cargo_action = "build".to_owned();
             }
-            "test" | "bench" => {
+            "test" | "t" | "bench" => {
                 let delimiter = task_template
                     .args
                     .iter()
@@ -75,27 +75,24 @@ impl DapLocator for CargoLocator {
             }
             _ => {}
         }
-        let label = format!("Debug `{resolved_label}`");
+
         Some(DebugScenario {
-            adapter: adapter.0,
-            label: SharedString::from(label),
+            adapter: adapter.0.clone(),
+            label: resolved_label.to_string().into(),
             build: Some(BuildTaskDefinition::Template {
                 task_template,
                 locator_name: Some(self.name()),
             }),
-            request: None,
-            initialize_args: None,
+            config: serde_json::Value::Null,
             tcp_connection: None,
-            stop_on_entry: None,
         })
     }
 
     async fn run(&self, build_config: SpawnInTerminal) -> Result<DebugRequest> {
-        let Some(cwd) = build_config.cwd.clone() else {
-            return Err(anyhow!(
-                "Couldn't get cwd from debug config which is needed for locators"
-            ));
-        };
+        let cwd = build_config
+            .cwd
+            .clone()
+            .context("Couldn't get cwd from debug config which is needed for locators")?;
         let builder = ShellBuilder::new(true, &build_config.shell).non_interactive();
         let (program, args) = builder.build(
             "cargo".into(),
@@ -120,9 +117,7 @@ impl DapLocator for CargoLocator {
         }
 
         let status = child.status().await?;
-        if !status.success() {
-            return Err(anyhow::anyhow!("Cargo command failed"));
-        }
+        anyhow::ensure!(status.success(), "Cargo command failed");
 
         let executables = output
             .lines()
@@ -134,10 +129,14 @@ impl DapLocator for CargoLocator {
                     .map(String::from)
             })
             .collect::<Vec<_>>();
-        if executables.is_empty() {
-            return Err(anyhow!("Couldn't get executable in cargo locator"));
-        };
-        let is_test = build_config.args.first().map_or(false, |arg| arg == "test");
+        anyhow::ensure!(
+            !executables.is_empty(),
+            "Couldn't get executable in cargo locator"
+        );
+        let is_test = build_config
+            .args
+            .first()
+            .map_or(false, |arg| arg == "test" || arg == "t");
 
         let mut test_name = None;
         if is_test {
@@ -162,20 +161,19 @@ impl DapLocator for CargoLocator {
         };
 
         let Some(executable) = executable.or_else(|| executables.first().cloned()) else {
-            return Err(anyhow!("Couldn't get executable in cargo locator"));
+            anyhow::bail!("Couldn't get executable in cargo locator");
         };
 
-        let args = test_name.into_iter().collect();
+        let mut args: Vec<_> = test_name.into_iter().collect();
+        if is_test {
+            args.push("--nocapture".to_owned());
+        }
 
         Ok(DebugRequest::Launch(task::LaunchRequest {
             program: executable,
-            cwd: build_config.cwd.clone(),
+            cwd: build_config.cwd,
             args,
-            env: build_config
-                .env
-                .iter()
-                .map(|(k, v)| (k.clone(), v.clone()))
-                .collect(),
+            env: build_config.env.into_iter().collect(),
         }))
     }
 }

crates/project/src/debugger/locators/go.rs 🔗

@@ -0,0 +1,428 @@
+use anyhow::Result;
+use async_trait::async_trait;
+use collections::HashMap;
+use dap::{DapLocator, DebugRequest, adapters::DebugAdapterName};
+use gpui::SharedString;
+use serde::{Deserialize, Serialize};
+use task::{DebugScenario, SpawnInTerminal, TaskTemplate};
+
+pub(crate) struct GoLocator;
+
+#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+struct DelveLaunchRequest {
+    request: String,
+    mode: String,
+    program: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    cwd: Option<String>,
+    args: Vec<String>,
+    build_flags: Vec<String>,
+    env: HashMap<String, String>,
+}
+
+fn is_debug_flag(arg: &str) -> Option<bool> {
+    let mut part = if let Some(suffix) = arg.strip_prefix("test.") {
+        suffix
+    } else {
+        arg
+    };
+    let mut might_have_arg = true;
+    if let Some(idx) = part.find('=') {
+        might_have_arg = false;
+        part = &part[..idx];
+    }
+    match part {
+        "benchmem" | "failfast" | "fullpath" | "fuzzworker" | "json" | "short" | "v"
+        | "paniconexit0" => Some(false),
+        "bench"
+        | "benchtime"
+        | "blockprofile"
+        | "blockprofilerate"
+        | "count"
+        | "coverprofile"
+        | "cpu"
+        | "cpuprofile"
+        | "fuzz"
+        | "fuzzcachedir"
+        | "fuzzminimizetime"
+        | "fuzztime"
+        | "gocoverdir"
+        | "list"
+        | "memprofile"
+        | "memprofilerate"
+        | "mutexprofile"
+        | "mutexprofilefraction"
+        | "outputdir"
+        | "parallel"
+        | "run"
+        | "shuffle"
+        | "skip"
+        | "testlogfile"
+        | "timeout"
+        | "trace" => Some(might_have_arg),
+        _ if arg.starts_with("test.") => Some(false),
+        _ => None,
+    }
+}
+
+fn is_build_flag(mut arg: &str) -> Option<bool> {
+    let mut might_have_arg = true;
+    if let Some(idx) = arg.find('=') {
+        might_have_arg = false;
+        arg = &arg[..idx];
+    }
+    match arg {
+        "a" | "n" | "race" | "msan" | "asan" | "cover" | "work" | "x" | "v" | "buildvcs"
+        | "json" | "linkshared" | "modcacherw" | "trimpath" => Some(false),
+
+        "p" | "covermode" | "coverpkg" | "asmflags" | "buildmode" | "compiler" | "gccgoflags"
+        | "gcflags" | "installsuffix" | "ldflags" | "mod" | "modfile" | "overlay" | "pgo"
+        | "pkgdir" | "tags" | "toolexec" => Some(might_have_arg),
+        _ => None,
+    }
+}
+
+#[async_trait]
+impl DapLocator for GoLocator {
+    fn name(&self) -> SharedString {
+        SharedString::new_static("go-debug-locator")
+    }
+
+    async fn create_scenario(
+        &self,
+        build_config: &TaskTemplate,
+        resolved_label: &str,
+        adapter: &DebugAdapterName,
+    ) -> Option<DebugScenario> {
+        if build_config.command != "go" {
+            return None;
+        }
+        let go_action = build_config.args.first()?;
+
+        match go_action.as_str() {
+            "test" => {
+                let mut program = ".".to_string();
+                let mut args = Vec::default();
+                let mut build_flags = Vec::default();
+
+                let mut all_args_are_test = false;
+                let mut next_arg_is_test = false;
+                let mut next_arg_is_build = false;
+                let mut seen_pkg = false;
+                let mut seen_v = false;
+
+                for arg in build_config.args.iter().skip(1) {
+                    if all_args_are_test || next_arg_is_test {
+                        // HACK: tasks assume that they are run in a shell context,
+                        // so the -run regex has escaped specials. Delve correctly
+                        // handles escaping, so we undo that here.
+                        if arg.starts_with("\\^") && arg.ends_with("\\$") {
+                            let mut arg = arg[1..arg.len() - 2].to_string();
+                            arg.push('$');
+                            args.push(arg);
+                        } else {
+                            args.push(arg.clone());
+                        }
+                        next_arg_is_test = false;
+                    } else if next_arg_is_build {
+                        build_flags.push(arg.clone());
+                        next_arg_is_build = false;
+                    } else if arg.starts_with('-') {
+                        let flag = arg.trim_start_matches('-');
+                        if flag == "args" {
+                            all_args_are_test = true;
+                        } else if let Some(has_arg) = is_debug_flag(flag) {
+                            if flag == "v" || flag == "test.v" {
+                                seen_v = true;
+                            }
+                            if flag.starts_with("test.") {
+                                args.push(arg.clone());
+                            } else {
+                                args.push(format!("-test.{flag}"))
+                            }
+                            next_arg_is_test = has_arg;
+                        } else if let Some(has_arg) = is_build_flag(flag) {
+                            build_flags.push(arg.clone());
+                            next_arg_is_build = has_arg;
+                        }
+                    } else if !seen_pkg {
+                        program = arg.clone();
+                        seen_pkg = true;
+                    } else {
+                        args.push(arg.clone());
+                    }
+                }
+                if !seen_v {
+                    args.push("-test.v".to_string());
+                }
+
+                let config: serde_json::Value = serde_json::to_value(DelveLaunchRequest {
+                    request: "launch".to_string(),
+                    mode: "test".to_string(),
+                    program,
+                    args: args,
+                    build_flags,
+                    cwd: build_config.cwd.clone(),
+                    env: build_config.env.clone(),
+                })
+                .unwrap();
+
+                Some(DebugScenario {
+                    label: resolved_label.to_string().into(),
+                    adapter: adapter.0.clone(),
+                    build: None,
+                    config: config,
+                    tcp_connection: None,
+                })
+            }
+            "run" => {
+                let mut next_arg_is_build = false;
+                let mut seen_pkg = false;
+
+                let mut program = ".".to_string();
+                let mut args = Vec::default();
+                let mut build_flags = Vec::default();
+
+                for arg in build_config.args.iter().skip(1) {
+                    if seen_pkg {
+                        args.push(arg.clone())
+                    } else if next_arg_is_build {
+                        build_flags.push(arg.clone());
+                        next_arg_is_build = false;
+                    } else if arg.starts_with("-") {
+                        if let Some(has_arg) = is_build_flag(arg.trim_start_matches("-")) {
+                            next_arg_is_build = has_arg;
+                        }
+                        build_flags.push(arg.clone())
+                    } else {
+                        program = arg.to_string();
+                        seen_pkg = true;
+                    }
+                }
+
+                let config: serde_json::Value = serde_json::to_value(DelveLaunchRequest {
+                    cwd: build_config.cwd.clone(),
+                    env: build_config.env.clone(),
+                    request: "launch".to_string(),
+                    mode: "debug".to_string(),
+                    program,
+                    args: args,
+                    build_flags,
+                })
+                .unwrap();
+
+                Some(DebugScenario {
+                    label: resolved_label.to_string().into(),
+                    adapter: adapter.0.clone(),
+                    build: None,
+                    config,
+                    tcp_connection: None,
+                })
+            }
+            _ => None,
+        }
+    }
+
+    async fn run(&self, _build_config: SpawnInTerminal) -> Result<DebugRequest> {
+        unreachable!()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::TestAppContext;
+    use task::{HideStrategy, RevealStrategy, RevealTarget, Shell, TaskTemplate};
+
+    #[gpui::test]
+    async fn test_create_scenario_for_go_build(_: &mut TestAppContext) {
+        let locator = GoLocator;
+        let task = TaskTemplate {
+            label: "go build".into(),
+            command: "go".into(),
+            args: vec!["build".into(), ".".into()],
+            env: Default::default(),
+            cwd: Some("${ZED_WORKTREE_ROOT}".into()),
+            use_new_terminal: false,
+            allow_concurrent_runs: false,
+            reveal: RevealStrategy::Always,
+            reveal_target: RevealTarget::Dock,
+            hide: HideStrategy::Never,
+            shell: Shell::System,
+            tags: vec![],
+            show_summary: true,
+            show_command: true,
+        };
+
+        let scenario = locator
+            .create_scenario(&task, "test label", &DebugAdapterName("Delve".into()))
+            .await;
+
+        assert!(scenario.is_none());
+    }
+
+    #[gpui::test]
+    async fn test_skip_non_go_commands_with_non_delve_adapter(_: &mut TestAppContext) {
+        let locator = GoLocator;
+        let task = TaskTemplate {
+            label: "cargo build".into(),
+            command: "cargo".into(),
+            args: vec!["build".into()],
+            env: Default::default(),
+            cwd: Some("${ZED_WORKTREE_ROOT}".into()),
+            use_new_terminal: false,
+            allow_concurrent_runs: false,
+            reveal: RevealStrategy::Always,
+            reveal_target: RevealTarget::Dock,
+            hide: HideStrategy::Never,
+            shell: Shell::System,
+            tags: vec![],
+            show_summary: true,
+            show_command: true,
+        };
+
+        let scenario = locator
+            .create_scenario(
+                &task,
+                "test label",
+                &DebugAdapterName("SomeOtherAdapter".into()),
+            )
+            .await;
+        assert!(scenario.is_none());
+
+        let scenario = locator
+            .create_scenario(&task, "test label", &DebugAdapterName("Delve".into()))
+            .await;
+        assert!(scenario.is_none());
+    }
+    #[gpui::test]
+    async fn test_go_locator_run(_: &mut TestAppContext) {
+        let locator = GoLocator;
+        let delve = DebugAdapterName("Delve".into());
+
+        let task = TaskTemplate {
+            label: "go run with flags".into(),
+            command: "go".into(),
+            args: vec![
+                "run".to_string(),
+                "-race".to_string(),
+                "-ldflags".to_string(),
+                "-X main.version=1.0".to_string(),
+                "./cmd/myapp".to_string(),
+                "--config".to_string(),
+                "production.yaml".to_string(),
+                "--verbose".to_string(),
+            ],
+            env: {
+                let mut env = HashMap::default();
+                env.insert("GO_ENV".to_string(), "production".to_string());
+                env
+            },
+            cwd: Some("/project/root".into()),
+            ..Default::default()
+        };
+
+        let scenario = locator
+            .create_scenario(&task, "test run label", &delve)
+            .await
+            .unwrap();
+
+        let config: DelveLaunchRequest = serde_json::from_value(scenario.config).unwrap();
+
+        assert_eq!(
+            config,
+            DelveLaunchRequest {
+                request: "launch".to_string(),
+                mode: "debug".to_string(),
+                program: "./cmd/myapp".to_string(),
+                build_flags: vec![
+                    "-race".to_string(),
+                    "-ldflags".to_string(),
+                    "-X main.version=1.0".to_string()
+                ],
+                args: vec![
+                    "--config".to_string(),
+                    "production.yaml".to_string(),
+                    "--verbose".to_string(),
+                ],
+                env: {
+                    let mut env = HashMap::default();
+                    env.insert("GO_ENV".to_string(), "production".to_string());
+                    env
+                },
+                cwd: Some("/project/root".to_string()),
+            }
+        );
+    }
+
+    #[gpui::test]
+    async fn test_go_locator_test(_: &mut TestAppContext) {
+        let locator = GoLocator;
+        let delve = DebugAdapterName("Delve".into());
+
+        // Test with tags and run flag
+        let task_with_tags = TaskTemplate {
+            label: "test".into(),
+            command: "go".into(),
+            args: vec![
+                "test".to_string(),
+                "-tags".to_string(),
+                "integration,unit".to_string(),
+                "-run".to_string(),
+                "Foo".to_string(),
+                ".".to_string(),
+            ],
+            ..Default::default()
+        };
+        let result = locator
+            .create_scenario(&task_with_tags, "", &delve)
+            .await
+            .unwrap();
+
+        let config: DelveLaunchRequest = serde_json::from_value(result.config).unwrap();
+
+        assert_eq!(
+            config,
+            DelveLaunchRequest {
+                request: "launch".to_string(),
+                mode: "test".to_string(),
+                program: ".".to_string(),
+                build_flags: vec!["-tags".to_string(), "integration,unit".to_string(),],
+                args: vec![
+                    "-test.run".to_string(),
+                    "Foo".to_string(),
+                    "-test.v".to_string()
+                ],
+                env: HashMap::default(),
+                cwd: None,
+            }
+        );
+    }
+
+    #[gpui::test]
+    async fn test_skip_unsupported_go_commands(_: &mut TestAppContext) {
+        let locator = GoLocator;
+        let task = TaskTemplate {
+            label: "go clean".into(),
+            command: "go".into(),
+            args: vec!["clean".into()],
+            env: Default::default(),
+            cwd: Some("${ZED_WORKTREE_ROOT}".into()),
+            use_new_terminal: false,
+            allow_concurrent_runs: false,
+            reveal: RevealStrategy::Always,
+            reveal_target: RevealTarget::Dock,
+            hide: HideStrategy::Never,
+            shell: Shell::System,
+            tags: vec![],
+            show_summary: true,
+            show_command: true,
+        };
+
+        let scenario = locator
+            .create_scenario(&task, "test label", &DebugAdapterName("Delve".into()))
+            .await;
+        assert!(scenario.is_none());
+    }
+}

crates/project/src/debugger/locators/node.rs 🔗

@@ -0,0 +1,62 @@
+use std::borrow::Cow;
+
+use anyhow::{Result, bail};
+use async_trait::async_trait;
+use dap::{DapLocator, DebugRequest, adapters::DebugAdapterName};
+use gpui::SharedString;
+
+use task::{DebugScenario, SpawnInTerminal, TaskTemplate, VariableName};
+
+pub(crate) struct NodeLocator;
+
+const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
+    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
+
+#[async_trait]
+impl DapLocator for NodeLocator {
+    fn name(&self) -> SharedString {
+        SharedString::new_static("Node")
+    }
+
+    /// Determines whether this locator can generate debug target for given task.
+    async fn create_scenario(
+        &self,
+        build_config: &TaskTemplate,
+        resolved_label: &str,
+        adapter: &DebugAdapterName,
+    ) -> Option<DebugScenario> {
+        if adapter.0.as_ref() != "JavaScript" {
+            return None;
+        }
+        if build_config.command != TYPESCRIPT_RUNNER_VARIABLE.template_value()
+            && build_config.command != "npm"
+            && build_config.command != "pnpm"
+            && build_config.command != "yarn"
+        {
+            return None;
+        }
+
+        let config = serde_json::json!({
+            "request": "launch",
+            "type": "pwa-node",
+            "args": build_config.args.clone(),
+            "cwd": build_config.cwd.clone(),
+            "runtimeExecutable": build_config.command.clone(),
+            "env": build_config.env.clone(),
+            "runtimeArgs": ["--inspect-brk"],
+            "console": "integratedTerminal",
+        });
+
+        Some(DebugScenario {
+            adapter: adapter.0.clone(),
+            label: resolved_label.to_string().into(),
+            build: None,
+            config,
+            tcp_connection: None,
+        })
+    }
+
+    async fn run(&self, _: SpawnInTerminal) -> Result<DebugRequest> {
+        bail!("JavaScript locator should not require DapLocator::run to be ran");
+    }
+}

crates/project/src/debugger/locators/python.rs 🔗

@@ -0,0 +1,106 @@
+use std::path::Path;
+
+use anyhow::{Result, bail};
+use async_trait::async_trait;
+use dap::{DapLocator, DebugRequest, adapters::DebugAdapterName};
+use gpui::SharedString;
+
+use task::{DebugScenario, SpawnInTerminal, TaskTemplate, VariableName};
+
+pub(crate) struct PythonLocator;
+
+#[async_trait]
+impl DapLocator for PythonLocator {
+    fn name(&self) -> SharedString {
+        SharedString::new_static("Python")
+    }
+
+    /// Determines whether this locator can generate debug target for given task.
+    async fn create_scenario(
+        &self,
+        build_config: &TaskTemplate,
+        resolved_label: &str,
+        adapter: &DebugAdapterName,
+    ) -> Option<DebugScenario> {
+        if adapter.0.as_ref() != "Debugpy" {
+            return None;
+        }
+        let valid_program = build_config.command.starts_with("$ZED_")
+            || Path::new(&build_config.command)
+                .file_name()
+                .map_or(false, |name| {
+                    name.to_str().is_some_and(|path| path.starts_with("python"))
+                });
+        if !valid_program || build_config.args.iter().any(|arg| arg == "-c") {
+            // We cannot debug selections.
+            return None;
+        }
+        let command = if build_config.command
+            == VariableName::Custom("PYTHON_ACTIVE_ZED_TOOLCHAIN".into()).template_value()
+        {
+            VariableName::Custom("PYTHON_ACTIVE_ZED_TOOLCHAIN_RAW".into()).template_value()
+        } else {
+            build_config.command.clone()
+        };
+        let module_specifier_position = build_config
+            .args
+            .iter()
+            .position(|arg| arg == "-m")
+            .map(|position| position + 1);
+        // Skip the -m and module name, get all that's after.
+        let mut rest_of_the_args = module_specifier_position
+            .and_then(|position| build_config.args.get(position..))
+            .into_iter()
+            .flatten()
+            .fuse();
+        let mod_name = rest_of_the_args.next();
+        let args = rest_of_the_args.collect::<Vec<_>>();
+
+        let program_position = mod_name
+            .is_none()
+            .then(|| {
+                build_config
+                    .args
+                    .iter()
+                    .position(|arg| *arg == "\"$ZED_FILE\"")
+            })
+            .flatten();
+        let args = if let Some(position) = program_position {
+            args.into_iter().skip(position).collect::<Vec<_>>()
+        } else {
+            args
+        };
+        if program_position.is_none() && mod_name.is_none() {
+            return None;
+        }
+        let mut config = serde_json::json!({
+            "request": "launch",
+            "python": command,
+            "args": args,
+            "cwd": build_config.cwd.clone()
+        });
+        if let Some(config_obj) = config.as_object_mut() {
+            if let Some(module) = mod_name {
+                config_obj.insert("module".to_string(), module.clone().into());
+            }
+            if let Some(program) = program_position {
+                config_obj.insert(
+                    "program".to_string(),
+                    build_config.args[program].clone().into(),
+                );
+            }
+        }
+
+        Some(DebugScenario {
+            adapter: adapter.0.clone(),
+            label: resolved_label.to_string().into(),
+            build: None,
+            config,
+            tcp_connection: None,
+        })
+    }
+
+    async fn run(&self, _: SpawnInTerminal) -> Result<DebugRequest> {
+        bail!("Python locator should not require DapLocator::run to be ran");
+    }
+}

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

@@ -1,3 +1,5 @@
+use crate::debugger::breakpoint_store::BreakpointSessionState;
+
 use super::breakpoint_store::{
     BreakpointStore, BreakpointStoreEvent, BreakpointUpdatedReason, SourceBreakpoint,
 };
@@ -10,8 +12,8 @@ use super::dap_command::{
     TerminateThreadsCommand, ThreadsCommand, VariablesCommand,
 };
 use super::dap_store::DapStore;
-use anyhow::{Result, anyhow};
-use collections::{HashMap, HashSet, IndexMap, IndexSet};
+use anyhow::{Context as _, Result, anyhow};
+use collections::{HashMap, HashSet, IndexMap};
 use dap::adapters::{DebugAdapterBinary, DebugAdapterName};
 use dap::messages::Response;
 use dap::requests::{Request, RunInTerminal, StartDebugging};
@@ -22,9 +24,12 @@ use dap::{
     messages::{Events, Message},
 };
 use dap::{
-    ExceptionBreakpointsFilter, ExceptionFilterOptions, OutputEventCategory,
-    RunInTerminalRequestArguments, StartDebuggingRequestArguments,
+    ExceptionBreakpointsFilter, ExceptionFilterOptions, OutputEvent, OutputEventCategory,
+    RunInTerminalRequestArguments, StackFramePresentationHint, StartDebuggingRequestArguments,
+    StartDebuggingRequestArgumentsRequest, VariablePresentationHint,
 };
+use futures::SinkExt;
+use futures::channel::mpsc::UnboundedSender;
 use futures::channel::{mpsc, oneshot};
 use futures::{FutureExt, future::Shared};
 use gpui::{
@@ -32,6 +37,7 @@ use gpui::{
     Task, WeakEntity,
 };
 
+use rpc::ErrorExt;
 use serde_json::Value;
 use smol::stream::StreamExt;
 use std::any::TypeId;
@@ -44,6 +50,7 @@ use std::{
     path::Path,
     sync::Arc,
 };
+use task::TaskContext;
 use text::{PointUtf16, ToPointUtf16};
 use util::ResultExt;
 use worktree::Worktree;
@@ -103,7 +110,8 @@ impl ThreadStatus {
 #[derive(Debug)]
 pub struct Thread {
     dap: dap::Thread,
-    stack_frame_ids: IndexSet<StackFrameId>,
+    stack_frames: Vec<StackFrame>,
+    stack_frames_error: Option<anyhow::Error>,
     _has_stopped: bool,
 }
 
@@ -111,24 +119,36 @@ impl From<dap::Thread> for Thread {
     fn from(dap: dap::Thread) -> Self {
         Self {
             dap,
-            stack_frame_ids: Default::default(),
+            stack_frames: Default::default(),
+            stack_frames_error: None,
             _has_stopped: false,
         }
     }
 }
 
+#[derive(Debug, Clone, PartialEq)]
+pub struct Watcher {
+    pub expression: SharedString,
+    pub value: SharedString,
+    pub variables_reference: u64,
+    pub presentation_hint: Option<VariablePresentationHint>,
+}
+
 pub enum Mode {
     Building,
-    Running(LocalMode),
+    Running(RunningMode),
 }
 
 #[derive(Clone)]
-pub struct LocalMode {
+pub struct RunningMode {
     client: Arc<DebugAdapterClient>,
     binary: DebugAdapterBinary,
     tmp_breakpoint: Option<SourceBreakpoint>,
     worktree: WeakEntity<Worktree>,
     executor: BackgroundExecutor,
+    is_started: bool,
+    has_ever_stopped: bool,
+    messages_tx: UnboundedSender<Message>,
 }
 
 fn client_source(abs_path: &Path) -> dap::Source {
@@ -146,39 +166,42 @@ fn client_source(abs_path: &Path) -> dap::Source {
     }
 }
 
-impl LocalMode {
+impl RunningMode {
     async fn new(
         session_id: SessionId,
         parent_session: Option<Entity<Session>>,
         worktree: WeakEntity<Worktree>,
         binary: DebugAdapterBinary,
         messages_tx: futures::channel::mpsc::UnboundedSender<Message>,
-        cx: AsyncApp,
+        cx: &mut AsyncApp,
     ) -> Result<Self> {
-        let message_handler = Box::new(move |message| {
-            messages_tx.unbounded_send(message).ok();
+        let message_handler = Box::new({
+            let messages_tx = messages_tx.clone();
+            move |message| {
+                messages_tx.unbounded_send(message).ok();
+            }
         });
 
-        let client = Arc::new(
-            if let Some(client) = parent_session
-                .and_then(|session| cx.update(|cx| session.read(cx).adapter_client()).ok())
-                .flatten()
-            {
-                client
-                    .reconnect(session_id, binary.clone(), message_handler, cx.clone())
-                    .await?
-            } else {
-                DebugAdapterClient::start(session_id, binary.clone(), message_handler, cx.clone())
-                    .await?
-            },
-        );
+        let client = if let Some(client) = parent_session
+            .and_then(|session| cx.update(|cx| session.read(cx).adapter_client()).ok())
+            .flatten()
+        {
+            client
+                .create_child_connection(session_id, binary.clone(), message_handler, cx)
+                .await?
+        } else {
+            DebugAdapterClient::start(session_id, binary.clone(), message_handler, cx).await?
+        };
 
         Ok(Self {
-            client,
+            client: Arc::new(client),
             worktree,
             tmp_breakpoint: None,
             binary,
             executor: cx.background_executor().clone(),
+            is_started: false,
+            has_ever_stopped: false,
+            messages_tx,
         })
     }
 
@@ -218,25 +241,54 @@ impl LocalMode {
         breakpoint_store: &Entity<BreakpointStore>,
         cx: &mut App,
     ) -> Task<()> {
-        let breakpoints = breakpoint_store
-            .read_with(cx, |store, cx| store.breakpoints_from_path(&abs_path, cx))
+        let breakpoints =
+            breakpoint_store
+                .read(cx)
+                .source_breakpoints_from_path(&abs_path, cx)
+                .into_iter()
+                .filter(|bp| bp.state.is_enabled())
+                .chain(self.tmp_breakpoint.iter().filter_map(|breakpoint| {
+                    breakpoint.path.eq(&abs_path).then(|| breakpoint.clone())
+                }))
+                .map(Into::into)
+                .collect();
+
+        let raw_breakpoints = breakpoint_store
+            .read(cx)
+            .breakpoints_from_path(&abs_path)
             .into_iter()
-            .filter(|bp| bp.state.is_enabled())
-            .chain(self.tmp_breakpoint.clone())
-            .map(Into::into)
-            .collect();
+            .filter(|bp| bp.bp.state.is_enabled())
+            .collect::<Vec<_>>();
 
         let task = self.request(dap_command::SetBreakpoints {
             source: client_source(&abs_path),
             source_modified: Some(matches!(reason, BreakpointUpdatedReason::FileSaved)),
             breakpoints,
         });
-
-        cx.background_spawn(async move {
-            match task.await {
-                Ok(_) => {}
-                Err(err) => log::warn!("Set breakpoints request failed for path: {}", err),
+        let session_id = self.client.id();
+        let breakpoint_store = breakpoint_store.downgrade();
+        cx.spawn(async move |cx| match cx.background_spawn(task).await {
+            Ok(breakpoints) => {
+                let breakpoints =
+                    breakpoints
+                        .into_iter()
+                        .zip(raw_breakpoints)
+                        .filter_map(|(dap_bp, zed_bp)| {
+                            Some((
+                                zed_bp,
+                                BreakpointSessionState {
+                                    id: dap_bp.id?,
+                                    verified: dap_bp.verified,
+                                },
+                            ))
+                        });
+                breakpoint_store
+                    .update(cx, |this, _| {
+                        this.mark_breakpoints_verified(session_id, &abs_path, breakpoints);
+                    })
+                    .ok();
             }
+            Err(err) => log::warn!("Set breakpoints request failed for path: {}", err),
         })
     }
 
@@ -271,8 +323,10 @@ impl LocalMode {
         cx: &App,
     ) -> Task<HashMap<Arc<Path>, anyhow::Error>> {
         let mut breakpoint_tasks = Vec::new();
-        let breakpoints = breakpoint_store.read_with(cx, |store, cx| store.all_breakpoints(cx));
-
+        let breakpoints = breakpoint_store.read(cx).all_source_breakpoints(cx);
+        let mut raw_breakpoints = breakpoint_store.read_with(cx, |this, _| this.all_breakpoints());
+        debug_assert_eq!(raw_breakpoints.len(), breakpoints.len());
+        let session_id = self.client.id();
         for (path, breakpoints) in breakpoints {
             let breakpoints = if ignore_breakpoints {
                 vec![]
@@ -284,14 +338,46 @@ impl LocalMode {
                     .collect()
             };
 
-            breakpoint_tasks.push(
-                self.request(dap_command::SetBreakpoints {
+            let raw_breakpoints = raw_breakpoints
+                .remove(&path)
+                .unwrap_or_default()
+                .into_iter()
+                .filter(|bp| bp.bp.state.is_enabled());
+            let error_path = path.clone();
+            let send_request = self
+                .request(dap_command::SetBreakpoints {
                     source: client_source(&path),
                     source_modified: Some(false),
                     breakpoints,
                 })
-                .map(|result| result.map_err(|e| (path, e))),
-            );
+                .map(|result| result.map_err(move |e| (error_path, e)));
+
+            let task = cx.spawn({
+                let breakpoint_store = breakpoint_store.downgrade();
+                async move |cx| {
+                    let breakpoints = cx.background_spawn(send_request).await?;
+
+                    let breakpoints = breakpoints.into_iter().zip(raw_breakpoints).filter_map(
+                        |(dap_bp, zed_bp)| {
+                            Some((
+                                zed_bp,
+                                BreakpointSessionState {
+                                    id: dap_bp.id?,
+                                    verified: dap_bp.verified,
+                                },
+                            ))
+                        },
+                    );
+                    breakpoint_store
+                        .update(cx, |this, _| {
+                            this.mark_breakpoints_verified(session_id, &path, breakpoints);
+                        })
+                        .ok();
+
+                    Ok(())
+                }
+            });
+            breakpoint_tasks.push(task);
         }
 
         cx.background_spawn(async move {
@@ -308,7 +394,7 @@ impl LocalMode {
         capabilities: &Capabilities,
         initialized_rx: oneshot::Receiver<()>,
         dap_store: WeakEntity<DapStore>,
-        cx: &App,
+        cx: &mut Context<Session>,
     ) -> Task<Result<()>> {
         let raw = self.binary.request_args.clone();
 
@@ -340,9 +426,9 @@ impl LocalMode {
         let this = self.clone();
         let worktree = self.worktree().clone();
         let configuration_sequence = cx.spawn({
-            async move |cx| {
+            async move |_, cx| {
                 let breakpoint_store =
-                    dap_store.update(cx, |dap_store, _| dap_store.breakpoint_store().clone())?;
+                    dap_store.read_with(cx, |dap_store, _| dap_store.breakpoint_store().clone())?;
                 initialized_rx.await?;
                 let errors_by_path = cx
                     .update(|cx| this.send_source_breakpoints(false, &breakpoint_store, cx))?
@@ -388,12 +474,40 @@ impl LocalMode {
             }
         });
 
-        cx.background_spawn(async move {
-            futures::future::try_join(launch, configuration_sequence).await?;
-            Ok(())
+        let task = cx.background_spawn(futures::future::try_join(launch, configuration_sequence));
+
+        cx.spawn(async move |this, cx| {
+            let result = task.await;
+
+            this.update(cx, |this, cx| {
+                if let Some(this) = this.as_running_mut() {
+                    this.is_started = true;
+                    cx.notify();
+                }
+            })
+            .ok();
+
+            result?;
+            anyhow::Ok(())
         })
     }
 
+    fn reconnect_for_ssh(&self, cx: &mut AsyncApp) -> Option<Task<Result<()>>> {
+        let client = self.client.clone();
+        let messages_tx = self.messages_tx.clone();
+        let message_handler = Box::new(move |message| {
+            messages_tx.unbounded_send(message).ok();
+        });
+        if client.should_reconnect_for_ssh() {
+            Some(cx.spawn(async move |cx| {
+                client.connect(message_handler, cx).await?;
+                anyhow::Ok(())
+            }))
+        } else {
+            None
+        }
+    }
+
     fn request<R: LocalDapCommand>(&self, request: R) -> Task<Result<R::Response>>
     where
         <R::DapRequest as dap::requests::Request>::Response: 'static,
@@ -420,11 +534,24 @@ impl Mode {
         match self {
             Mode::Running(debug_adapter_client) => debug_adapter_client.request(request),
             Mode::Building => Task::ready(Err(anyhow!(
-                "no adapter running to send request: {:?}",
-                request
+                "no adapter running to send request: {request:?}"
             ))),
         }
     }
+
+    /// Did this debug session stop at least once?
+    pub(crate) fn has_ever_stopped(&self) -> bool {
+        match self {
+            Mode::Building => false,
+            Mode::Running(running_mode) => running_mode.has_ever_stopped,
+        }
+    }
+
+    fn stopped(&mut self) {
+        if let Mode::Running(running) = self {
+            running.has_ever_stopped = true;
+        }
+    }
 }
 
 #[derive(Default)]
@@ -511,6 +638,7 @@ pub struct Session {
     output: Box<circular_buffer::CircularBuffer<MAX_TRACKED_OUTPUT_EVENTS, dap::OutputEvent>>,
     threads: IndexMap<ThreadId, Thread>,
     thread_states: ThreadStates,
+    watchers: HashMap<SharedString, Watcher>,
     variables: HashMap<VariableReference, Vec<dap::Variable>>,
     stack_frames: IndexMap<StackFrameId, StackFrame>,
     locations: HashMap<u64, dap::LocationsResponse>,
@@ -520,6 +648,7 @@ pub struct Session {
     ignore_breakpoints: bool,
     exception_breakpoints: BTreeMap<String, (ExceptionBreakpointsFilter, IsEnabled)>,
     background_tasks: Vec<Task<()>>,
+    task_context: TaskContext,
 }
 
 trait CacheableCommand: Any + Send + Sync {
@@ -601,6 +730,7 @@ pub enum SessionEvent {
     Stopped(Option<ThreadId>),
     StackTrace,
     Variables,
+    Watchers,
     Threads,
     InvalidateInlineValue,
     CapabilitiesLoaded,
@@ -608,6 +738,7 @@ pub enum SessionEvent {
         request: RunInTerminalRequestArguments,
         sender: mpsc::Sender<Result<u32>>,
     },
+    ConsoleOutput,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq)]
@@ -633,13 +764,14 @@ impl Session {
         parent_session: Option<Entity<Session>>,
         label: SharedString,
         adapter: DebugAdapterName,
+        task_context: TaskContext,
         cx: &mut App,
     ) -> Entity<Self> {
         cx.new::<Self>(|cx| {
             cx.subscribe(&breakpoint_store, |this, store, event, cx| match event {
                 BreakpointStoreEvent::BreakpointsUpdated(path, reason) => {
                     if let Some(local) = (!this.ignore_breakpoints)
-                        .then(|| this.as_local_mut())
+                        .then(|| this.as_running_mut())
                         .flatten()
                     {
                         local
@@ -649,7 +781,7 @@ impl Session {
                 }
                 BreakpointStoreEvent::BreakpointsCleared(paths) => {
                     if let Some(local) = (!this.ignore_breakpoints)
-                        .then(|| this.as_local_mut())
+                        .then(|| this.as_running_mut())
                         .flatten()
                     {
                         local.unset_breakpoints_from_paths(paths, cx).detach();
@@ -658,7 +790,7 @@ impl Session {
                 BreakpointStoreEvent::SetDebugLine | BreakpointStoreEvent::ClearDebugLines => {}
             })
             .detach();
-            cx.on_app_quit(Self::on_app_quit).detach();
+            // cx.on_app_quit(Self::on_app_quit).detach();
 
             let this = Self {
                 mode: Mode::Building,
@@ -666,6 +798,7 @@ impl Session {
                 child_session_ids: HashSet::default(),
                 parent_session,
                 capabilities: Capabilities::default(),
+                watchers: HashMap::default(),
                 variables: Default::default(),
                 stack_frames: Default::default(),
                 thread_states: ThreadStates::default(),
@@ -683,12 +816,17 @@ impl Session {
                 exception_breakpoints: Default::default(),
                 label,
                 adapter,
+                task_context,
             };
 
             this
         })
     }
 
+    pub fn task_context(&self) -> &TaskContext {
+        &self.task_context
+    }
+
     pub fn worktree(&self) -> Option<Entity<Worktree>> {
         match &self.mode {
             Mode::Building => None,
@@ -741,13 +879,13 @@ impl Session {
         let parent_session = self.parent_session.clone();
 
         cx.spawn(async move |this, cx| {
-            let mode = LocalMode::new(
+            let mode = RunningMode::new(
                 id,
                 parent_session,
                 worktree.downgrade(),
-                binary,
+                binary.clone(),
                 message_tx,
-                cx.clone(),
+                cx,
             )
             .await?;
             this.update(cx, |this, cx| {
@@ -758,10 +896,26 @@ impl Session {
             this.update(cx, |session, cx| session.request_initialize(cx))?
                 .await?;
 
-            this.update(cx, |session, cx| {
-                session.initialize_sequence(initialized_rx, dap_store.clone(), cx)
-            })?
-            .await
+            let result = this
+                .update(cx, |session, cx| {
+                    session.initialize_sequence(initialized_rx, dap_store.clone(), cx)
+                })?
+                .await;
+
+            if result.is_err() {
+                let mut console = this.update(cx, |session, cx| session.console_output(cx))?;
+
+                console
+                    .send(format!(
+                        "Tried to launch debugger with: {}",
+                        serde_json::to_string_pretty(&binary.request_args.configuration)
+                            .unwrap_or_default(),
+                    ))
+                    .await
+                    .ok();
+            }
+
+            result
         })
     }
 
@@ -791,15 +945,46 @@ impl Session {
         self.parent_session.as_ref()
     }
 
+    pub fn on_app_quit(&mut self, cx: &mut Context<Self>) -> Task<()> {
+        let Some(client) = self.adapter_client() else {
+            return Task::ready(());
+        };
+
+        let supports_terminate = self
+            .capabilities
+            .support_terminate_debuggee
+            .unwrap_or(false);
+
+        cx.background_spawn(async move {
+            if supports_terminate {
+                client
+                    .request::<dap::requests::Terminate>(dap::TerminateArguments {
+                        restart: Some(false),
+                    })
+                    .await
+                    .ok();
+            } else {
+                client
+                    .request::<dap::requests::Disconnect>(dap::DisconnectArguments {
+                        restart: Some(false),
+                        terminate_debuggee: Some(true),
+                        suspend_debuggee: Some(false),
+                    })
+                    .await
+                    .ok();
+            }
+        })
+    }
+
     pub fn capabilities(&self) -> &Capabilities {
         &self.capabilities
     }
 
-    pub fn binary(&self) -> &DebugAdapterBinary {
-        let Mode::Running(local_mode) = &self.mode else {
-            panic!("Session is not local");
-        };
-        &local_mode.binary
+    pub fn binary(&self) -> Option<&DebugAdapterBinary> {
+        match &self.mode {
+            Mode::Building => None,
+            Mode::Running(running_mode) => Some(&running_mode.binary),
+        }
     }
 
     pub fn adapter(&self) -> DebugAdapterName {
@@ -819,9 +1004,8 @@ impl Session {
 
         cx.spawn(async move |this, cx| {
             while let Some(output) = rx.next().await {
-                this.update(cx, |this, _| {
-                    this.output_token.0 += 1;
-                    this.output.push_back(dap::OutputEvent {
+                this.update(cx, |this, cx| {
+                    let event = dap::OutputEvent {
                         category: None,
                         output,
                         group: None,
@@ -831,7 +1015,8 @@ impl Session {
                         column: None,
                         data: None,
                         location_reference: None,
-                    });
+                    };
+                    this.push_output(event, cx);
                 })?;
             }
             anyhow::Ok(())
@@ -841,18 +1026,29 @@ impl Session {
         return tx;
     }
 
-    pub fn is_local(&self) -> bool {
+    pub fn is_started(&self) -> bool {
+        match &self.mode {
+            Mode::Building => false,
+            Mode::Running(running) => running.is_started,
+        }
+    }
+
+    pub fn is_building(&self) -> bool {
+        matches!(self.mode, Mode::Building)
+    }
+
+    pub fn is_running(&self) -> bool {
         matches!(self.mode, Mode::Running(_))
     }
 
-    pub fn as_local_mut(&mut self) -> Option<&mut LocalMode> {
+    pub fn as_running_mut(&mut self) -> Option<&mut RunningMode> {
         match &mut self.mode {
             Mode::Running(local_mode) => Some(local_mode),
             Mode::Building => None,
         }
     }
 
-    pub fn as_local(&self) -> Option<&LocalMode> {
+    pub fn as_running(&self) -> Option<&RunningMode> {
         match &self.mode {
             Mode::Running(local_mode) => Some(local_mode),
             Mode::Building => None,
@@ -901,10 +1097,41 @@ impl Session {
         request: dap::messages::Request,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        let request_args = serde_json::from_value::<RunInTerminalRequestArguments>(
+        let request_args = match serde_json::from_value::<RunInTerminalRequestArguments>(
             request.arguments.unwrap_or_default(),
-        )
-        .expect("To parse StartDebuggingRequestArguments");
+        ) {
+            Ok(args) => args,
+            Err(error) => {
+                return cx.spawn(async move |session, cx| {
+                    let error = serde_json::to_value(dap::ErrorResponse {
+                        error: Some(dap::Message {
+                            id: request.seq,
+                            format: error.to_string(),
+                            variables: None,
+                            send_telemetry: None,
+                            show_user: None,
+                            url: None,
+                            url_label: None,
+                        }),
+                    })
+                    .ok();
+
+                    session
+                        .update(cx, |this, cx| {
+                            this.respond_to_client(
+                                request.seq,
+                                false,
+                                StartDebugging::COMMAND.to_string(),
+                                error,
+                                cx,
+                            )
+                        })?
+                        .await?;
+
+                    Err(anyhow!("Failed to parse RunInTerminalRequestArguments"))
+                });
+            }
+        };
 
         let seq = request.seq;
 
@@ -965,35 +1192,58 @@ impl Session {
     pub(super) fn request_initialize(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
         let adapter_id = self.adapter().to_string();
         let request = Initialize { adapter_id };
-        match &self.mode {
-            Mode::Running(local_mode) => {
-                let capabilities = local_mode.request(request);
 
-                cx.spawn(async move |this, cx| {
-                    let capabilities = capabilities.await?;
-                    this.update(cx, |session, cx| {
-                        session.capabilities = capabilities;
-                        let filters = session
-                            .capabilities
-                            .exception_breakpoint_filters
-                            .clone()
-                            .unwrap_or_default();
-                        for filter in filters {
-                            let default = filter.default.unwrap_or_default();
-                            session
-                                .exception_breakpoints
-                                .entry(filter.filter.clone())
-                                .or_insert_with(|| (filter, default));
-                        }
-                        cx.emit(SessionEvent::CapabilitiesLoaded);
-                    })?;
-                    Ok(())
-                })
-            }
-            Mode::Building => Task::ready(Err(anyhow!(
+        let Mode::Running(running) = &self.mode else {
+            return Task::ready(Err(anyhow!(
                 "Cannot send initialize request, task still building"
-            ))),
-        }
+            )));
+        };
+        let mut response = running.request(request.clone());
+
+        cx.spawn(async move |this, cx| {
+            loop {
+                let capabilities = response.await;
+                match capabilities {
+                    Err(e) => {
+                        let Ok(Some(reconnect)) = this.update(cx, |this, cx| {
+                            this.as_running()
+                                .and_then(|running| running.reconnect_for_ssh(&mut cx.to_async()))
+                        }) else {
+                            return Err(e);
+                        };
+                        log::info!("Failed to connect to debug adapter: {}, retrying...", e);
+                        reconnect.await?;
+
+                        let Ok(Some(r)) = this.update(cx, |this, _| {
+                            this.as_running()
+                                .map(|running| running.request(request.clone()))
+                        }) else {
+                            return Err(e);
+                        };
+                        response = r
+                    }
+                    Ok(capabilities) => {
+                        this.update(cx, |session, cx| {
+                            session.capabilities = capabilities;
+                            let filters = session
+                                .capabilities
+                                .exception_breakpoint_filters
+                                .clone()
+                                .unwrap_or_default();
+                            for filter in filters {
+                                let default = filter.default.unwrap_or_default();
+                                session
+                                    .exception_breakpoints
+                                    .entry(filter.filter.clone())
+                                    .or_insert_with(|| (filter, default));
+                            }
+                            cx.emit(SessionEvent::CapabilitiesLoaded);
+                        })?;
+                        return Ok(());
+                    }
+                }
+            }
+        })
     }
 
     pub(super) fn initialize_sequence(
@@ -1075,7 +1325,7 @@ impl Session {
         body: Option<serde_json::Value>,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        let Some(local_session) = self.as_local() else {
+        let Some(local_session) = self.as_running() else {
             unreachable!("Cannot respond to remote client");
         };
         let client = local_session.client.clone();
@@ -1095,9 +1345,10 @@ impl Session {
     }
 
     fn handle_stopped_event(&mut self, event: StoppedEvent, cx: &mut Context<Self>) {
+        self.mode.stopped();
         // todo(debugger): Find a clean way to get around the clone
         let breakpoint_store = self.breakpoint_store.clone();
-        if let Some((local, path)) = self.as_local_mut().and_then(|local| {
+        if let Some((local, path)) = self.as_running_mut().and_then(|local| {
             let breakpoint = local.tmp_breakpoint.take()?;
             let path = breakpoint.path.clone();
             Some((local, path))
@@ -1114,7 +1365,6 @@ impl Session {
 
         if event.all_threads_stopped.unwrap_or_default() || event.thread_id.is_none() {
             self.thread_states.stop_all_threads();
-
             self.invalidate_command_type::<StackTraceCommand>();
         }
 
@@ -1200,11 +1450,12 @@ impl Session {
                     return;
                 }
 
-                self.output.push_back(event);
-                self.output_token.0 += 1;
+                self.push_output(event, cx);
                 cx.notify();
             }
-            Events::Breakpoint(_) => {}
+            Events::Breakpoint(event) => self.breakpoint_store.update(cx, |store, _| {
+                store.update_session_breakpoint(self.session_id(), event.reason, event.breakpoint);
+            }),
             Events::Module(event) => {
                 match event.reason {
                     dap::ModuleEventReason::New => {
@@ -1248,12 +1499,7 @@ impl Session {
     fn fetch<T: DapCommand + PartialEq + Eq + Hash>(
         &mut self,
         request: T,
-        process_result: impl FnOnce(
-            &mut Self,
-            Result<T::Response>,
-            &mut Context<Self>,
-        ) -> Option<T::Response>
-        + 'static,
+        process_result: impl FnOnce(&mut Self, Result<T::Response>, &mut Context<Self>) + 'static,
         cx: &mut Context<Self>,
     ) {
         const {
@@ -1282,7 +1528,10 @@ impl Session {
                 &self.capabilities,
                 &self.mode,
                 command,
-                process_result,
+                |this, result, cx| {
+                    process_result(this, result, cx);
+                    None
+                },
                 cx,
             );
             let task = cx
@@ -1298,17 +1547,6 @@ impl Session {
         }
     }
 
-    pub async fn request2<T: DapCommand + PartialEq + Eq + Hash>(
-        &self,
-        request: T,
-    ) -> Result<T::Response> {
-        if !T::is_supported(&self.capabilities) {
-            anyhow::bail!("DAP request {:?} is not supported", request);
-        }
-
-        self.mode.request_dap(request).await
-    }
-
     fn request_inner<T: DapCommand + PartialEq + Eq + Hash>(
         capabilities: &Capabilities,
         mode: &Mode,
@@ -1331,7 +1569,7 @@ impl Session {
             ));
             return cx.spawn(async move |this, cx| {
                 this.update(cx, |this, cx| process_result(this, error, cx))
-                    .log_err()
+                    .ok()
                     .flatten()
             });
         }
@@ -1340,7 +1578,7 @@ impl Session {
         cx.spawn(async move |this, cx| {
             let result = request.await;
             this.update(cx, |this, cx| process_result(this, result, cx))
-                .log_err()
+                .ok()
                 .flatten()
         })
     }
@@ -1377,6 +1615,12 @@ impl Session {
             });
     }
 
+    fn push_output(&mut self, event: OutputEvent, cx: &mut Context<Self>) {
+        self.output.push_back(event);
+        self.output_token.0 += 1;
+        cx.emit(SessionEvent::ConsoleOutput);
+    }
+
     pub fn any_stopped_thread(&self) -> bool {
         self.thread_states.any_stopped_thread()
     }
@@ -1389,18 +1633,18 @@ impl Session {
         self.fetch(
             dap_command::ThreadsCommand,
             |this, result, cx| {
-                let result = result.log_err()?;
+                let Some(result) = result.log_err() else {
+                    return;
+                };
 
                 this.threads = result
-                    .iter()
+                    .into_iter()
                     .map(|thread| (ThreadId(thread.id), Thread::from(thread.clone())))
                     .collect();
 
                 this.invalidate_command_type::<StackTraceCommand>();
                 cx.emit(SessionEvent::Threads);
                 cx.notify();
-
-                Some(result)
             },
             cx,
         );
@@ -1420,13 +1664,13 @@ impl Session {
         self.fetch(
             dap_command::ModulesCommand,
             |this, result, cx| {
-                let result = result.log_err()?;
+                let Some(result) = result.log_err() else {
+                    return;
+                };
 
-                this.modules = result.iter().cloned().collect();
+                this.modules = result;
                 cx.emit(SessionEvent::Modules);
                 cx.notify();
-
-                Some(result)
             },
             cx,
         );
@@ -1456,7 +1700,7 @@ impl Session {
 
         self.ignore_breakpoints = ignore;
 
-        if let Some(local) = self.as_local() {
+        if let Some(local) = self.as_running() {
             local.send_source_breakpoints(ignore, &self.breakpoint_store, cx)
         } else {
             // todo(debugger): We need to propagate this change to downstream sessions and send a message to upstream sessions
@@ -1478,7 +1722,7 @@ impl Session {
     }
 
     fn send_exception_breakpoints(&mut self, cx: &App) {
-        if let Some(local) = self.as_local() {
+        if let Some(local) = self.as_running() {
             let exception_filters = self
                 .exception_breakpoints
                 .values()
@@ -1505,11 +1749,12 @@ impl Session {
         self.fetch(
             dap_command::LoadedSourcesCommand,
             |this, result, cx| {
-                let result = result.log_err()?;
-                this.loaded_sources = result.iter().cloned().collect();
+                let Some(result) = result.log_err() else {
+                    return;
+                };
+                this.loaded_sources = result;
                 cx.emit(SessionEvent::LoadedSources);
                 cx.notify();
-                Some(result)
             },
             cx,
         );
@@ -1604,17 +1849,11 @@ impl Session {
         }
     }
 
-    fn on_app_quit(&mut self, cx: &mut Context<Self>) -> Task<()> {
-        let debug_adapter = self.adapter_client();
-
-        cx.background_spawn(async move {
-            if let Some(client) = debug_adapter {
-                client.shutdown().await.log_err();
-            }
-        })
-    }
-
     pub fn shutdown(&mut self, cx: &mut Context<Self>) -> Task<()> {
+        if self.is_session_terminated {
+            return Task::ready(());
+        }
+
         self.is_session_terminated = true;
         self.thread_states.exit_all_threads();
         cx.notify();
@@ -1645,14 +1884,8 @@ impl Session {
 
         cx.emit(SessionStateEvent::Shutdown);
 
-        let debug_client = self.adapter_client();
-
-        cx.background_spawn(async move {
-            let _ = task.await;
-
-            if let Some(client) = debug_client {
-                client.shutdown().await.log_err();
-            }
+        cx.spawn(async move |_, _| {
+            task.await;
         })
     }
 
@@ -1667,7 +1900,7 @@ impl Session {
             anyhow::Ok(
                 task.await
                     .map(|response| response.targets)
-                    .ok_or_else(|| anyhow!("failed to fetch completions"))?,
+                    .context("failed to fetch completions")?,
             )
         })
     }

crates/project/src/direnv.rs 🔗

@@ -9,7 +9,6 @@ pub enum DirenvError {
     NotFound,
     FailedRun,
     NonZeroExit(ExitStatus, Vec<u8>),
-    EmptyOutput,
     InvalidJson,
 }
 
@@ -22,7 +21,6 @@ impl From<DirenvError> for Option<EnvironmentErrorMessage> {
                     "Failed to run direnv. See logs for more info",
                 )))
             }
-            DirenvError::EmptyOutput => None,
             DirenvError::InvalidJson => Some(EnvironmentErrorMessage(String::from(
                 "Direnv returned invalid json. See logs for more info",
             ))),
@@ -34,13 +32,14 @@ impl From<DirenvError> for Option<EnvironmentErrorMessage> {
 pub async fn load_direnv_environment(
     env: &HashMap<String, String>,
     dir: &Path,
-) -> Result<HashMap<String, String>, DirenvError> {
+) -> Result<HashMap<String, Option<String>>, DirenvError> {
     let Ok(direnv_path) = which::which("direnv") else {
         return Err(DirenvError::NotFound);
     };
 
-    let Some(direnv_output) = smol::process::Command::new(direnv_path)
-        .args(["export", "json"])
+    let args = &["export", "json"];
+    let Some(direnv_output) = smol::process::Command::new(&direnv_path)
+        .args(args)
         .envs(env)
         .env("TERM", "dumb")
         .current_dir(dir)
@@ -65,12 +64,21 @@ pub async fn load_direnv_environment(
 
     let output = String::from_utf8_lossy(&direnv_output.stdout);
     if output.is_empty() {
-        return Err(DirenvError::EmptyOutput);
+        // direnv outputs nothing when it has no changes to apply to environment variables
+        return Ok(HashMap::new());
     }
 
-    let Some(env) = serde_json::from_str(&output).log_err() else {
-        return Err(DirenvError::InvalidJson);
-    };
-
-    Ok(env)
+    match serde_json::from_str(&output) {
+        Ok(env) => Ok(env),
+        Err(err) => {
+            log::error!(
+                "json parse error {}, while parsing output of `{} {}`:\n{}",
+                err,
+                direnv_path.display(),
+                args.join(" "),
+                output
+            );
+            Err(DirenvError::InvalidJson)
+        }
+    }
 }

crates/project/src/environment.rs 🔗

@@ -245,108 +245,45 @@ async fn load_shell_environment(
     Option<HashMap<String, String>>,
     Option<EnvironmentErrorMessage>,
 ) {
-    use crate::direnv::{DirenvError, load_direnv_environment};
-    use std::path::PathBuf;
-    use util::parse_env_output;
-
-    fn message<T>(with: &str) -> (Option<T>, Option<EnvironmentErrorMessage>) {
-        let message = EnvironmentErrorMessage::from_str(with);
-        (None, Some(message))
-    }
-
-    const MARKER: &str = "ZED_SHELL_START";
-    let Some(shell) = std::env::var("SHELL").log_err() else {
-        return message("Failed to get login environment. SHELL environment variable is not set");
+    use crate::direnv::load_direnv_environment;
+    use util::shell_env;
+
+    let dir_ = dir.to_owned();
+    let mut envs = match smol::unblock(move || shell_env::capture(&dir_)).await {
+        Ok(envs) => envs,
+        Err(err) => {
+            util::log_err(&err);
+            return (
+                None,
+                Some(EnvironmentErrorMessage::from_str(
+                    "Failed to load environment variables. See log for details",
+                )),
+            );
+        }
     };
-    let shell_path = PathBuf::from(&shell);
-    let shell_name = shell_path.file_name().and_then(|f| f.to_str());
-
-    // What we're doing here is to spawn a shell and then `cd` into
-    // the project directory to get the env in there as if the user
-    // `cd`'d into it. We do that because tools like direnv, asdf, ...
-    // hook into `cd` and only set up the env after that.
-    //
+
     // If the user selects `Direct` for direnv, it would set an environment
     // variable that later uses to know that it should not run the hook.
     // We would include in `.envs` call so it is okay to run the hook
     // even if direnv direct mode is enabled.
-    //
-    // In certain shells we need to execute additional_command in order to
-    // trigger the behavior of direnv, etc.
-
-    let command = match shell_name {
-        Some("fish") => format!(
-            "cd '{}'; emit fish_prompt; printf '%s' {MARKER}; /usr/bin/env;",
-            dir.display()
-        ),
-        _ => format!(
-            "cd '{}'; printf '%s' {MARKER}; /usr/bin/env;",
-            dir.display()
-        ),
-    };
-
-    // csh/tcsh only supports `-l` if it's the only flag. So this won't be a login shell.
-    // Users must rely on vars from `~/.tcshrc` or `~/.cshrc` and not `.login` as a result.
-    let args = match shell_name {
-        Some("tcsh") | Some("csh") => vec!["-i".to_string(), "-c".to_string(), command],
-        _ => vec![
-            "-l".to_string(),
-            "-i".to_string(),
-            "-c".to_string(),
-            command,
-        ],
-    };
-
-    let Some(output) = smol::unblock(move || {
-        util::set_pre_exec_to_start_new_session(std::process::Command::new(&shell).args(&args))
-            .output()
-    })
-    .await
-    .log_err() else {
-        return message(
-            "Failed to spawn login shell to source login environment variables. See logs for details",
-        );
-    };
-
-    if !output.status.success() {
-        log::error!("login shell exited with {}", output.status);
-        return message("Login shell exited with nonzero exit code. See logs for details");
-    }
-
-    let stdout = String::from_utf8_lossy(&output.stdout);
-    let Some(env_output_start) = stdout.find(MARKER) else {
-        let stderr = String::from_utf8_lossy(&output.stderr);
-        log::error!(
-            "failed to parse output of `env` command in login shell. stdout: {:?}, stderr: {:?}",
-            stdout,
-            stderr
-        );
-        return message("Failed to parse stdout of env command. See logs for the output");
-    };
-
-    let mut parsed_env = HashMap::default();
-    let env_output = &stdout[env_output_start + MARKER.len()..];
-
-    parse_env_output(env_output, |key, value| {
-        parsed_env.insert(key, value);
-    });
-
     let (direnv_environment, direnv_error) = match load_direnv {
         DirenvSettings::ShellHook => (None, None),
-        DirenvSettings::Direct => match load_direnv_environment(&parsed_env, dir).await {
+        DirenvSettings::Direct => match load_direnv_environment(&envs, dir).await {
             Ok(env) => (Some(env), None),
-            Err(err) => (
-                None,
-                <Option<EnvironmentErrorMessage> as From<DirenvError>>::from(err),
-            ),
+            Err(err) => (None, err.into()),
         },
     };
-
-    for (key, value) in direnv_environment.unwrap_or(HashMap::default()) {
-        parsed_env.insert(key, value);
+    if let Some(direnv_environment) = direnv_environment {
+        for (key, value) in direnv_environment {
+            if let Some(value) = value {
+                envs.insert(key, value);
+            } else {
+                envs.remove(&key);
+            }
+        }
     }
 
-    (Some(parsed_env), direnv_error)
+    (Some(envs), direnv_error)
 }
 
 fn get_directory_env_impl(

crates/project/src/git_store.rs 🔗

@@ -23,9 +23,9 @@ use git::{
     blame::Blame,
     parse_git_remote_url,
     repository::{
-        Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, GitRepository,
-        GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode,
-        UpstreamTrackingStatus,
+        Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, FetchOptions,
+        GitRepository, GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath,
+        ResetMode, UpstreamTrackingStatus,
     },
     status::{
         FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode,
@@ -163,7 +163,7 @@ struct LocalDownstreamState {
     _task: Task<Result<()>>,
 }
 
-#[derive(Clone)]
+#[derive(Clone, Debug)]
 pub struct GitStoreCheckpoint {
     checkpoints_by_work_dir_abs_path: HashMap<Arc<Path>, GitRepositoryCheckpoint>,
 }
@@ -292,7 +292,7 @@ pub enum RepositoryState {
 
 #[derive(Clone, Debug)]
 pub enum RepositoryEvent {
-    Updated { full_scan: bool },
+    Updated { full_scan: bool, new_instance: bool },
     MergeHeadsChanged,
 }
 
@@ -778,11 +778,7 @@ impl GitStore {
         let is_unmerged = self
             .repository_and_path_for_buffer_id(buffer_id, cx)
             .map_or(false, |(repo, path)| {
-                repo.read(cx)
-                    .snapshot
-                    .merge
-                    .conflicted_paths
-                    .contains(&path)
+                repo.read(cx).snapshot.has_conflict(&path)
             });
         let git_store = cx.weak_entity();
         let buffer_git_state = self
@@ -976,7 +972,7 @@ impl GitStore {
             return cx.spawn(async move |cx| {
                 let provider_registry = cx.update(GitHostingProviderRegistry::default_global)?;
                 get_permalink_in_rust_registry_src(provider_registry, file_path, selection)
-                    .map_err(|_| anyhow!("no permalink available"))
+                    .context("no permalink available")
             });
 
             // TODO remote case
@@ -997,23 +993,20 @@ impl GitStore {
                     RepositoryState::Local { backend, .. } => {
                         let origin_url = backend
                             .remote_url(&remote)
-                            .ok_or_else(|| anyhow!("remote \"{remote}\" not found"))?;
+                            .with_context(|| format!("remote \"{remote}\" not found"))?;
 
-                        let sha = backend
-                            .head_sha()
-                            .await
-                            .ok_or_else(|| anyhow!("failed to read HEAD SHA"))?;
+                        let sha = backend.head_sha().await.context("reading HEAD SHA")?;
 
                         let provider_registry =
                             cx.update(GitHostingProviderRegistry::default_global)?;
 
                         let (provider, remote) =
                             parse_git_remote_url(provider_registry, &origin_url)
-                                .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
+                                .context("parsing Git remote URL")?;
 
-                        let path = repo_path
-                            .to_str()
-                            .ok_or_else(|| anyhow!("failed to convert path to string"))?;
+                        let path = repo_path.to_str().with_context(|| {
+                            format!("converting repo path {repo_path:?} to string")
+                        })?;
 
                         Ok(provider.build_permalink(
                             remote,
@@ -1148,7 +1141,7 @@ impl GitStore {
         cx: &mut Context<Self>,
     ) {
         let id = repo.read(cx).id;
-        let merge_conflicts = repo.read(cx).snapshot.merge.conflicted_paths.clone();
+        let repo_snapshot = repo.read(cx).snapshot.clone();
         for (buffer_id, diff) in self.diffs.iter() {
             if let Some((buffer_repo, repo_path)) =
                 self.repository_and_path_for_buffer_id(*buffer_id, cx)
@@ -1158,7 +1151,7 @@ impl GitStore {
                         if let Some(conflict_set) = &diff.conflict_set {
                             let conflict_status_changed =
                                 conflict_set.update(cx, |conflict_set, cx| {
-                                    let has_conflict = merge_conflicts.contains(&repo_path);
+                                    let has_conflict = repo_snapshot.has_conflict(&repo_path);
                                     conflict_set.set_has_conflict(has_conflict, cx)
                                 })?;
                             if conflict_status_changed {
@@ -1503,7 +1496,7 @@ impl GitStore {
 
             repo.update(cx, {
                 let update = update.clone();
-                |repo, cx| repo.apply_remote_update(update, cx)
+                |repo, cx| repo.apply_remote_update(update, is_new, cx)
             })?;
 
             this.active_repo_id.get_or_insert_with(|| {
@@ -1560,6 +1553,7 @@ impl GitStore {
     ) -> Result<proto::RemoteMessageResponse> {
         let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
         let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+        let fetch_options = FetchOptions::from_proto(envelope.payload.remote);
         let askpass_id = envelope.payload.askpass_id;
 
         let askpass = make_remote_delegate(
@@ -1572,7 +1566,7 @@ impl GitStore {
 
         let remote_output = repository_handle
             .update(&mut cx, |repository_handle, cx| {
-                repository_handle.fetch(askpass, cx)
+                repository_handle.fetch(fetch_options, askpass, cx)
             })?
             .await??;
 
@@ -1966,7 +1960,7 @@ impl GitStore {
         let delegates = cx.update(|cx| repository.read(cx).askpass_delegates.clone())?;
         let Some(mut askpass) = delegates.lock().remove(&envelope.payload.askpass_id) else {
             debug_panic!("no askpass found");
-            return Err(anyhow::anyhow!("no askpass found"));
+            anyhow::bail!("no askpass found");
         };
 
         let response = askpass.ask_password(envelope.payload.prompt).await?;
@@ -2035,7 +2029,7 @@ impl GitStore {
                 let buffer = this.buffer_store.read(cx).get(buffer_id)?;
                 Some(this.open_unstaged_diff(buffer, cx))
             })?
-            .ok_or_else(|| anyhow!("no such buffer"))?
+            .context("missing buffer")?
             .await?;
         this.update(&mut cx, |this, _| {
             let shared_diffs = this
@@ -2059,7 +2053,7 @@ impl GitStore {
                 let buffer = this.buffer_store.read(cx).get(buffer_id)?;
                 Some(this.open_uncommitted_diff(buffer, cx))
             })?
-            .ok_or_else(|| anyhow!("no such buffer"))?
+            .context("missing buffer")?
             .await?;
         this.update(&mut cx, |this, _| {
             let shared_diffs = this
@@ -2182,7 +2176,7 @@ impl GitStore {
         id: RepositoryId,
         cx: &mut AsyncApp,
     ) -> Result<Entity<Repository>> {
-        this.update(cx, |this, _| {
+        this.read_with(cx, |this, _| {
             this.repositories
                 .get(&id)
                 .context("missing repository handle")
@@ -2671,8 +2665,17 @@ impl RepositorySnapshot {
             .ok()
     }
 
+    pub fn had_conflict_on_last_merge_head_change(&self, repo_path: &RepoPath) -> bool {
+        self.merge.conflicted_paths.contains(&repo_path)
+    }
+
     pub fn has_conflict(&self, repo_path: &RepoPath) -> bool {
-        self.merge.conflicted_paths.contains(repo_path)
+        let had_conflict_on_last_merge_head_change =
+            self.merge.conflicted_paths.contains(&repo_path);
+        let has_conflict_currently = self
+            .status_for_path(&repo_path)
+            .map_or(false, |entry| entry.status.is_conflicted());
+        had_conflict_on_last_merge_head_change || has_conflict_currently
     }
 
     /// This is the name that will be displayed in the repository selector for this repository.
@@ -3319,7 +3322,7 @@ impl Repository {
                     let Some(project_path) = self.repo_path_to_project_path(path, cx) else {
                         continue;
                     };
-                    if let Some(buffer) = buffer_store.get_by_path(&project_path, cx) {
+                    if let Some(buffer) = buffer_store.get_by_path(&project_path) {
                         if buffer
                             .read(cx)
                             .file()
@@ -3386,7 +3389,7 @@ impl Repository {
                     let Some(project_path) = self.repo_path_to_project_path(path, cx) else {
                         continue;
                     };
-                    if let Some(buffer) = buffer_store.get_by_path(&project_path, cx) {
+                    if let Some(buffer) = buffer_store.get_by_path(&project_path) {
                         if buffer
                             .read(cx)
                             .file()
@@ -3498,6 +3501,7 @@ impl Repository {
 
     pub fn fetch(
         &mut self,
+        fetch_options: FetchOptions,
         askpass: AskPassDelegate,
         _cx: &mut App,
     ) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
@@ -3511,7 +3515,7 @@ impl Repository {
                     backend,
                     environment,
                     ..
-                } => backend.fetch(askpass, environment, cx).await,
+                } => backend.fetch(fetch_options, askpass, environment, cx).await,
                 RepositoryState::Remote { project_id, client } => {
                     askpass_delegates.lock().insert(askpass_id, askpass);
                     let _defer = util::defer(|| {
@@ -3524,6 +3528,7 @@ impl Repository {
                             project_id: project_id.0,
                             repository_id: id.to_proto(),
                             askpass_id,
+                            remote: fetch_options.to_proto(),
                         })
                         .await
                         .context("sending fetch request")?;
@@ -3552,7 +3557,7 @@ impl Repository {
         let args = options
             .map(|option| match option {
                 PushOptions::SetUpstream => " --set-upstream",
-                PushOptions::Force => " --force",
+                PushOptions::Force => " --force-with-lease",
             })
             .unwrap_or("");
 
@@ -3592,7 +3597,10 @@ impl Repository {
                             let snapshot = this.update(&mut cx, |this, cx| {
                                 this.snapshot.branch = branch;
                                 let snapshot = this.snapshot.clone();
-                                cx.emit(RepositoryEvent::Updated { full_scan: false });
+                                cx.emit(RepositoryEvent::Updated {
+                                    full_scan: false,
+                                    new_instance: false,
+                                });
                                 snapshot
                             })?;
                             if let Some(updates_tx) = updates_tx {
@@ -3741,7 +3749,7 @@ impl Repository {
                         let buffer_id = git_store
                             .buffer_store
                             .read(cx)
-                            .get_by_path(&project_path?, cx)?
+                            .get_by_path(&project_path?)?
                             .read(cx)
                             .remote_id();
                         let diff_state = git_store.diffs.get(&buffer_id)?;
@@ -3915,7 +3923,7 @@ impl Repository {
         self.send_job(None, |repo, _cx| async move {
             match repo {
                 RepositoryState::Local { backend, .. } => backend.checkpoint().await,
-                RepositoryState::Remote { .. } => Err(anyhow!("not implemented yet")),
+                RepositoryState::Remote { .. } => anyhow::bail!("not implemented yet"),
             }
         })
     }
@@ -3929,7 +3937,7 @@ impl Repository {
                 RepositoryState::Local { backend, .. } => {
                     backend.restore_checkpoint(checkpoint).await
                 }
-                RepositoryState::Remote { .. } => Err(anyhow!("not implemented yet")),
+                RepositoryState::Remote { .. } => anyhow::bail!("not implemented yet"),
             }
         })
     }
@@ -3937,6 +3945,7 @@ impl Repository {
     pub(crate) fn apply_remote_update(
         &mut self,
         update: proto::UpdateRepository,
+        is_new: bool,
         cx: &mut Context<Self>,
     ) -> Result<()> {
         let conflicted_paths = TreeSet::from_ordered_entries(
@@ -3970,7 +3979,10 @@ impl Repository {
         if update.is_last_update {
             self.snapshot.scan_id = update.scan_id;
         }
-        cx.emit(RepositoryEvent::Updated { full_scan: true });
+        cx.emit(RepositoryEvent::Updated {
+            full_scan: true,
+            new_instance: is_new,
+        });
         Ok(())
     }
 
@@ -3984,7 +3996,7 @@ impl Repository {
                 RepositoryState::Local { backend, .. } => {
                     backend.compare_checkpoints(left, right).await
                 }
-                RepositoryState::Remote { .. } => Err(anyhow!("not implemented yet")),
+                RepositoryState::Remote { .. } => anyhow::bail!("not implemented yet"),
             }
         })
     }
@@ -4001,7 +4013,7 @@ impl Repository {
                         .diff_checkpoints(base_checkpoint, target_checkpoint)
                         .await
                 }
-                RepositoryState::Remote { .. } => Err(anyhow!("not implemented yet")),
+                RepositoryState::Remote { .. } => anyhow::bail!("not implemented yet"),
             }
         })
     }
@@ -4025,7 +4037,7 @@ impl Repository {
                     bail!("not a local repository")
                 };
                 let (snapshot, events) = this
-                    .update(&mut cx, |this, _| {
+                    .read_with(&mut cx, |this, _| {
                         compute_snapshot(
                             this.id,
                             this.work_directory_abs_path.clone(),
@@ -4064,7 +4076,7 @@ impl Repository {
         cx.spawn(async move |_, cx| {
             let environment = project_environment
                 .upgrade()
-                .ok_or_else(|| anyhow!("missing project environment"))?
+                .context("missing project environment")?
                 .update(cx, |project_environment, cx| {
                     project_environment.get_directory_environment(work_directory_abs_path.clone(), cx)
                 })?
@@ -4076,7 +4088,7 @@ impl Repository {
             let backend = cx
                 .background_spawn(async move {
                     fs.open_repo(&dot_git_abs_path)
-                        .ok_or_else(|| anyhow!("failed to build repository"))
+                        .with_context(|| format!("opening repository at {dot_git_abs_path:?}"))
                 })
                 .await?;
 
@@ -4215,8 +4227,7 @@ impl Repository {
                             buffer_id: buffer_id.to_proto(),
                         })
                         .await?;
-                    let mode =
-                        Mode::from_i32(response.mode).ok_or_else(|| anyhow!("Invalid mode"))?;
+                    let mode = Mode::from_i32(response.mode).context("Invalid mode")?;
                     let bases = match mode {
                         Mode::IndexMatchesHead => DiffBasesChange::SetBoth(response.committed_text),
                         Mode::IndexAndHead => DiffBasesChange::SetEach {
@@ -4278,9 +4289,9 @@ impl Repository {
                             }));
                         }
                         let mut cursor = prev_statuses.cursor::<PathProgress>(&());
-                        for path in changed_paths.iter() {
+                        for path in changed_paths.into_iter() {
                             if cursor.seek_forward(&PathTarget::Path(&path), Bias::Left, &()) {
-                                changed_path_statuses.push(Edit::Remove(PathKey(path.0.clone())));
+                                changed_path_statuses.push(Edit::Remove(PathKey(path.0)));
                             }
                         }
                         changed_path_statuses
@@ -4301,7 +4312,10 @@ impl Repository {
                                 .ok();
                         }
                     }
-                    cx.emit(RepositoryEvent::Updated { full_scan: false });
+                    cx.emit(RepositoryEvent::Updated {
+                        full_scan: false,
+                        new_instance: false,
+                    });
                 })
             },
         );
@@ -4353,7 +4367,7 @@ fn get_permalink_in_rust_registry_src(
     let cargo_toml = std::fs::read_to_string(dir.join("Cargo.toml"))?;
     let manifest = toml::from_str::<CargoToml>(&cargo_toml)?;
     let (provider, remote) = parse_git_remote_url(provider_registry, &manifest.package.repository)
-        .ok_or_else(|| anyhow!("Failed to parse package.repository field of manifest"))?;
+        .context("parsing package.repository field of manifest")?;
     let path = PathBuf::from(cargo_vcs_info.path_in_vcs).join(path.strip_prefix(dir).unwrap());
     let permalink = provider.build_permalink(
         remote,
@@ -4542,7 +4556,9 @@ async fn compute_snapshot(
     let mut events = Vec::new();
     let branches = backend.branches().await?;
     let branch = branches.into_iter().find(|branch| branch.is_head);
-    let statuses = backend.status(&[WORK_DIRECTORY_REPO_PATH.clone()]).await?;
+    let statuses = backend
+        .status(std::slice::from_ref(&WORK_DIRECTORY_REPO_PATH))
+        .await?;
     let statuses_by_path = SumTree::from_iter(
         statuses
             .entries
@@ -4561,7 +4577,10 @@ async fn compute_snapshot(
         || branch != prev_snapshot.branch
         || statuses_by_path != prev_snapshot.statuses_by_path
     {
-        events.push(RepositoryEvent::Updated { full_scan: true });
+        events.push(RepositoryEvent::Updated {
+            full_scan: true,
+            new_instance: false,
+        });
     }
 
     // Cache merge conflict paths so they don't change from staging/unstaging,
@@ -4597,7 +4616,7 @@ fn status_from_proto(
 
     let Some(variant) = status.and_then(|status| status.variant) else {
         let code = proto::GitStatus::from_i32(simple_status)
-            .ok_or_else(|| anyhow!("Invalid git status code: {simple_status}"))?;
+            .with_context(|| format!("Invalid git status code: {simple_status}"))?;
         let result = match code {
             proto::GitStatus::Added => TrackedStatus {
                 worktree_status: StatusCode::Added,
@@ -4619,7 +4638,7 @@ fn status_from_proto(
                 index_status: StatusCode::Unmodified,
             }
             .into(),
-            _ => return Err(anyhow!("Invalid code for simple status: {simple_status}")),
+            _ => anyhow::bail!("Invalid code for simple status: {simple_status}"),
         };
         return Ok(result);
     };
@@ -4631,12 +4650,12 @@ fn status_from_proto(
             let [first_head, second_head] =
                 [unmerged.first_head, unmerged.second_head].map(|head| {
                     let code = proto::GitStatus::from_i32(head)
-                        .ok_or_else(|| anyhow!("Invalid git status code: {head}"))?;
+                        .with_context(|| format!("Invalid git status code: {head}"))?;
                     let result = match code {
                         proto::GitStatus::Added => UnmergedStatusCode::Added,
                         proto::GitStatus::Updated => UnmergedStatusCode::Updated,
                         proto::GitStatus::Deleted => UnmergedStatusCode::Deleted,
-                        _ => return Err(anyhow!("Invalid code for unmerged status: {code:?}")),
+                        _ => anyhow::bail!("Invalid code for unmerged status: {code:?}"),
                     };
                     Ok(result)
                 });
@@ -4651,7 +4670,7 @@ fn status_from_proto(
             let [index_status, worktree_status] = [tracked.index_status, tracked.worktree_status]
                 .map(|status| {
                     let code = proto::GitStatus::from_i32(status)
-                        .ok_or_else(|| anyhow!("Invalid git status code: {status}"))?;
+                        .with_context(|| format!("Invalid git status code: {status}"))?;
                     let result = match code {
                         proto::GitStatus::Modified => StatusCode::Modified,
                         proto::GitStatus::TypeChanged => StatusCode::TypeChanged,
@@ -4660,7 +4679,7 @@ fn status_from_proto(
                         proto::GitStatus::Renamed => StatusCode::Renamed,
                         proto::GitStatus::Copied => StatusCode::Copied,
                         proto::GitStatus::Unmodified => StatusCode::Unmodified,
-                        _ => return Err(anyhow!("Invalid code for tracked status: {code:?}")),
+                        _ => anyhow::bail!("Invalid code for tracked status: {code:?}"),
                     };
                     Ok(result)
                 });

crates/project/src/git_store/conflict_set.rs 🔗

@@ -171,7 +171,8 @@ impl ConflictSet {
         let mut conflicts = Vec::new();
 
         let mut line_pos = 0;
-        let mut lines = buffer.text_for_range(0..buffer.len()).lines();
+        let buffer_len = buffer.len();
+        let mut lines = buffer.text_for_range(0..buffer_len).lines();
 
         let mut conflict_start: Option<usize> = None;
         let mut ours_start: Option<usize> = None;
@@ -212,7 +213,7 @@ impl ConflictSet {
                 && theirs_start.is_some()
             {
                 let theirs_end = line_pos;
-                let conflict_end = line_end + 1;
+                let conflict_end = (line_end + 1).min(buffer_len);
 
                 let range = buffer.anchor_after(conflict_start.unwrap())
                     ..buffer.anchor_before(conflict_end);
@@ -254,7 +255,7 @@ impl EventEmitter<ConflictSetUpdate> for ConflictSet {}
 
 #[cfg(test)]
 mod tests {
-    use std::sync::mpsc;
+    use std::{path::Path, sync::mpsc};
 
     use crate::{Project, project_settings::ProjectSettings};
 
@@ -265,7 +266,7 @@ mod tests {
     use language::language_settings::AllLanguageSettings;
     use serde_json::json;
     use settings::Settings as _;
-    use text::{Buffer, BufferId, ToOffset as _};
+    use text::{Buffer, BufferId, Point, ToOffset as _};
     use unindent::Unindent as _;
     use util::path;
     use worktree::WorktreeSettings;
@@ -390,6 +391,22 @@ mod tests {
         assert_eq!(their_text, "This is their version in a nested conflict\n");
     }
 
+    #[test]
+    fn test_conflict_markers_at_eof() {
+        let test_content = r#"
+            <<<<<<< ours
+            =======
+            This is their version
+            >>>>>>> "#
+            .unindent();
+        let buffer_id = BufferId::new(1).unwrap();
+        let buffer = Buffer::new(0, buffer_id, test_content.to_string());
+        let snapshot = buffer.snapshot();
+
+        let conflict_snapshot = ConflictSet::parse(&snapshot);
+        assert_eq!(conflict_snapshot.conflicts.len(), 1);
+    }
+
     #[test]
     fn test_conflicts_in_range() {
         // Create a buffer with conflict markers
@@ -463,7 +480,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_conflict_updates(executor: BackgroundExecutor, cx: &mut TestAppContext) {
-        env_logger::try_init().ok();
+        zlog::init_test();
         cx.update(|cx| {
             settings::init(cx);
             WorktreeSettings::register(cx);
@@ -504,7 +521,8 @@ mod tests {
                 events_tx.send(event.clone()).ok();
             })
         });
-        let conflicts_snapshot = conflict_set.update(cx, |conflict_set, _| conflict_set.snapshot());
+        let conflicts_snapshot =
+            conflict_set.read_with(cx, |conflict_set, _| conflict_set.snapshot());
         assert!(conflicts_snapshot.conflicts.is_empty());
 
         buffer.update(cx, |buffer, cx| {
@@ -543,11 +561,11 @@ mod tests {
         assert_eq!(update.old_range, 0..0);
         assert_eq!(update.new_range, 0..1);
 
-        let conflict = conflict_set.update(cx, |conflict_set, _| {
+        let conflict = conflict_set.read_with(cx, |conflict_set, _| {
             conflict_set.snapshot().conflicts[0].clone()
         });
         cx.update(|cx| {
-            conflict.resolve(buffer.clone(), &[conflict.theirs.clone()], cx);
+            conflict.resolve(buffer.clone(), std::slice::from_ref(&conflict.theirs), cx);
         });
 
         cx.run_until_parked();
@@ -557,4 +575,106 @@ mod tests {
         assert_eq!(update.old_range, 0..1);
         assert_eq!(update.new_range, 0..0);
     }
+
+    #[gpui::test]
+    async fn test_conflict_updates_without_merge_head(
+        executor: BackgroundExecutor,
+        cx: &mut TestAppContext,
+    ) {
+        zlog::init_test();
+        cx.update(|cx| {
+            settings::init(cx);
+            WorktreeSettings::register(cx);
+            ProjectSettings::register(cx);
+            AllLanguageSettings::register(cx);
+        });
+
+        let initial_text = "
+            zero
+            <<<<<<< HEAD
+            one
+            =======
+            two
+            >>>>>>> Stashed Changes
+            three
+        "
+        .unindent();
+
+        let fs = FakeFs::new(executor);
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".git": {},
+                "a.txt": initial_text,
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
+        let (git_store, buffer) = project.update(cx, |project, cx| {
+            (
+                project.git_store().clone(),
+                project.open_local_buffer(path!("/project/a.txt"), cx),
+            )
+        });
+
+        cx.run_until_parked();
+        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
+            state.unmerged_paths.insert(
+                "a.txt".into(),
+                UnmergedStatus {
+                    first_head: UnmergedStatusCode::Updated,
+                    second_head: UnmergedStatusCode::Updated,
+                },
+            )
+        })
+        .unwrap();
+
+        let buffer = buffer.await.unwrap();
+
+        // Open the conflict set for a file that currently has conflicts.
+        let conflict_set = git_store.update(cx, |git_store, cx| {
+            git_store.open_conflict_set(buffer.clone(), cx)
+        });
+
+        cx.run_until_parked();
+        conflict_set.update(cx, |conflict_set, cx| {
+            let conflict_range = conflict_set.snapshot().conflicts[0]
+                .range
+                .to_point(buffer.read(cx));
+            assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
+        });
+
+        // Simulate the conflict being removed by e.g. staging the file.
+        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
+            state.unmerged_paths.remove(Path::new("a.txt"))
+        })
+        .unwrap();
+
+        cx.run_until_parked();
+        conflict_set.update(cx, |conflict_set, _| {
+            assert_eq!(conflict_set.has_conflict, false);
+            assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
+        });
+
+        // Simulate the conflict being re-added.
+        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
+            state.unmerged_paths.insert(
+                "a.txt".into(),
+                UnmergedStatus {
+                    first_head: UnmergedStatusCode::Updated,
+                    second_head: UnmergedStatusCode::Updated,
+                },
+            )
+        })
+        .unwrap();
+
+        cx.run_until_parked();
+        conflict_set.update(cx, |conflict_set, cx| {
+            let conflict_range = conflict_set.snapshot().conflicts[0]
+                .range
+                .to_point(buffer.read(cx));
+            assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
+        });
+    }
 }

crates/project/src/git_store/git_traversal.rs 🔗

@@ -211,7 +211,7 @@ pub struct GitEntry {
 }
 
 impl GitEntry {
-    pub fn to_ref(&self) -> GitEntryRef {
+    pub fn to_ref(&self) -> GitEntryRef<'_> {
         GitEntryRef {
             entry: &self.entry,
             git_summary: self.git_summary,
@@ -674,9 +674,7 @@ mod tests {
     }
 
     fn init_test(cx: &mut gpui::TestAppContext) {
-        if std::env::var("RUST_LOG").is_ok() {
-            env_logger::try_init().ok();
-        }
+        zlog::init_test();
 
         cx.update(|cx| {
             let settings_store = SettingsStore::test(cx);
@@ -743,6 +741,7 @@ mod tests {
                 ("a.txt".into(), "".into()),
                 ("b/c.txt".into(), "something-else".into()),
             ],
+            "deadbeef",
         );
         cx.executor().run_until_parked();
         cx.executor().advance_clock(Duration::from_secs(1));

crates/project/src/image_store.rs 🔗

@@ -2,7 +2,7 @@ use crate::{
     Project, ProjectEntryId, ProjectItem, ProjectPath,
     worktree_store::{WorktreeStore, WorktreeStoreEvent},
 };
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use collections::{HashMap, HashSet, hash_map};
 use futures::{StreamExt, channel::oneshot};
 use gpui::{
@@ -12,10 +12,10 @@ pub use image::ImageFormat;
 use image::{ExtendedColorType, GenericImageView, ImageReader};
 use language::{DiskState, File};
 use rpc::{AnyProtoClient, ErrorExt as _};
-use std::ffi::OsStr;
 use std::num::NonZeroU64;
 use std::path::Path;
 use std::sync::Arc;
+use std::{ffi::OsStr, path::PathBuf};
 use util::ResultExt;
 use worktree::{LoadedBinaryFile, PathChange, Worktree};
 
@@ -96,7 +96,7 @@ impl ImageColorInfo {
 
 pub struct ImageItem {
     pub id: ImageId,
-    pub file: Arc<dyn File>,
+    pub file: Arc<worktree::File>,
     pub image: Arc<gpui::Image>,
     reload_task: Option<Task<()>>,
     pub image_metadata: Option<ImageMetadata>,
@@ -109,22 +109,11 @@ impl ImageItem {
         cx: &mut AsyncApp,
     ) -> Result<ImageMetadata> {
         let (fs, image_path) = cx.update(|cx| {
-            let project_path = image.read(cx).project_path(cx);
-
-            let worktree = project
-                .read(cx)
-                .worktree_for_id(project_path.worktree_id, cx)
-                .ok_or_else(|| anyhow!("worktree not found"))?;
-            let worktree_root = worktree.read(cx).abs_path();
-            let image_path = image.read(cx).path();
-            let image_path = if image_path.is_absolute() {
-                image_path.to_path_buf()
-            } else {
-                worktree_root.join(image_path)
-            };
-
             let fs = project.read(cx).fs().clone();
-
+            let image_path = image
+                .read(cx)
+                .abs_path(cx)
+                .context("absolutizing image file path")?;
             anyhow::Ok((fs, image_path))
         })??;
 
@@ -139,7 +128,7 @@ impl ImageItem {
         let file_metadata = fs
             .metadata(image_path.as_path())
             .await?
-            .ok_or_else(|| anyhow!("failed to load image metadata"))?;
+            .context("failed to load image metadata")?;
 
         Ok(ImageMetadata {
             width,
@@ -157,14 +146,14 @@ impl ImageItem {
         }
     }
 
-    pub fn path(&self) -> &Arc<Path> {
-        self.file.path()
+    pub fn abs_path(&self, cx: &App) -> Option<PathBuf> {
+        Some(self.file.as_local()?.abs_path(cx))
     }
 
-    fn file_updated(&mut self, new_file: Arc<dyn File>, cx: &mut Context<Self>) {
+    fn file_updated(&mut self, new_file: Arc<worktree::File>, cx: &mut Context<Self>) {
         let mut file_changed = false;
 
-        let old_file = self.file.as_ref();
+        let old_file = &self.file;
         if new_file.path() != old_file.path() {
             file_changed = true;
         }
@@ -234,7 +223,7 @@ impl ProjectItem for ImageItem {
         project: &Entity<Project>,
         path: &ProjectPath,
         cx: &mut App,
-    ) -> Option<Task<gpui::Result<Entity<Self>>>> {
+    ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
         if is_image_file(&project, &path, cx) {
             Some(cx.spawn({
                 let path = path.clone();
@@ -251,7 +240,7 @@ impl ProjectItem for ImageItem {
     }
 
     fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
-        worktree::File::from_dyn(Some(&self.file))?.entry_id
+        self.file.entry_id
     }
 
     fn project_path(&self, cx: &App) -> Option<ProjectPath> {
@@ -604,9 +593,7 @@ impl LocalImageStore {
         };
 
         image.update(cx, |image, cx| {
-            let Some(old_file) = worktree::File::from_dyn(Some(&image.file)) else {
-                return;
-            };
+            let old_file = &image.file;
             if old_file.worktree != *worktree {
                 return;
             }
@@ -639,7 +626,7 @@ impl LocalImageStore {
                 }
             };
 
-            if new_file == *old_file {
+            if new_file == **old_file {
                 return;
             }
 
@@ -672,9 +659,10 @@ impl LocalImageStore {
     }
 
     fn image_changed_file(&mut self, image: Entity<ImageItem>, cx: &mut App) -> Option<()> {
-        let file = worktree::File::from_dyn(Some(&image.read(cx).file))?;
+        let image = image.read(cx);
+        let file = &image.file;
 
-        let image_id = image.read(cx).id;
+        let image_id = image.id;
         if let Some(entry_id) = file.entry_id {
             match self.local_image_ids_by_entry_id.get(&entry_id) {
                 Some(_) => {
@@ -708,7 +696,7 @@ fn create_gpui_image(content: Vec<u8>) -> anyhow::Result<Arc<gpui::Image>> {
             image::ImageFormat::Gif => gpui::ImageFormat::Gif,
             image::ImageFormat::Bmp => gpui::ImageFormat::Bmp,
             image::ImageFormat::Tiff => gpui::ImageFormat::Tiff,
-            _ => Err(anyhow::anyhow!("Image format not supported"))?,
+            format => anyhow::bail!("Image format {format:?} not supported"),
         },
         content,
     )))
@@ -751,9 +739,7 @@ mod tests {
     use std::path::PathBuf;
 
     pub fn init_test(cx: &mut TestAppContext) {
-        if std::env::var("RUST_LOG").is_ok() {
-            env_logger::try_init().ok();
-        }
+        zlog::init_test();
 
         cx.update(|cx| {
             let settings_store = SettingsStore::test(cx);

crates/project/src/lsp_command.rs 🔗

@@ -1,17 +1,18 @@
 mod signature_help;
 
 use crate::{
-    CodeAction, CompletionSource, CoreCompletion, DocumentHighlight, DocumentSymbol, Hover,
-    HoverBlock, HoverBlockKind, InlayHint, InlayHintLabel, InlayHintLabelPart,
-    InlayHintLabelPartTooltip, InlayHintTooltip, Location, LocationLink, LspAction, MarkupContent,
-    PrepareRenameResponse, ProjectTransaction, ResolveState,
+    CodeAction, CompletionSource, CoreCompletion, CoreCompletionResponse, DocumentColor,
+    DocumentHighlight, DocumentSymbol, Hover, HoverBlock, HoverBlockKind, InlayHint,
+    InlayHintLabel, InlayHintLabelPart, InlayHintLabelPartTooltip, InlayHintTooltip, Location,
+    LocationLink, LspAction, LspPullDiagnostics, MarkupContent, PrepareRenameResponse,
+    ProjectTransaction, PulledDiagnostics, ResolveState,
     lsp_store::{LocalLspStore, LspStore},
 };
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use async_trait::async_trait;
 use client::proto::{self, PeerId};
 use clock::Global;
-use collections::HashSet;
+use collections::{HashMap, HashSet};
 use futures::future;
 use gpui::{App, AsyncApp, Entity, Task};
 use language::{
@@ -23,14 +24,18 @@ use language::{
     range_from_lsp, range_to_lsp,
 };
 use lsp::{
-    AdapterServerCapabilities, CodeActionKind, CodeActionOptions, CompletionContext,
-    CompletionListItemDefaultsEditRange, CompletionTriggerKind, DocumentHighlightKind,
-    LanguageServer, LanguageServerId, LinkedEditingRangeServerCapabilities, OneOf, RenameOptions,
-    ServerCapabilities,
+    AdapterServerCapabilities, CodeActionKind, CodeActionOptions, CodeDescription,
+    CompletionContext, CompletionListItemDefaultsEditRange, CompletionTriggerKind,
+    DocumentHighlightKind, LanguageServer, LanguageServerId, LinkedEditingRangeServerCapabilities,
+    OneOf, RenameOptions, ServerCapabilities,
 };
+use serde_json::Value;
 use signature_help::{lsp_to_proto_signature, proto_to_lsp_signature};
-use std::{cmp::Reverse, mem, ops::Range, path::Path, sync::Arc};
+use std::{
+    cmp::Reverse, collections::hash_map, mem, ops::Range, path::Path, str::FromStr, sync::Arc,
+};
 use text::{BufferId, LineEnding};
+use util::{ResultExt as _, debug_panic};
 
 pub use signature_help::SignatureHelp;
 
@@ -45,12 +50,10 @@ pub fn lsp_formatting_options(settings: &LanguageSettings) -> lsp::FormattingOpt
     }
 }
 
-pub(crate) fn file_path_to_lsp_url(path: &Path) -> Result<lsp::Url> {
+pub fn file_path_to_lsp_url(path: &Path) -> Result<lsp::Url> {
     match lsp::Url::from_file_path(path) {
         Ok(url) => Ok(url),
-        Err(()) => Err(anyhow!(
-            "Invalid file path provided to LSP request: {path:?}"
-        )),
+        Err(()) => anyhow::bail!("Invalid file path provided to LSP request: {path:?}"),
     }
 }
 
@@ -104,9 +107,7 @@ pub trait LspCommand: 'static + Sized + Send + std::fmt::Debug {
     }
 
     /// When false, `to_lsp_params_or_response` default implementation will return the default response.
-    fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool {
-        true
-    }
+    fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool;
 
     fn to_lsp(
         &self,
@@ -241,6 +242,9 @@ pub(crate) struct InlayHints {
 #[derive(Debug, Copy, Clone)]
 pub(crate) struct GetCodeLens;
 
+#[derive(Debug, Copy, Clone)]
+pub(crate) struct GetDocumentColor;
+
 impl GetCodeLens {
     pub(crate) fn can_resolve_lens(capabilities: &ServerCapabilities) -> bool {
         capabilities
@@ -256,6 +260,11 @@ pub(crate) struct LinkedEditingRange {
     pub position: Anchor,
 }
 
+#[derive(Clone, Debug)]
+pub(crate) struct GetDocumentDiagnostics {
+    pub previous_result_id: Option<String>,
+}
+
 #[async_trait(?Send)]
 impl LspCommand for PrepareRename {
     type Response = PrepareRenameResponse;
@@ -266,6 +275,16 @@ impl LspCommand for PrepareRename {
         "Prepare rename"
     }
 
+    fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool {
+        capabilities
+            .server_capabilities
+            .rename_provider
+            .is_some_and(|capability| match capability {
+                OneOf::Left(enabled) => enabled,
+                OneOf::Right(options) => options.prepare_provider.unwrap_or(false),
+            })
+    }
+
     fn to_lsp_params_or_response(
         &self,
         path: &Path,
@@ -293,7 +312,7 @@ impl LspCommand for PrepareRename {
             Some(lsp::OneOf::Left(true)) => Ok(LspParamsOrResponse::Response(
                 PrepareRenameResponse::OnlyUnpreparedRenameSupported,
             )),
-            _ => Err(anyhow!("Rename not supported")),
+            _ => anyhow::bail!("Rename not supported"),
         }
     }
 
@@ -315,7 +334,7 @@ impl LspCommand for PrepareRename {
         _: LanguageServerId,
         mut cx: AsyncApp,
     ) -> Result<PrepareRenameResponse> {
-        buffer.update(&mut cx, |buffer, _| match message {
+        buffer.read_with(&mut cx, |buffer, _| match message {
             Some(lsp::PrepareRenameResponse::Range(range))
             | Some(lsp::PrepareRenameResponse::RangeWithPlaceholder { range, .. }) => {
                 let Range { start, end } = range_from_lsp(range);
@@ -359,7 +378,7 @@ impl LspCommand for PrepareRename {
         let position = message
             .position
             .and_then(deserialize_anchor)
-            .ok_or_else(|| anyhow!("invalid position"))?;
+            .context("invalid position")?;
         buffer
             .update(&mut cx, |buffer, _| {
                 buffer.wait_for_version(deserialize_version(&message.version))
@@ -367,7 +386,7 @@ impl LspCommand for PrepareRename {
             .await?;
 
         Ok(Self {
-            position: buffer.update(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
+            position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
         })
     }
 
@@ -422,9 +441,9 @@ impl LspCommand for PrepareRename {
             ) {
                 Ok(PrepareRenameResponse::Success(start..end))
             } else {
-                Err(anyhow!(
+                anyhow::bail!(
                     "Missing start or end position in remote project PrepareRenameResponse"
-                ))
+                );
             }
         } else if message.only_unprepared_rename_supported {
             Ok(PrepareRenameResponse::OnlyUnpreparedRenameSupported)
@@ -448,6 +467,16 @@ impl LspCommand for PerformRename {
         "Rename"
     }
 
+    fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool {
+        capabilities
+            .server_capabilities
+            .rename_provider
+            .is_some_and(|capability| match capability {
+                OneOf::Left(enabled) => enabled,
+                OneOf::Right(_options) => true,
+            })
+    }
+
     fn to_lsp(
         &self,
         path: &Path,
@@ -508,14 +537,14 @@ impl LspCommand for PerformRename {
         let position = message
             .position
             .and_then(deserialize_anchor)
-            .ok_or_else(|| anyhow!("invalid position"))?;
+            .context("invalid position")?;
         buffer
             .update(&mut cx, |buffer, _| {
                 buffer.wait_for_version(deserialize_version(&message.version))
             })?
             .await?;
         Ok(Self {
-            position: buffer.update(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
+            position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
             new_name: message.new_name,
             push_to_history: false,
         })
@@ -543,9 +572,7 @@ impl LspCommand for PerformRename {
         _: Entity<Buffer>,
         mut cx: AsyncApp,
     ) -> Result<ProjectTransaction> {
-        let message = message
-            .transaction
-            .ok_or_else(|| anyhow!("missing transaction"))?;
+        let message = message.transaction.context("missing transaction")?;
         lsp_store
             .update(&mut cx, |lsp_store, cx| {
                 lsp_store.buffer_store().update(cx, |buffer_store, cx| {
@@ -574,7 +601,10 @@ impl LspCommand for GetDefinition {
         capabilities
             .server_capabilities
             .definition_provider
-            .is_some()
+            .is_some_and(|capability| match capability {
+                OneOf::Left(supported) => supported,
+                OneOf::Right(_options) => true,
+            })
     }
 
     fn to_lsp(
@@ -622,14 +652,14 @@ impl LspCommand for GetDefinition {
         let position = message
             .position
             .and_then(deserialize_anchor)
-            .ok_or_else(|| anyhow!("invalid position"))?;
+            .context("invalid position")?;
         buffer
             .update(&mut cx, |buffer, _| {
                 buffer.wait_for_version(deserialize_version(&message.version))
             })?
             .await?;
         Ok(Self {
-            position: buffer.update(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
+            position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
         })
     }
 
@@ -673,7 +703,11 @@ impl LspCommand for GetDeclaration {
         capabilities
             .server_capabilities
             .declaration_provider
-            .is_some()
+            .is_some_and(|capability| match capability {
+                lsp::DeclarationCapability::Simple(supported) => supported,
+                lsp::DeclarationCapability::RegistrationOptions(..) => true,
+                lsp::DeclarationCapability::Options(..) => true,
+            })
     }
 
     fn to_lsp(
@@ -721,14 +755,14 @@ impl LspCommand for GetDeclaration {
         let position = message
             .position
             .and_then(deserialize_anchor)
-            .ok_or_else(|| anyhow!("invalid position"))?;
+            .context("invalid position")?;
         buffer
             .update(&mut cx, |buffer, _| {
                 buffer.wait_for_version(deserialize_version(&message.version))
             })?
             .await?;
         Ok(Self {
-            position: buffer.update(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
+            position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
         })
     }
 
@@ -768,6 +802,16 @@ impl LspCommand for GetImplementation {
         "Get implementation"
     }
 
+    fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool {
+        capabilities
+            .server_capabilities
+            .implementation_provider
+            .is_some_and(|capability| match capability {
+                lsp::ImplementationProviderCapability::Simple(enabled) => enabled,
+                lsp::ImplementationProviderCapability::Options(_options) => true,
+            })
+    }
+
     fn to_lsp(
         &self,
         path: &Path,
@@ -813,14 +857,14 @@ impl LspCommand for GetImplementation {
         let position = message
             .position
             .and_then(deserialize_anchor)
-            .ok_or_else(|| anyhow!("invalid position"))?;
+            .context("invalid position")?;
         buffer
             .update(&mut cx, |buffer, _| {
                 buffer.wait_for_version(deserialize_version(&message.version))
             })?
             .await?;
         Ok(Self {
-            position: buffer.update(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
+            position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
         })
     }
 
@@ -912,14 +956,14 @@ impl LspCommand for GetTypeDefinition {
         let position = message
             .position
             .and_then(deserialize_anchor)
-            .ok_or_else(|| anyhow!("invalid position"))?;
+            .context("invalid position")?;
         buffer
             .update(&mut cx, |buffer, _| {
                 buffer.wait_for_version(deserialize_version(&message.version))
             })?
             .await?;
         Ok(Self {
-            position: buffer.update(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
+            position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
         })
     }
 
@@ -963,7 +1007,7 @@ fn language_server_for_buffer(
                     .map(|(adapter, server)| (adapter.clone(), server.clone()))
             })
         })?
-        .ok_or_else(|| anyhow!("no language server found for buffer"))
+        .context("no language server found for buffer")
 }
 
 pub async fn location_links_from_proto(
@@ -997,11 +1041,11 @@ pub fn location_link_from_proto(
                 let start = origin
                     .start
                     .and_then(deserialize_anchor)
-                    .ok_or_else(|| anyhow!("missing origin start"))?;
+                    .context("missing origin start")?;
                 let end = origin
                     .end
                     .and_then(deserialize_anchor)
-                    .ok_or_else(|| anyhow!("missing origin end"))?;
+                    .context("missing origin end")?;
                 buffer
                     .update(cx, |buffer, _| buffer.wait_for_anchors([start, end]))?
                     .await?;
@@ -1013,7 +1057,7 @@ pub fn location_link_from_proto(
             None => None,
         };
 
-        let target = link.target.ok_or_else(|| anyhow!("missing target"))?;
+        let target = link.target.context("missing target")?;
         let buffer_id = BufferId::new(target.buffer_id)?;
         let buffer = lsp_store
             .update(cx, |lsp_store, cx| {
@@ -1023,11 +1067,11 @@ pub fn location_link_from_proto(
         let start = target
             .start
             .and_then(deserialize_anchor)
-            .ok_or_else(|| anyhow!("missing target start"))?;
+            .context("missing target start")?;
         let end = target
             .end
             .and_then(deserialize_anchor)
-            .ok_or_else(|| anyhow!("missing target end"))?;
+            .context("missing target end")?;
         buffer
             .update(cx, |buffer, _| buffer.wait_for_anchors([start, end]))?
             .await?;
@@ -1300,7 +1344,7 @@ impl LspCommand for GetReferences {
 
                 target_buffer_handle
                     .clone()
-                    .update(&mut cx, |target_buffer, _| {
+                    .read_with(&mut cx, |target_buffer, _| {
                         let target_start = target_buffer
                             .clip_point_utf16(point_from_lsp(lsp_location.range.start), Bias::Left);
                         let target_end = target_buffer
@@ -1337,14 +1381,14 @@ impl LspCommand for GetReferences {
         let position = message
             .position
             .and_then(deserialize_anchor)
-            .ok_or_else(|| anyhow!("invalid position"))?;
+            .context("invalid position")?;
         buffer
             .update(&mut cx, |buffer, _| {
                 buffer.wait_for_version(deserialize_version(&message.version))
             })?
             .await?;
         Ok(Self {
-            position: buffer.update(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
+            position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
         })
     }
 
@@ -1393,11 +1437,11 @@ impl LspCommand for GetReferences {
             let start = location
                 .start
                 .and_then(deserialize_anchor)
-                .ok_or_else(|| anyhow!("missing target start"))?;
+                .context("missing target start")?;
             let end = location
                 .end
                 .and_then(deserialize_anchor)
-                .ok_or_else(|| anyhow!("missing target end"))?;
+                .context("missing target end")?;
             target_buffer
                 .update(&mut cx, |buffer, _| buffer.wait_for_anchors([start, end]))?
                 .await?;
@@ -1428,7 +1472,10 @@ impl LspCommand for GetDocumentHighlights {
         capabilities
             .server_capabilities
             .document_highlight_provider
-            .is_some()
+            .is_some_and(|capability| match capability {
+                OneOf::Left(supported) => supported,
+                OneOf::Right(_options) => true,
+            })
     }
 
     fn to_lsp(
@@ -1453,7 +1500,7 @@ impl LspCommand for GetDocumentHighlights {
         _: LanguageServerId,
         mut cx: AsyncApp,
     ) -> Result<Vec<DocumentHighlight>> {
-        buffer.update(&mut cx, |buffer, _| {
+        buffer.read_with(&mut cx, |buffer, _| {
             let mut lsp_highlights = lsp_highlights.unwrap_or_default();
             lsp_highlights.sort_unstable_by_key(|h| (h.range.start, Reverse(h.range.end)));
             lsp_highlights
@@ -1494,14 +1541,14 @@ impl LspCommand for GetDocumentHighlights {
         let position = message
             .position
             .and_then(deserialize_anchor)
-            .ok_or_else(|| anyhow!("invalid position"))?;
+            .context("invalid position")?;
         buffer
             .update(&mut cx, |buffer, _| {
                 buffer.wait_for_version(deserialize_version(&message.version))
             })?
             .await?;
         Ok(Self {
-            position: buffer.update(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
+            position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
         })
     }
 
@@ -1540,11 +1587,11 @@ impl LspCommand for GetDocumentHighlights {
             let start = highlight
                 .start
                 .and_then(deserialize_anchor)
-                .ok_or_else(|| anyhow!("missing target start"))?;
+                .context("missing target start")?;
             let end = highlight
                 .end
                 .and_then(deserialize_anchor)
-                .ok_or_else(|| anyhow!("missing target end"))?;
+                .context("missing target end")?;
             buffer
                 .update(&mut cx, |buffer, _| buffer.wait_for_anchors([start, end]))?
                 .await?;
@@ -1581,7 +1628,10 @@ impl LspCommand for GetDocumentSymbols {
         capabilities
             .server_capabilities
             .document_symbol_provider
-            .is_some()
+            .is_some_and(|capability| match capability {
+                OneOf::Left(supported) => supported,
+                OneOf::Right(_options) => true,
+            })
     }
 
     fn to_lsp(
@@ -1723,19 +1773,15 @@ impl LspCommand for GetDocumentSymbols {
                 let kind =
                     unsafe { mem::transmute::<i32, lsp::SymbolKind>(serialized_symbol.kind) };
 
-                let start = serialized_symbol
-                    .start
-                    .ok_or_else(|| anyhow!("invalid start"))?;
-                let end = serialized_symbol
-                    .end
-                    .ok_or_else(|| anyhow!("invalid end"))?;
+                let start = serialized_symbol.start.context("invalid start")?;
+                let end = serialized_symbol.end.context("invalid end")?;
 
                 let selection_start = serialized_symbol
                     .selection_start
-                    .ok_or_else(|| anyhow!("invalid selection start"))?;
+                    .context("invalid selection start")?;
                 let selection_end = serialized_symbol
                     .selection_end
-                    .ok_or_else(|| anyhow!("invalid selection end"))?;
+                    .context("invalid selection end")?;
 
                 Ok(DocumentSymbol {
                     name: serialized_symbol.name,
@@ -1830,7 +1876,7 @@ impl LspCommand for GetSignatureHelp {
             })?
             .await
             .with_context(|| format!("waiting for version for buffer {}", buffer.entity_id()))?;
-        let buffer_snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?;
+        let buffer_snapshot = buffer.read_with(&mut cx, |buffer, _| buffer.snapshot())?;
         Ok(Self {
             position: payload
                 .position
@@ -1914,7 +1960,7 @@ impl LspCommand for GetHover {
             return Ok(None);
         };
 
-        let (language, range) = buffer.update(&mut cx, |buffer, _| {
+        let (language, range) = buffer.read_with(&mut cx, |buffer, _| {
             (
                 buffer.language().cloned(),
                 hover.range.map(|range| {
@@ -1993,14 +2039,14 @@ impl LspCommand for GetHover {
         let position = message
             .position
             .and_then(deserialize_anchor)
-            .ok_or_else(|| anyhow!("invalid position"))?;
+            .context("invalid position")?;
         buffer
             .update(&mut cx, |buffer, _| {
                 buffer.wait_for_version(deserialize_version(&message.version))
             })?
             .await?;
         Ok(Self {
-            position: buffer.update(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
+            position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
         })
     }
 
@@ -2074,7 +2120,7 @@ impl LspCommand for GetHover {
             return Ok(None);
         }
 
-        let language = buffer.update(&mut cx, |buffer, _| buffer.language().cloned())?;
+        let language = buffer.read_with(&mut cx, |buffer, _| buffer.language().cloned())?;
         let range = if let (Some(start), Some(end)) = (message.start, message.end) {
             language::proto::deserialize_anchor(start)
                 .and_then(|start| language::proto::deserialize_anchor(end).map(|end| start..end))
@@ -2103,7 +2149,7 @@ impl LspCommand for GetHover {
 
 #[async_trait(?Send)]
 impl LspCommand for GetCompletions {
-    type Response = Vec<CoreCompletion>;
+    type Response = CoreCompletionResponse;
     type LspRequest = lsp::request::Completion;
     type ProtoRequest = proto::GetCompletions;
 
@@ -2111,6 +2157,13 @@ impl LspCommand for GetCompletions {
         "Get completion"
     }
 
+    fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool {
+        capabilities
+            .server_capabilities
+            .completion_provider
+            .is_some()
+    }
+
     fn to_lsp(
         &self,
         path: &Path,
@@ -2135,21 +2188,24 @@ impl LspCommand for GetCompletions {
         mut cx: AsyncApp,
     ) -> Result<Self::Response> {
         let mut response_list = None;
-        let mut completions = if let Some(completions) = completions {
+        let (mut completions, mut is_incomplete) = if let Some(completions) = completions {
             match completions {
-                lsp::CompletionResponse::Array(completions) => completions,
+                lsp::CompletionResponse::Array(completions) => (completions, false),
                 lsp::CompletionResponse::List(mut list) => {
+                    let is_incomplete = list.is_incomplete;
                     let items = std::mem::take(&mut list.items);
                     response_list = Some(list);
-                    items
+                    (items, is_incomplete)
                 }
             }
         } else {
-            Vec::new()
+            (Vec::new(), false)
         };
 
+        let unfiltered_completions_count = completions.len();
+
         let language_server_adapter = lsp_store
-            .update(&mut cx, |lsp_store, _| {
+            .read_with(&mut cx, |lsp_store, _| {
                 lsp_store.language_server_adapter_for_id(server_id)
             })?
             .with_context(|| format!("no language server with id {server_id}"))?;
@@ -2267,11 +2323,17 @@ impl LspCommand for GetCompletions {
             });
         })?;
 
+        // If completions were filtered out due to errors that may be transient, mark the result
+        // incomplete so that it is re-queried.
+        if unfiltered_completions_count != completions.len() {
+            is_incomplete = true;
+        }
+
         language_server_adapter
             .process_completions(&mut completions)
             .await;
 
-        Ok(completions
+        let completions = completions
             .into_iter()
             .zip(completion_edits)
             .map(|(mut lsp_completion, mut edit)| {
@@ -2298,7 +2360,12 @@ impl LspCommand for GetCompletions {
                     },
                 }
             })
-            .collect())
+            .collect();
+
+        Ok(CoreCompletionResponse {
+            completions,
+            is_incomplete,
+        })
     }
 
     fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetCompletions {
@@ -2325,11 +2392,11 @@ impl LspCommand for GetCompletions {
             .position
             .and_then(language::proto::deserialize_anchor)
             .map(|p| {
-                buffer.update(&mut cx, |buffer, _| {
+                buffer.read_with(&mut cx, |buffer, _| {
                     buffer.clip_point_utf16(Unclipped(p.to_point_utf16(buffer)), Bias::Left)
                 })
             })
-            .ok_or_else(|| anyhow!("invalid position"))??;
+            .context("invalid position")??;
         Ok(Self {
             position,
             context: CompletionContext {
@@ -2340,18 +2407,20 @@ impl LspCommand for GetCompletions {
     }
 
     fn response_to_proto(
-        completions: Vec<CoreCompletion>,
+        response: CoreCompletionResponse,
         _: &mut LspStore,
         _: PeerId,
         buffer_version: &clock::Global,
         _: &mut App,
     ) -> proto::GetCompletionsResponse {
         proto::GetCompletionsResponse {
-            completions: completions
+            completions: response
+                .completions
                 .iter()
                 .map(LspStore::serialize_completion)
                 .collect(),
             version: serialize_version(buffer_version),
+            can_reuse: !response.is_incomplete,
         }
     }
 
@@ -2368,11 +2437,16 @@ impl LspCommand for GetCompletions {
             })?
             .await?;
 
-        message
+        let completions = message
             .completions
             .into_iter()
             .map(LspStore::deserialize_completion)
-            .collect()
+            .collect::<Result<Vec<_>>>()?;
+
+        Ok(CoreCompletionResponse {
+            completions,
+            is_incomplete: !message.can_reuse,
+        })
     }
 
     fn buffer_id_from_proto(message: &proto::GetCompletions) -> Result<BufferId> {
@@ -2597,11 +2671,11 @@ impl LspCommand for GetCodeActions {
         let start = message
             .start
             .and_then(language::proto::deserialize_anchor)
-            .ok_or_else(|| anyhow!("invalid start"))?;
+            .context("invalid start")?;
         let end = message
             .end
             .and_then(language::proto::deserialize_anchor)
-            .ok_or_else(|| anyhow!("invalid end"))?;
+            .context("invalid end")?;
         buffer
             .update(&mut cx, |buffer, _| {
                 buffer.wait_for_version(deserialize_version(&message.version))
@@ -2767,7 +2841,7 @@ impl LspCommand for OnTypeFormatting {
         let position = message
             .position
             .and_then(deserialize_anchor)
-            .ok_or_else(|| anyhow!("invalid position"))?;
+            .context("invalid position")?;
         buffer
             .update(&mut cx, |buffer, _| {
                 buffer.wait_for_version(deserialize_version(&message.version))
@@ -2781,7 +2855,7 @@ impl LspCommand for OnTypeFormatting {
         })?;
 
         Ok(Self {
-            position: buffer.update(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
+            position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
             trigger: message.trigger.clone(),
             options,
             push_to_history: false,
@@ -2834,7 +2908,7 @@ impl InlayHints {
             _ => None,
         });
 
-        let position = buffer_handle.update(cx, |buffer, _| {
+        let position = buffer_handle.read_with(cx, |buffer, _| {
             let position = buffer.clip_point_utf16(point_from_lsp(lsp_hint.position), Bias::Left);
             if kind == Some(InlayHintKind::Parameter) {
                 buffer.anchor_before(position)
@@ -3395,7 +3469,7 @@ impl LspCommand for GetCodeLens {
         server_id: LanguageServerId,
         mut cx: AsyncApp,
     ) -> anyhow::Result<Vec<CodeAction>> {
-        let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?;
+        let snapshot = buffer.read_with(&mut cx, |buffer, _| buffer.snapshot())?;
         let language_server = cx.update(|cx| {
             lsp_store
                 .read(cx)
@@ -3576,15 +3650,13 @@ impl LspCommand for LinkedEditingRange {
         buffer: Entity<Buffer>,
         mut cx: AsyncApp,
     ) -> Result<Self> {
-        let position = message
-            .position
-            .ok_or_else(|| anyhow!("invalid position"))?;
+        let position = message.position.context("invalid position")?;
         buffer
             .update(&mut cx, |buffer, _| {
                 buffer.wait_for_version(deserialize_version(&message.version))
             })?
             .await?;
-        let position = deserialize_anchor(position).ok_or_else(|| anyhow!("invalid position"))?;
+        let position = deserialize_anchor(position).context("invalid position")?;
         buffer
             .update(&mut cx, |buffer, _| buffer.wait_for_anchors([position]))?
             .await?;
@@ -3645,3 +3717,874 @@ impl LspCommand for LinkedEditingRange {
         BufferId::new(message.buffer_id)
     }
 }
+
+impl GetDocumentDiagnostics {
+    pub fn diagnostics_from_proto(
+        response: proto::GetDocumentDiagnosticsResponse,
+    ) -> Vec<LspPullDiagnostics> {
+        response
+            .pulled_diagnostics
+            .into_iter()
+            .filter_map(|diagnostics| {
+                Some(LspPullDiagnostics::Response {
+                    server_id: LanguageServerId::from_proto(diagnostics.server_id),
+                    uri: lsp::Url::from_str(diagnostics.uri.as_str()).log_err()?,
+                    diagnostics: if diagnostics.changed {
+                        PulledDiagnostics::Unchanged {
+                            result_id: diagnostics.result_id?,
+                        }
+                    } else {
+                        PulledDiagnostics::Changed {
+                            result_id: diagnostics.result_id,
+                            diagnostics: diagnostics
+                                .diagnostics
+                                .into_iter()
+                                .filter_map(|diagnostic| {
+                                    GetDocumentDiagnostics::deserialize_lsp_diagnostic(diagnostic)
+                                        .context("deserializing diagnostics")
+                                        .log_err()
+                                })
+                                .collect(),
+                        }
+                    },
+                })
+            })
+            .collect()
+    }
+
+    fn deserialize_lsp_diagnostic(diagnostic: proto::LspDiagnostic) -> Result<lsp::Diagnostic> {
+        let start = diagnostic.start.context("invalid start range")?;
+        let end = diagnostic.end.context("invalid end range")?;
+
+        let range = Range::<PointUtf16> {
+            start: PointUtf16 {
+                row: start.row,
+                column: start.column,
+            },
+            end: PointUtf16 {
+                row: end.row,
+                column: end.column,
+            },
+        };
+
+        let data = diagnostic.data.and_then(|data| Value::from_str(&data).ok());
+        let code = diagnostic.code.map(lsp::NumberOrString::String);
+
+        let related_information = diagnostic
+            .related_information
+            .into_iter()
+            .map(|info| {
+                let start = info.location_range_start.unwrap();
+                let end = info.location_range_end.unwrap();
+
+                lsp::DiagnosticRelatedInformation {
+                    location: lsp::Location {
+                        range: lsp::Range {
+                            start: point_to_lsp(PointUtf16::new(start.row, start.column)),
+                            end: point_to_lsp(PointUtf16::new(end.row, end.column)),
+                        },
+                        uri: lsp::Url::parse(&info.location_url.unwrap()).unwrap(),
+                    },
+                    message: info.message.clone(),
+                }
+            })
+            .collect::<Vec<_>>();
+
+        let tags = diagnostic
+            .tags
+            .into_iter()
+            .filter_map(|tag| match proto::LspDiagnosticTag::from_i32(tag) {
+                Some(proto::LspDiagnosticTag::Unnecessary) => Some(lsp::DiagnosticTag::UNNECESSARY),
+                Some(proto::LspDiagnosticTag::Deprecated) => Some(lsp::DiagnosticTag::DEPRECATED),
+                _ => None,
+            })
+            .collect::<Vec<_>>();
+
+        Ok(lsp::Diagnostic {
+            range: language::range_to_lsp(range)?,
+            severity: match proto::lsp_diagnostic::Severity::from_i32(diagnostic.severity).unwrap()
+            {
+                proto::lsp_diagnostic::Severity::Error => Some(lsp::DiagnosticSeverity::ERROR),
+                proto::lsp_diagnostic::Severity::Warning => Some(lsp::DiagnosticSeverity::WARNING),
+                proto::lsp_diagnostic::Severity::Information => {
+                    Some(lsp::DiagnosticSeverity::INFORMATION)
+                }
+                proto::lsp_diagnostic::Severity::Hint => Some(lsp::DiagnosticSeverity::HINT),
+                _ => None,
+            },
+            code,
+            code_description: match diagnostic.code_description {
+                Some(code_description) => Some(CodeDescription {
+                    href: lsp::Url::parse(&code_description).unwrap(),
+                }),
+                None => None,
+            },
+            related_information: Some(related_information),
+            tags: Some(tags),
+            source: diagnostic.source.clone(),
+            message: diagnostic.message,
+            data,
+        })
+    }
+
+    fn serialize_lsp_diagnostic(diagnostic: lsp::Diagnostic) -> Result<proto::LspDiagnostic> {
+        let range = language::range_from_lsp(diagnostic.range);
+        let related_information = diagnostic
+            .related_information
+            .unwrap_or_default()
+            .into_iter()
+            .map(|related_information| {
+                let location_range_start =
+                    point_from_lsp(related_information.location.range.start).0;
+                let location_range_end = point_from_lsp(related_information.location.range.end).0;
+
+                Ok(proto::LspDiagnosticRelatedInformation {
+                    location_url: Some(related_information.location.uri.to_string()),
+                    location_range_start: Some(proto::PointUtf16 {
+                        row: location_range_start.row,
+                        column: location_range_start.column,
+                    }),
+                    location_range_end: Some(proto::PointUtf16 {
+                        row: location_range_end.row,
+                        column: location_range_end.column,
+                    }),
+                    message: related_information.message,
+                })
+            })
+            .collect::<Result<Vec<_>>>()?;
+
+        let tags = diagnostic
+            .tags
+            .unwrap_or_default()
+            .into_iter()
+            .map(|tag| match tag {
+                lsp::DiagnosticTag::UNNECESSARY => proto::LspDiagnosticTag::Unnecessary,
+                lsp::DiagnosticTag::DEPRECATED => proto::LspDiagnosticTag::Deprecated,
+                _ => proto::LspDiagnosticTag::None,
+            } as i32)
+            .collect();
+
+        Ok(proto::LspDiagnostic {
+            start: Some(proto::PointUtf16 {
+                row: range.start.0.row,
+                column: range.start.0.column,
+            }),
+            end: Some(proto::PointUtf16 {
+                row: range.end.0.row,
+                column: range.end.0.column,
+            }),
+            severity: match diagnostic.severity {
+                Some(lsp::DiagnosticSeverity::ERROR) => proto::lsp_diagnostic::Severity::Error,
+                Some(lsp::DiagnosticSeverity::WARNING) => proto::lsp_diagnostic::Severity::Warning,
+                Some(lsp::DiagnosticSeverity::INFORMATION) => {
+                    proto::lsp_diagnostic::Severity::Information
+                }
+                Some(lsp::DiagnosticSeverity::HINT) => proto::lsp_diagnostic::Severity::Hint,
+                _ => proto::lsp_diagnostic::Severity::None,
+            } as i32,
+            code: diagnostic.code.as_ref().map(|code| match code {
+                lsp::NumberOrString::Number(code) => code.to_string(),
+                lsp::NumberOrString::String(code) => code.clone(),
+            }),
+            source: diagnostic.source.clone(),
+            related_information,
+            tags,
+            code_description: diagnostic
+                .code_description
+                .map(|desc| desc.href.to_string()),
+            message: diagnostic.message,
+            data: diagnostic.data.as_ref().map(|data| data.to_string()),
+        })
+    }
+
+    pub fn deserialize_workspace_diagnostics_report(
+        report: lsp::WorkspaceDiagnosticReportResult,
+        server_id: LanguageServerId,
+    ) -> Vec<WorkspaceLspPullDiagnostics> {
+        let mut pulled_diagnostics = HashMap::default();
+        match report {
+            lsp::WorkspaceDiagnosticReportResult::Report(workspace_diagnostic_report) => {
+                for report in workspace_diagnostic_report.items {
+                    match report {
+                        lsp::WorkspaceDocumentDiagnosticReport::Full(report) => {
+                            process_full_workspace_diagnostics_report(
+                                &mut pulled_diagnostics,
+                                server_id,
+                                report,
+                            )
+                        }
+                        lsp::WorkspaceDocumentDiagnosticReport::Unchanged(report) => {
+                            process_unchanged_workspace_diagnostics_report(
+                                &mut pulled_diagnostics,
+                                server_id,
+                                report,
+                            )
+                        }
+                    }
+                }
+            }
+            lsp::WorkspaceDiagnosticReportResult::Partial(
+                workspace_diagnostic_report_partial_result,
+            ) => {
+                for report in workspace_diagnostic_report_partial_result.items {
+                    match report {
+                        lsp::WorkspaceDocumentDiagnosticReport::Full(report) => {
+                            process_full_workspace_diagnostics_report(
+                                &mut pulled_diagnostics,
+                                server_id,
+                                report,
+                            )
+                        }
+                        lsp::WorkspaceDocumentDiagnosticReport::Unchanged(report) => {
+                            process_unchanged_workspace_diagnostics_report(
+                                &mut pulled_diagnostics,
+                                server_id,
+                                report,
+                            )
+                        }
+                    }
+                }
+            }
+        }
+        pulled_diagnostics.into_values().collect()
+    }
+}
+
+#[derive(Debug)]
+pub struct WorkspaceLspPullDiagnostics {
+    pub version: Option<i32>,
+    pub diagnostics: LspPullDiagnostics,
+}
+
+fn process_full_workspace_diagnostics_report(
+    diagnostics: &mut HashMap<lsp::Url, WorkspaceLspPullDiagnostics>,
+    server_id: LanguageServerId,
+    report: lsp::WorkspaceFullDocumentDiagnosticReport,
+) {
+    let mut new_diagnostics = HashMap::default();
+    process_full_diagnostics_report(
+        &mut new_diagnostics,
+        server_id,
+        report.uri,
+        report.full_document_diagnostic_report,
+    );
+    diagnostics.extend(new_diagnostics.into_iter().map(|(uri, diagnostics)| {
+        (
+            uri,
+            WorkspaceLspPullDiagnostics {
+                version: report.version.map(|v| v as i32),
+                diagnostics,
+            },
+        )
+    }));
+}
+
+fn process_unchanged_workspace_diagnostics_report(
+    diagnostics: &mut HashMap<lsp::Url, WorkspaceLspPullDiagnostics>,
+    server_id: LanguageServerId,
+    report: lsp::WorkspaceUnchangedDocumentDiagnosticReport,
+) {
+    let mut new_diagnostics = HashMap::default();
+    process_unchanged_diagnostics_report(
+        &mut new_diagnostics,
+        server_id,
+        report.uri,
+        report.unchanged_document_diagnostic_report,
+    );
+    diagnostics.extend(new_diagnostics.into_iter().map(|(uri, diagnostics)| {
+        (
+            uri,
+            WorkspaceLspPullDiagnostics {
+                version: report.version.map(|v| v as i32),
+                diagnostics,
+            },
+        )
+    }));
+}
+
+#[async_trait(?Send)]
+impl LspCommand for GetDocumentDiagnostics {
+    type Response = Vec<LspPullDiagnostics>;
+    type LspRequest = lsp::request::DocumentDiagnosticRequest;
+    type ProtoRequest = proto::GetDocumentDiagnostics;
+
+    fn display_name(&self) -> &str {
+        "Get diagnostics"
+    }
+
+    fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool {
+        server_capabilities
+            .server_capabilities
+            .diagnostic_provider
+            .is_some()
+    }
+
+    fn to_lsp(
+        &self,
+        path: &Path,
+        _: &Buffer,
+        language_server: &Arc<LanguageServer>,
+        _: &App,
+    ) -> Result<lsp::DocumentDiagnosticParams> {
+        let identifier = match language_server.capabilities().diagnostic_provider {
+            Some(lsp::DiagnosticServerCapabilities::Options(options)) => options.identifier,
+            Some(lsp::DiagnosticServerCapabilities::RegistrationOptions(options)) => {
+                options.diagnostic_options.identifier
+            }
+            None => None,
+        };
+
+        Ok(lsp::DocumentDiagnosticParams {
+            text_document: lsp::TextDocumentIdentifier {
+                uri: file_path_to_lsp_url(path)?,
+            },
+            identifier,
+            previous_result_id: self.previous_result_id.clone(),
+            partial_result_params: Default::default(),
+            work_done_progress_params: Default::default(),
+        })
+    }
+
+    async fn response_from_lsp(
+        self,
+        message: lsp::DocumentDiagnosticReportResult,
+        _: Entity<LspStore>,
+        buffer: Entity<Buffer>,
+        server_id: LanguageServerId,
+        cx: AsyncApp,
+    ) -> Result<Self::Response> {
+        let url = buffer.read_with(&cx, |buffer, cx| {
+            buffer
+                .file()
+                .and_then(|file| file.as_local())
+                .map(|file| {
+                    let abs_path = file.abs_path(cx);
+                    file_path_to_lsp_url(&abs_path)
+                })
+                .transpose()?
+                .with_context(|| format!("missing url on buffer {}", buffer.remote_id()))
+        })??;
+
+        let mut pulled_diagnostics = HashMap::default();
+        match message {
+            lsp::DocumentDiagnosticReportResult::Report(report) => match report {
+                lsp::DocumentDiagnosticReport::Full(report) => {
+                    if let Some(related_documents) = report.related_documents {
+                        process_related_documents(
+                            &mut pulled_diagnostics,
+                            server_id,
+                            related_documents,
+                        );
+                    }
+                    process_full_diagnostics_report(
+                        &mut pulled_diagnostics,
+                        server_id,
+                        url,
+                        report.full_document_diagnostic_report,
+                    );
+                }
+                lsp::DocumentDiagnosticReport::Unchanged(report) => {
+                    if let Some(related_documents) = report.related_documents {
+                        process_related_documents(
+                            &mut pulled_diagnostics,
+                            server_id,
+                            related_documents,
+                        );
+                    }
+                    process_unchanged_diagnostics_report(
+                        &mut pulled_diagnostics,
+                        server_id,
+                        url,
+                        report.unchanged_document_diagnostic_report,
+                    );
+                }
+            },
+            lsp::DocumentDiagnosticReportResult::Partial(report) => {
+                if let Some(related_documents) = report.related_documents {
+                    process_related_documents(
+                        &mut pulled_diagnostics,
+                        server_id,
+                        related_documents,
+                    );
+                }
+            }
+        }
+
+        Ok(pulled_diagnostics.into_values().collect())
+    }
+
+    fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::GetDocumentDiagnostics {
+        proto::GetDocumentDiagnostics {
+            project_id,
+            buffer_id: buffer.remote_id().into(),
+            version: serialize_version(&buffer.version()),
+        }
+    }
+
+    async fn from_proto(
+        _: proto::GetDocumentDiagnostics,
+        _: Entity<LspStore>,
+        _: Entity<Buffer>,
+        _: AsyncApp,
+    ) -> Result<Self> {
+        anyhow::bail!(
+            "proto::GetDocumentDiagnostics is not expected to be converted from proto directly, as it needs `previous_result_id` fetched first"
+        )
+    }
+
+    fn response_to_proto(
+        response: Self::Response,
+        _: &mut LspStore,
+        _: PeerId,
+        _: &clock::Global,
+        _: &mut App,
+    ) -> proto::GetDocumentDiagnosticsResponse {
+        let pulled_diagnostics = response
+            .into_iter()
+            .filter_map(|diagnostics| match diagnostics {
+                LspPullDiagnostics::Default => None,
+                LspPullDiagnostics::Response {
+                    server_id,
+                    uri,
+                    diagnostics,
+                } => {
+                    let mut changed = false;
+                    let (diagnostics, result_id) = match diagnostics {
+                        PulledDiagnostics::Unchanged { result_id } => (Vec::new(), Some(result_id)),
+                        PulledDiagnostics::Changed {
+                            result_id,
+                            diagnostics,
+                        } => {
+                            changed = true;
+                            (diagnostics, result_id)
+                        }
+                    };
+                    Some(proto::PulledDiagnostics {
+                        changed,
+                        result_id,
+                        uri: uri.to_string(),
+                        server_id: server_id.to_proto(),
+                        diagnostics: diagnostics
+                            .into_iter()
+                            .filter_map(|diagnostic| {
+                                GetDocumentDiagnostics::serialize_lsp_diagnostic(diagnostic)
+                                    .context("serializing diagnostics")
+                                    .log_err()
+                            })
+                            .collect(),
+                    })
+                }
+            })
+            .collect();
+
+        proto::GetDocumentDiagnosticsResponse { pulled_diagnostics }
+    }
+
+    async fn response_from_proto(
+        self,
+        response: proto::GetDocumentDiagnosticsResponse,
+        _: Entity<LspStore>,
+        _: Entity<Buffer>,
+        _: AsyncApp,
+    ) -> Result<Self::Response> {
+        Ok(Self::diagnostics_from_proto(response))
+    }
+
+    fn buffer_id_from_proto(message: &proto::GetDocumentDiagnostics) -> Result<BufferId> {
+        BufferId::new(message.buffer_id)
+    }
+}
+
+#[async_trait(?Send)]
+impl LspCommand for GetDocumentColor {
+    type Response = Vec<DocumentColor>;
+    type LspRequest = lsp::request::DocumentColor;
+    type ProtoRequest = proto::GetDocumentColor;
+
+    fn display_name(&self) -> &str {
+        "Document color"
+    }
+
+    fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool {
+        server_capabilities
+            .server_capabilities
+            .color_provider
+            .is_some_and(|capability| match capability {
+                lsp::ColorProviderCapability::Simple(supported) => supported,
+                lsp::ColorProviderCapability::ColorProvider(..) => true,
+                lsp::ColorProviderCapability::Options(..) => true,
+            })
+    }
+
+    fn to_lsp(
+        &self,
+        path: &Path,
+        _: &Buffer,
+        _: &Arc<LanguageServer>,
+        _: &App,
+    ) -> Result<lsp::DocumentColorParams> {
+        Ok(lsp::DocumentColorParams {
+            text_document: make_text_document_identifier(path)?,
+            work_done_progress_params: Default::default(),
+            partial_result_params: Default::default(),
+        })
+    }
+
+    async fn response_from_lsp(
+        self,
+        message: Vec<lsp::ColorInformation>,
+        _: Entity<LspStore>,
+        _: Entity<Buffer>,
+        _: LanguageServerId,
+        _: AsyncApp,
+    ) -> Result<Self::Response> {
+        Ok(message
+            .into_iter()
+            .map(|color| DocumentColor {
+                lsp_range: color.range,
+                color: color.color,
+                resolved: false,
+                color_presentations: Vec::new(),
+            })
+            .collect())
+    }
+
+    fn to_proto(&self, project_id: u64, buffer: &Buffer) -> Self::ProtoRequest {
+        proto::GetDocumentColor {
+            project_id,
+            buffer_id: buffer.remote_id().to_proto(),
+            version: serialize_version(&buffer.version()),
+        }
+    }
+
+    async fn from_proto(
+        _: Self::ProtoRequest,
+        _: Entity<LspStore>,
+        _: Entity<Buffer>,
+        _: AsyncApp,
+    ) -> Result<Self> {
+        Ok(Self {})
+    }
+
+    fn response_to_proto(
+        response: Self::Response,
+        _: &mut LspStore,
+        _: PeerId,
+        buffer_version: &clock::Global,
+        _: &mut App,
+    ) -> proto::GetDocumentColorResponse {
+        proto::GetDocumentColorResponse {
+            colors: response
+                .into_iter()
+                .map(|color| {
+                    let start = point_from_lsp(color.lsp_range.start).0;
+                    let end = point_from_lsp(color.lsp_range.end).0;
+                    proto::ColorInformation {
+                        red: color.color.red,
+                        green: color.color.green,
+                        blue: color.color.blue,
+                        alpha: color.color.alpha,
+                        lsp_range_start: Some(proto::PointUtf16 {
+                            row: start.row,
+                            column: start.column,
+                        }),
+                        lsp_range_end: Some(proto::PointUtf16 {
+                            row: end.row,
+                            column: end.column,
+                        }),
+                    }
+                })
+                .collect(),
+            version: serialize_version(buffer_version),
+        }
+    }
+
+    async fn response_from_proto(
+        self,
+        message: proto::GetDocumentColorResponse,
+        _: Entity<LspStore>,
+        _: Entity<Buffer>,
+        _: AsyncApp,
+    ) -> Result<Self::Response> {
+        Ok(message
+            .colors
+            .into_iter()
+            .filter_map(|color| {
+                let start = color.lsp_range_start?;
+                let start = PointUtf16::new(start.row, start.column);
+                let end = color.lsp_range_end?;
+                let end = PointUtf16::new(end.row, end.column);
+                Some(DocumentColor {
+                    resolved: false,
+                    color_presentations: Vec::new(),
+                    lsp_range: lsp::Range {
+                        start: point_to_lsp(start),
+                        end: point_to_lsp(end),
+                    },
+                    color: lsp::Color {
+                        red: color.red,
+                        green: color.green,
+                        blue: color.blue,
+                        alpha: color.alpha,
+                    },
+                })
+            })
+            .collect())
+    }
+
+    fn buffer_id_from_proto(message: &Self::ProtoRequest) -> Result<BufferId> {
+        BufferId::new(message.buffer_id)
+    }
+}
+
+fn process_related_documents(
+    diagnostics: &mut HashMap<lsp::Url, LspPullDiagnostics>,
+    server_id: LanguageServerId,
+    documents: impl IntoIterator<Item = (lsp::Url, lsp::DocumentDiagnosticReportKind)>,
+) {
+    for (url, report_kind) in documents {
+        match report_kind {
+            lsp::DocumentDiagnosticReportKind::Full(report) => {
+                process_full_diagnostics_report(diagnostics, server_id, url, report)
+            }
+            lsp::DocumentDiagnosticReportKind::Unchanged(report) => {
+                process_unchanged_diagnostics_report(diagnostics, server_id, url, report)
+            }
+        }
+    }
+}
+
+fn process_unchanged_diagnostics_report(
+    diagnostics: &mut HashMap<lsp::Url, LspPullDiagnostics>,
+    server_id: LanguageServerId,
+    uri: lsp::Url,
+    report: lsp::UnchangedDocumentDiagnosticReport,
+) {
+    let result_id = report.result_id;
+    match diagnostics.entry(uri.clone()) {
+        hash_map::Entry::Occupied(mut o) => match o.get_mut() {
+            LspPullDiagnostics::Default => {
+                o.insert(LspPullDiagnostics::Response {
+                    server_id,
+                    uri,
+                    diagnostics: PulledDiagnostics::Unchanged { result_id },
+                });
+            }
+            LspPullDiagnostics::Response {
+                server_id: existing_server_id,
+                uri: existing_uri,
+                diagnostics: existing_diagnostics,
+            } => {
+                if server_id != *existing_server_id || &uri != existing_uri {
+                    debug_panic!(
+                        "Unexpected state: file {uri} has two different sets of diagnostics reported"
+                    );
+                }
+                match existing_diagnostics {
+                    PulledDiagnostics::Unchanged { .. } => {
+                        *existing_diagnostics = PulledDiagnostics::Unchanged { result_id };
+                    }
+                    PulledDiagnostics::Changed { .. } => {}
+                }
+            }
+        },
+        hash_map::Entry::Vacant(v) => {
+            v.insert(LspPullDiagnostics::Response {
+                server_id,
+                uri,
+                diagnostics: PulledDiagnostics::Unchanged { result_id },
+            });
+        }
+    }
+}
+
+fn process_full_diagnostics_report(
+    diagnostics: &mut HashMap<lsp::Url, LspPullDiagnostics>,
+    server_id: LanguageServerId,
+    uri: lsp::Url,
+    report: lsp::FullDocumentDiagnosticReport,
+) {
+    let result_id = report.result_id;
+    match diagnostics.entry(uri.clone()) {
+        hash_map::Entry::Occupied(mut o) => match o.get_mut() {
+            LspPullDiagnostics::Default => {
+                o.insert(LspPullDiagnostics::Response {
+                    server_id,
+                    uri,
+                    diagnostics: PulledDiagnostics::Changed {
+                        result_id,
+                        diagnostics: report.items,
+                    },
+                });
+            }
+            LspPullDiagnostics::Response {
+                server_id: existing_server_id,
+                uri: existing_uri,
+                diagnostics: existing_diagnostics,
+            } => {
+                if server_id != *existing_server_id || &uri != existing_uri {
+                    debug_panic!(
+                        "Unexpected state: file {uri} has two different sets of diagnostics reported"
+                    );
+                }
+                match existing_diagnostics {
+                    PulledDiagnostics::Unchanged { .. } => {
+                        *existing_diagnostics = PulledDiagnostics::Changed {
+                            result_id,
+                            diagnostics: report.items,
+                        };
+                    }
+                    PulledDiagnostics::Changed {
+                        result_id: existing_result_id,
+                        diagnostics: existing_diagnostics,
+                    } => {
+                        if result_id.is_some() {
+                            *existing_result_id = result_id;
+                        }
+                        existing_diagnostics.extend(report.items);
+                    }
+                }
+            }
+        },
+        hash_map::Entry::Vacant(v) => {
+            v.insert(LspPullDiagnostics::Response {
+                server_id,
+                uri,
+                diagnostics: PulledDiagnostics::Changed {
+                    result_id,
+                    diagnostics: report.items,
+                },
+            });
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use lsp::{DiagnosticSeverity, DiagnosticTag};
+    use serde_json::json;
+
+    #[test]
+    fn test_serialize_lsp_diagnostic() {
+        let lsp_diagnostic = lsp::Diagnostic {
+            range: lsp::Range {
+                start: lsp::Position::new(0, 1),
+                end: lsp::Position::new(2, 3),
+            },
+            severity: Some(DiagnosticSeverity::ERROR),
+            code: Some(lsp::NumberOrString::String("E001".to_string())),
+            source: Some("test-source".to_string()),
+            message: "Test error message".to_string(),
+            related_information: None,
+            tags: Some(vec![DiagnosticTag::DEPRECATED]),
+            code_description: None,
+            data: Some(json!({"detail": "test detail"})),
+        };
+
+        let proto_diagnostic =
+            GetDocumentDiagnostics::serialize_lsp_diagnostic(lsp_diagnostic.clone())
+                .expect("Failed to serialize diagnostic");
+
+        let start = proto_diagnostic.start.unwrap();
+        let end = proto_diagnostic.end.unwrap();
+        assert_eq!(start.row, 0);
+        assert_eq!(start.column, 1);
+        assert_eq!(end.row, 2);
+        assert_eq!(end.column, 3);
+        assert_eq!(
+            proto_diagnostic.severity,
+            proto::lsp_diagnostic::Severity::Error as i32
+        );
+        assert_eq!(proto_diagnostic.code, Some("E001".to_string()));
+        assert_eq!(proto_diagnostic.source, Some("test-source".to_string()));
+        assert_eq!(proto_diagnostic.message, "Test error message");
+    }
+
+    #[test]
+    fn test_deserialize_lsp_diagnostic() {
+        let proto_diagnostic = proto::LspDiagnostic {
+            start: Some(proto::PointUtf16 { row: 0, column: 1 }),
+            end: Some(proto::PointUtf16 { row: 2, column: 3 }),
+            severity: proto::lsp_diagnostic::Severity::Warning as i32,
+            code: Some("ERR".to_string()),
+            source: Some("Prism".to_string()),
+            message: "assigned but unused variable - a".to_string(),
+            related_information: vec![],
+            tags: vec![],
+            code_description: None,
+            data: None,
+        };
+
+        let lsp_diagnostic = GetDocumentDiagnostics::deserialize_lsp_diagnostic(proto_diagnostic)
+            .expect("Failed to deserialize diagnostic");
+
+        assert_eq!(lsp_diagnostic.range.start.line, 0);
+        assert_eq!(lsp_diagnostic.range.start.character, 1);
+        assert_eq!(lsp_diagnostic.range.end.line, 2);
+        assert_eq!(lsp_diagnostic.range.end.character, 3);
+        assert_eq!(lsp_diagnostic.severity, Some(DiagnosticSeverity::WARNING));
+        assert_eq!(
+            lsp_diagnostic.code,
+            Some(lsp::NumberOrString::String("ERR".to_string()))
+        );
+        assert_eq!(lsp_diagnostic.source, Some("Prism".to_string()));
+        assert_eq!(lsp_diagnostic.message, "assigned but unused variable - a");
+    }
+
+    #[test]
+    fn test_related_information() {
+        let related_info = lsp::DiagnosticRelatedInformation {
+            location: lsp::Location {
+                uri: lsp::Url::parse("file:///test.rs").unwrap(),
+                range: lsp::Range {
+                    start: lsp::Position::new(1, 1),
+                    end: lsp::Position::new(1, 5),
+                },
+            },
+            message: "Related info message".to_string(),
+        };
+
+        let lsp_diagnostic = lsp::Diagnostic {
+            range: lsp::Range {
+                start: lsp::Position::new(0, 0),
+                end: lsp::Position::new(0, 1),
+            },
+            severity: Some(DiagnosticSeverity::INFORMATION),
+            code: None,
+            source: Some("Prism".to_string()),
+            message: "assigned but unused variable - a".to_string(),
+            related_information: Some(vec![related_info]),
+            tags: None,
+            code_description: None,
+            data: None,
+        };
+
+        let proto_diagnostic = GetDocumentDiagnostics::serialize_lsp_diagnostic(lsp_diagnostic)
+            .expect("Failed to serialize diagnostic");
+
+        assert_eq!(proto_diagnostic.related_information.len(), 1);
+        let related = &proto_diagnostic.related_information[0];
+        assert_eq!(related.location_url, Some("file:///test.rs".to_string()));
+        assert_eq!(related.message, "Related info message");
+    }
+
+    #[test]
+    fn test_invalid_ranges() {
+        let proto_diagnostic = proto::LspDiagnostic {
+            start: None,
+            end: Some(proto::PointUtf16 { row: 2, column: 3 }),
+            severity: proto::lsp_diagnostic::Severity::Error as i32,
+            code: None,
+            source: None,
+            message: "Test message".to_string(),
+            related_information: vec![],
+            tags: vec![],
+            code_description: None,
+            data: None,
+        };
+
+        let result = GetDocumentDiagnostics::deserialize_lsp_diagnostic(proto_diagnostic);
+        assert!(result.is_err());
+    }
+}

crates/project/src/lsp_store.rs 🔗

@@ -3,13 +3,17 @@ pub mod lsp_ext_command;
 pub mod rust_analyzer_ext;
 
 use crate::{
-    CodeAction, Completion, CompletionSource, CoreCompletion, Hover, InlayHint, LspAction,
-    ProjectItem, ProjectPath, ProjectTransaction, ResolveState, Symbol, ToolchainStore,
+    CodeAction, ColorPresentation, Completion, CompletionResponse, CompletionSource,
+    CoreCompletion, DocumentColor, Hover, InlayHint, LspAction, LspPullDiagnostics, ProjectItem,
+    ProjectPath, ProjectTransaction, PulledDiagnostics, ResolveState, Symbol, ToolchainStore,
     buffer_store::{BufferStore, BufferStoreEvent},
     environment::ProjectEnvironment,
     lsp_command::{self, *},
     lsp_store,
-    manifest_tree::{AdapterQuery, LanguageServerTree, LaunchDisposition, ManifestTree},
+    manifest_tree::{
+        AdapterQuery, LanguageServerTree, LanguageServerTreeNode, LaunchDisposition,
+        ManifestQueryDelegate, ManifestTree,
+    },
     prettier_store::{self, PrettierStore, PrettierStoreEvent},
     project_settings::{LspSettings, ProjectSettings},
     relativize_path, resolve_path,
@@ -20,6 +24,7 @@ use crate::{
 use anyhow::{Context as _, Result, anyhow};
 use async_trait::async_trait;
 use client::{TypedEnvelope, proto};
+use clock::Global;
 use collections::{BTreeMap, BTreeSet, HashMap, HashSet, btree_map};
 use futures::{
     AsyncWriteExt, Future, FutureExt, StreamExt,
@@ -36,14 +41,17 @@ use http_client::HttpClient;
 use itertools::Itertools as _;
 use language::{
     Bias, BinaryStatus, Buffer, BufferSnapshot, CachedLspAdapter, CodeLabel, Diagnostic,
-    DiagnosticEntry, DiagnosticSet, Diff, File as _, Language, LanguageRegistry,
-    LanguageToolchainStore, LocalFile, LspAdapter, LspAdapterDelegate, Patch, PointUtf16,
-    TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped,
+    DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff, File as _, Language, LanguageName,
+    LanguageRegistry, LanguageToolchainStore, LocalFile, LspAdapter, LspAdapterDelegate, Patch,
+    PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped,
     language_settings::{
         FormatOnSave, Formatter, LanguageSettings, SelectedFormatter, language_settings,
     },
     point_to_lsp,
-    proto::{deserialize_anchor, deserialize_version, serialize_anchor, serialize_version},
+    proto::{
+        deserialize_anchor, deserialize_lsp_edit, deserialize_version, serialize_anchor,
+        serialize_lsp_edit, serialize_version,
+    },
     range_from_lsp, range_to_lsp,
 };
 use lsp::{
@@ -51,13 +59,13 @@ use lsp::{
     DidChangeWatchedFilesRegistrationOptions, Edit, FileOperationFilter, FileOperationPatternKind,
     FileOperationRegistrationOptions, FileRename, FileSystemWatcher, LanguageServer,
     LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerId, LanguageServerName,
-    LspRequestFuture, MessageActionItem, MessageType, OneOf, RenameFilesParams, SymbolKind,
-    TextEdit, WillRenameFiles, WorkDoneProgressCancelParams, WorkspaceFolder,
-    notification::DidRenameFiles,
+    LanguageServerSelector, LspRequestFuture, MessageActionItem, MessageType, OneOf,
+    RenameFilesParams, SymbolKind, TextEdit, WillRenameFiles, WorkDoneProgressCancelParams,
+    WorkspaceFolder, notification::DidRenameFiles,
 };
 use node_runtime::read_package_installed_version;
 use parking_lot::Mutex;
-use postage::watch;
+use postage::{mpsc, sink::Sink, stream::Stream, watch};
 use rand::prelude::*;
 
 use rpc::{
@@ -73,7 +81,7 @@ use std::{
     any::Any,
     borrow::Cow,
     cell::RefCell,
-    cmp::Ordering,
+    cmp::{Ordering, Reverse},
     convert::TryInto,
     ffi::OsStr,
     iter, mem,
@@ -86,7 +94,7 @@ use std::{
 use text::{Anchor, BufferId, LineEnding, OffsetRangeExt};
 use url::Url;
 use util::{
-    ResultExt as _, debug_panic, defer, maybe, merge_json_value_into,
+    ConnectionResult, ResultExt as _, debug_panic, defer, maybe, merge_json_value_into,
     paths::{PathExt, SanitizedPath},
     post_inc,
 };
@@ -162,6 +170,7 @@ pub struct LocalLspStore {
     _subscription: gpui::Subscription,
     lsp_tree: Entity<LanguageServerTree>,
     registered_buffers: HashMap<BufferId, usize>,
+    buffer_pull_diagnostics_result_ids: HashMap<LanguageServerId, HashMap<PathBuf, Option<String>>>,
 }
 
 impl LocalLspStore {
@@ -246,12 +255,17 @@ impl LocalLspStore {
             let delegate = delegate as Arc<dyn LspAdapterDelegate>;
             let key = key.clone();
             let adapter = adapter.clone();
-            let this = self.weak.clone();
+            let lsp_store = self.weak.clone();
             let pending_workspace_folders = pending_workspace_folders.clone();
             let fs = self.fs.clone();
+            let pull_diagnostics = ProjectSettings::get_global(cx)
+                .diagnostics
+                .lsp_pull_diagnostics
+                .enabled;
             cx.spawn(async move |cx| {
                 let result = async {
-                    let toolchains = this.update(cx, |this, cx| this.toolchain_store(cx))?;
+                    let toolchains =
+                        lsp_store.update(cx, |lsp_store, cx| lsp_store.toolchain_store(cx))?;
                     let language_server = pending_server.await?;
 
                     let workspace_config = Self::workspace_configuration_for_adapter(
@@ -279,13 +293,14 @@ impl LocalLspStore {
                     }
 
                     let initialization_params = cx.update(|cx| {
-                        let mut params = language_server.default_initialize_params(cx);
+                        let mut params =
+                            language_server.default_initialize_params(pull_diagnostics, cx);
                         params.initialization_options = initialization_options;
                         adapter.adapter.prepare_initialize_params(params, cx)
                     })??;
 
                     Self::setup_lsp_messages(
-                        this.clone(),
+                        lsp_store.clone(),
                         fs,
                         &language_server,
                         delegate.clone(),
@@ -306,11 +321,13 @@ impl LocalLspStore {
                         })?
                         .await
                         .inspect_err(|_| {
-                            if let Some(this) = this.upgrade() {
-                                this.update(cx, |_, cx| {
-                                    cx.emit(LspStoreEvent::LanguageServerRemoved(server_id))
-                                })
-                                .ok();
+                            if let Some(lsp_store) = lsp_store.upgrade() {
+                                lsp_store
+                                    .update(cx, |lsp_store, cx| {
+                                        lsp_store.cleanup_lsp_data(server_id);
+                                        cx.emit(LspStoreEvent::LanguageServerRemoved(server_id))
+                                    })
+                                    .ok();
                             }
                         })?;
 
@@ -326,17 +343,18 @@ impl LocalLspStore {
 
                 match result {
                     Ok(server) => {
-                        this.update(cx, |this, mut cx| {
-                            this.insert_newly_running_language_server(
-                                adapter,
-                                server.clone(),
-                                server_id,
-                                key,
-                                pending_workspace_folders,
-                                &mut cx,
-                            );
-                        })
-                        .ok();
+                        lsp_store
+                            .update(cx, |lsp_store, mut cx| {
+                                lsp_store.insert_newly_running_language_server(
+                                    adapter,
+                                    server.clone(),
+                                    server_id,
+                                    key,
+                                    pending_workspace_folders,
+                                    &mut cx,
+                                );
+                            })
+                            .ok();
                         stderr_capture.lock().take();
                         Some(server)
                     }
@@ -346,11 +364,13 @@ impl LocalLspStore {
                         delegate.update_status(
                             adapter.name(),
                             BinaryStatus::Failed {
-                                error: format!("{err}\n-- stderr--\n{}", log),
+                                error: format!("{err}\n-- stderr--\n{log}"),
                             },
                         );
-                        log::error!("Failed to start language server {server_name:?}: {err}");
-                        log::error!("server stderr: {:?}", log);
+                        let message =
+                            format!("Failed to start language server {server_name:?}: {err:#?}");
+                        log::error!("{message}");
+                        log::error!("server stderr: {log}");
                         None
                     }
                 }
@@ -361,6 +381,9 @@ impl LocalLspStore {
             pending_workspace_folders,
         };
 
+        self.languages
+            .update_lsp_binary_status(adapter.name(), BinaryStatus::Starting);
+
         self.language_servers.insert(server_id, state);
         self.language_server_ids
             .entry(key)
@@ -471,8 +494,15 @@ impl LocalLspStore {
                             this.merge_diagnostics(
                                 server_id,
                                 params,
+                                None,
+                                DiagnosticSourceKind::Pushed,
                                 &adapter.disk_based_diagnostic_sources,
-                                |diagnostic, cx| adapter.retain_old_diagnostic(diagnostic, cx),
+                                |_, diagnostic, cx| match diagnostic.source_kind {
+                                    DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => {
+                                        adapter.retain_old_diagnostic(diagnostic, cx)
+                                    }
+                                    DiagnosticSourceKind::Pulled => true,
+                                },
                                 cx,
                             )
                             .log_err();
@@ -533,8 +563,8 @@ impl LocalLspStore {
                     let this = this.clone();
                     let mut cx = cx.clone();
                     async move {
-                        let Some(server) =
-                            this.update(&mut cx, |this, _| this.language_server_for_id(server_id))?
+                        let Some(server) = this
+                            .read_with(&mut cx, |this, _| this.language_server_for_id(server_id))?
                         else {
                             return Ok(None);
                         };
@@ -598,7 +628,7 @@ impl LocalLspStore {
                                     }
                                 }
                                 "textDocument/rangeFormatting" => {
-                                    this.update(&mut cx, |this, _| {
+                                    this.read_with(&mut cx, |this, _| {
                                         if let Some(server) = this.language_server_for_id(server_id)
                                         {
                                             let options = reg
@@ -624,7 +654,7 @@ impl LocalLspStore {
                                     })??;
                                 }
                                 "textDocument/onTypeFormatting" => {
-                                    this.update(&mut cx, |this, _| {
+                                    this.read_with(&mut cx, |this, _| {
                                         if let Some(server) = this.language_server_for_id(server_id)
                                         {
                                             let options = reg
@@ -649,7 +679,7 @@ impl LocalLspStore {
                                     })??;
                                 }
                                 "textDocument/formatting" => {
-                                    this.update(&mut cx, |this, _| {
+                                    this.read_with(&mut cx, |this, _| {
                                         if let Some(server) = this.language_server_for_id(server_id)
                                         {
                                             let options = reg
@@ -678,7 +708,7 @@ impl LocalLspStore {
                                     // Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings.
                                 }
                                 "textDocument/rename" => {
-                                    this.update(&mut cx, |this, _| {
+                                    this.read_with(&mut cx, |this, _| {
                                         if let Some(server) = this.language_server_for_id(server_id)
                                         {
                                             let options = reg
@@ -732,7 +762,7 @@ impl LocalLspStore {
                                     // Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings.
                                 }
                                 "textDocument/rename" => {
-                                    this.update(&mut cx, |this, _| {
+                                    this.read_with(&mut cx, |this, _| {
                                         if let Some(server) = this.language_server_for_id(server_id)
                                         {
                                             server.update_capabilities(|capabilities| {
@@ -742,7 +772,7 @@ impl LocalLspStore {
                                     })?;
                                 }
                                 "textDocument/rangeFormatting" => {
-                                    this.update(&mut cx, |this, _| {
+                                    this.read_with(&mut cx, |this, _| {
                                         if let Some(server) = this.language_server_for_id(server_id)
                                         {
                                             server.update_capabilities(|capabilities| {
@@ -753,7 +783,7 @@ impl LocalLspStore {
                                     })?;
                                 }
                                 "textDocument/onTypeFormatting" => {
-                                    this.update(&mut cx, |this, _| {
+                                    this.read_with(&mut cx, |this, _| {
                                         if let Some(server) = this.language_server_for_id(server_id)
                                         {
                                             server.update_capabilities(|capabilities| {
@@ -764,7 +794,7 @@ impl LocalLspStore {
                                     })?;
                                 }
                                 "textDocument/formatting" => {
-                                    this.update(&mut cx, |this, _| {
+                                    this.read_with(&mut cx, |this, _| {
                                         if let Some(server) = this.language_server_for_id(server_id)
                                         {
                                             server.update_capabilities(|capabilities| {
@@ -848,6 +878,32 @@ impl LocalLspStore {
             })
             .detach();
 
+        language_server
+            .on_request::<lsp::request::WorkspaceDiagnosticRefresh, _, _>({
+                let this = this.clone();
+                move |(), cx| {
+                    let this = this.clone();
+                    let mut cx = cx.clone();
+                    async move {
+                        this.update(&mut cx, |lsp_store, _| {
+                            lsp_store.pull_workspace_diagnostics(server_id);
+                            lsp_store
+                                .downstream_client
+                                .as_ref()
+                                .map(|(client, project_id)| {
+                                    client.send(proto::PullWorkspaceDiagnostics {
+                                        project_id: *project_id,
+                                        server_id: server_id.to_proto(),
+                                    })
+                                })
+                        })?
+                        .transpose()?;
+                        Ok(())
+                    }
+                }
+            })
+            .detach();
+
         language_server
             .on_request::<lsp::request::ShowMessageRequest, _, _>({
                 let this = this.clone();
@@ -978,25 +1034,37 @@ impl LocalLspStore {
         clangd_ext::register_notifications(this, language_server, adapter);
     }
 
-    fn shutdown_language_servers(
+    fn shutdown_language_servers_on_quit(
         &mut self,
-        _cx: &mut Context<LspStore>,
+        _: &mut Context<LspStore>,
     ) -> impl Future<Output = ()> + use<> {
         let shutdown_futures = self
             .language_servers
             .drain()
-            .map(|(_, server_state)| async {
-                use LanguageServerState::*;
-                match server_state {
-                    Running { server, .. } => server.shutdown()?.await,
-                    Starting { startup, .. } => startup.await?.shutdown()?.await,
-                }
-            })
+            .map(|(_, server_state)| Self::shutdown_server(server_state))
             .collect::<Vec<_>>();
 
         async move {
-            futures::future::join_all(shutdown_futures).await;
+            join_all(shutdown_futures).await;
+        }
+    }
+
+    async fn shutdown_server(server_state: LanguageServerState) -> anyhow::Result<()> {
+        match server_state {
+            LanguageServerState::Running { server, .. } => {
+                if let Some(shutdown) = server.shutdown() {
+                    shutdown.await;
+                }
+            }
+            LanguageServerState::Starting { startup, .. } => {
+                if let Some(server) = startup.await {
+                    if let Some(shutdown) = server.shutdown() {
+                        shutdown.await;
+                    }
+                }
+            }
         }
+        Ok(())
     }
 
     fn language_servers_for_worktree(
@@ -1032,9 +1100,9 @@ impl LocalLspStore {
             .read(cx)
             .worktree_for_id(project_path.worktree_id, cx)
         else {
-            return vec![];
+            return Vec::new();
         };
-        let delegate = LocalLspAdapterDelegate::from_local_lsp(self, &worktree, cx);
+        let delegate = Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot()));
         let root = self.lsp_tree.update(cx, |this, cx| {
             this.get(
                 project_path,
@@ -1202,7 +1270,7 @@ impl LocalLspStore {
                 buffer.finalize_last_transaction();
                 let transaction_id = buffer
                     .start_transaction()
-                    .ok_or_else(|| anyhow!("transaction already open"))?;
+                    .context("transaction already open")?;
                 let transaction = buffer
                     .get_transaction(transaction_id)
                     .expect("transaction started")
@@ -1860,14 +1928,13 @@ impl LocalLspStore {
         let capabilities = &language_server.capabilities();
         let range_formatting_provider = capabilities.document_range_formatting_provider.as_ref();
         if range_formatting_provider.map_or(false, |provider| provider == &OneOf::Left(false)) {
-            return Err(anyhow!(
+            anyhow::bail!(
                 "{} language server does not support range formatting",
                 language_server.name()
-            ));
+            );
         }
 
-        let uri = lsp::Url::from_file_path(abs_path)
-            .map_err(|_| anyhow!("failed to convert abs path to uri"))?;
+        let uri = file_path_to_lsp_url(abs_path)?;
         let text_document = lsp::TextDocumentIdentifier::new(uri);
 
         let lsp_edits = {
@@ -1931,8 +1998,7 @@ impl LocalLspStore {
         let logger = zlog::scoped!("lsp_format");
         zlog::info!(logger => "Formatting via LSP");
 
-        let uri = lsp::Url::from_file_path(abs_path)
-            .map_err(|_| anyhow!("failed to convert abs path to uri"))?;
+        let uri = file_path_to_lsp_url(abs_path)?;
         let text_document = lsp::TextDocumentIdentifier::new(uri);
         let capabilities = &language_server.capabilities();
 
@@ -1952,7 +2018,7 @@ impl LocalLspStore {
         } else if matches!(range_formatting_provider, Some(p) if *p != OneOf::Left(false)) {
             let _timer = zlog::time!(logger => "format-range");
             let buffer_start = lsp::Position::new(0, 0);
-            let buffer_end = buffer.update(cx, |b, _| point_to_lsp(b.max_point_utf16()))?;
+            let buffer_end = buffer.read_with(cx, |b, _| point_to_lsp(b.max_point_utf16()))?;
             language_server
                 .request::<lsp::request::RangeFormatting>(lsp::DocumentRangeFormattingParams {
                     text_document: text_document.clone(),
@@ -2024,27 +2090,23 @@ impl LocalLspStore {
             .stderr(smol::process::Stdio::piped())
             .spawn()?;
 
-        let stdin = child
-            .stdin
-            .as_mut()
-            .ok_or_else(|| anyhow!("failed to acquire stdin"))?;
+        let stdin = child.stdin.as_mut().context("failed to acquire stdin")?;
         let text = buffer
             .handle
-            .update(cx, |buffer, _| buffer.as_rope().clone())?;
+            .read_with(cx, |buffer, _| buffer.as_rope().clone())?;
         for chunk in text.chunks() {
             stdin.write_all(chunk.as_bytes()).await?;
         }
         stdin.flush().await?;
 
         let output = child.output().await?;
-        if !output.status.success() {
-            return Err(anyhow!(
-                "command failed with exit code {:?}:\nstdout: {}\nstderr: {}",
-                output.status.code(),
-                String::from_utf8_lossy(&output.stdout),
-                String::from_utf8_lossy(&output.stderr),
-            ));
-        }
+        anyhow::ensure!(
+            output.status.success(),
+            "command failed with exit code {:?}:\nstdout: {}\nstderr: {}",
+            output.status.code(),
+            String::from_utf8_lossy(&output.stdout),
+            String::from_utf8_lossy(&output.stderr),
+        );
 
         let stdout = String::from_utf8(output.stdout)?;
         Ok(Some(
@@ -2107,8 +2169,16 @@ impl LocalLspStore {
             for (server_id, diagnostics) in
                 diagnostics.get(file.path()).cloned().unwrap_or_default()
             {
-                self.update_buffer_diagnostics(buffer_handle, server_id, None, diagnostics, cx)
-                    .log_err();
+                self.update_buffer_diagnostics(
+                    buffer_handle,
+                    server_id,
+                    None,
+                    None,
+                    diagnostics,
+                    Vec::new(),
+                    cx,
+                )
+                .log_err();
             }
         }
         let Some(language) = language else {
@@ -2177,8 +2247,10 @@ impl LocalLspStore {
         &mut self,
         buffer: &Entity<Buffer>,
         server_id: LanguageServerId,
+        result_id: Option<String>,
         version: Option<i32>,
-        mut diagnostics: Vec<DiagnosticEntry<Unclipped<PointUtf16>>>,
+        new_diagnostics: Vec<DiagnosticEntry<Unclipped<PointUtf16>>>,
+        reused_diagnostics: Vec<DiagnosticEntry<Unclipped<PointUtf16>>>,
         cx: &mut Context<LspStore>,
     ) -> Result<()> {
         fn compare_diagnostics(a: &Diagnostic, b: &Diagnostic) -> Ordering {
@@ -2189,7 +2261,11 @@ impl LocalLspStore {
                 .then_with(|| a.message.cmp(&b.message))
         }
 
-        diagnostics.sort_unstable_by(|a, b| {
+        let mut diagnostics = Vec::with_capacity(new_diagnostics.len() + reused_diagnostics.len());
+        diagnostics.extend(new_diagnostics.into_iter().map(|d| (true, d)));
+        diagnostics.extend(reused_diagnostics.into_iter().map(|d| (false, d)));
+
+        diagnostics.sort_unstable_by(|(_, a), (_, b)| {
             Ordering::Equal
                 .then_with(|| a.range.start.cmp(&b.range.start))
                 .then_with(|| b.range.end.cmp(&a.range.end))
@@ -2205,13 +2281,15 @@ impl LocalLspStore {
 
         let mut sanitized_diagnostics = Vec::with_capacity(diagnostics.len());
 
-        for entry in diagnostics {
+        for (new_diagnostic, entry) in diagnostics {
             let start;
             let end;
-            if entry.diagnostic.is_disk_based {
+            if new_diagnostic && entry.diagnostic.is_disk_based {
                 // Some diagnostics are based on files on disk instead of buffers'
                 // current contents. Adjust these diagnostics' ranges to reflect
                 // any unsaved edits.
+                // Do not alter the reused ones though, as their coordinates were stored as anchors
+                // and were properly adjusted on reuse.
                 start = Unclipped((*edits_since_save).old_to_new(entry.range.start.0));
                 end = Unclipped((*edits_since_save).old_to_new(entry.range.end.0));
             } else {
@@ -2242,14 +2320,23 @@ impl LocalLspStore {
 
         let set = DiagnosticSet::new(sanitized_diagnostics, &snapshot);
         buffer.update(cx, |buffer, cx| {
+            if let Some(abs_path) = File::from_dyn(buffer.file()).map(|f| f.abs_path(cx)) {
+                self.buffer_pull_diagnostics_result_ids
+                    .entry(server_id)
+                    .or_default()
+                    .insert(abs_path, result_id);
+            }
+
             buffer.update_diagnostics(server_id, set, cx)
         });
+
         Ok(())
     }
 
     fn register_buffer_with_language_servers(
         &mut self,
         buffer_handle: &Entity<Buffer>,
+        only_register_servers: HashSet<LanguageServerSelector>,
         cx: &mut Context<LspStore>,
     ) {
         let buffer = buffer_handle.read(cx);
@@ -2263,7 +2350,7 @@ impl LocalLspStore {
         }
 
         let abs_path = file.abs_path(cx);
-        let Some(uri) = lsp::Url::from_file_path(&abs_path).log_err() else {
+        let Some(uri) = file_path_to_lsp_url(&abs_path).log_err() else {
             return;
         };
         let initial_snapshot = buffer.text_snapshot();
@@ -2284,96 +2371,143 @@ impl LocalLspStore {
         else {
             return;
         };
-        let delegate = LocalLspAdapterDelegate::from_local_lsp(self, &worktree, cx);
-        let servers = self.lsp_tree.clone().update(cx, |this, cx| {
-            this.get(
-                ProjectPath { worktree_id, path },
-                AdapterQuery::Language(&language.name()),
-                delegate.clone(),
-                cx,
-            )
-            .collect::<Vec<_>>()
-        });
-        let servers = servers
+        let language_name = language.name();
+        let (reused, delegate, servers) = self
+            .lsp_tree
+            .update(cx, |lsp_tree, cx| {
+                self.reuse_existing_language_server(lsp_tree, &worktree, &language_name, cx)
+            })
+            .map(|(delegate, servers)| (true, delegate, servers))
+            .unwrap_or_else(|| {
+                let lsp_delegate = LocalLspAdapterDelegate::from_local_lsp(self, &worktree, cx);
+                let delegate = Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot()));
+                let servers = self
+                    .lsp_tree
+                    .clone()
+                    .update(cx, |language_server_tree, cx| {
+                        language_server_tree
+                            .get(
+                                ProjectPath { worktree_id, path },
+                                AdapterQuery::Language(&language.name()),
+                                delegate.clone(),
+                                cx,
+                            )
+                            .collect::<Vec<_>>()
+                    });
+                (false, lsp_delegate, servers)
+            });
+        let servers_and_adapters = servers
             .into_iter()
             .filter_map(|server_node| {
+                if reused && server_node.server_id().is_none() {
+                    return None;
+                }
+                if !only_register_servers.is_empty() {
+                    if let Some(server_id) = server_node.server_id() {
+                        if !only_register_servers.contains(&LanguageServerSelector::Id(server_id)) {
+                            return None;
+                        }
+                    }
+                    if let Some(name) = server_node.name() {
+                        if !only_register_servers.contains(&LanguageServerSelector::Name(name)) {
+                            return None;
+                        }
+                    }
+                }
+
                 let server_id = server_node.server_id_or_init(
                     |LaunchDisposition {
                          server_name,
                          attach,
                          path,
                          settings,
-                     }| match attach {
-                        language::Attach::InstancePerRoot => {
-                            // todo: handle instance per root proper.
-                            if let Some(server_ids) = self
-                                .language_server_ids
-                                .get(&(worktree_id, server_name.clone()))
-                            {
-                                server_ids.iter().cloned().next().unwrap()
-                            } else {
-                                let language_name = language.name();
-
-                                self.start_language_server(
-                                    &worktree,
-                                    delegate.clone(),
-                                    self.languages
-                                        .lsp_adapters(&language_name)
-                                        .into_iter()
-                                        .find(|adapter| &adapter.name() == server_name)
-                                        .expect("To find LSP adapter"),
-                                    settings,
-                                    cx,
-                                )
-                            }
-                        }
-                        language::Attach::Shared => {
-                            let uri = Url::from_file_path(
-                                worktree.read(cx).abs_path().join(&path.path),
-                            );
-                            let key = (worktree_id, server_name.clone());
-                            if !self.language_server_ids.contains_key(&key) {
-                                let language_name = language.name();
-                                self.start_language_server(
-                                    &worktree,
-                                    delegate.clone(),
-                                    self.languages
-                                        .lsp_adapters(&language_name)
-                                        .into_iter()
-                                        .find(|adapter| &adapter.name() == server_name)
-                                        .expect("To find LSP adapter"),
-                                    settings,
-                                    cx,
-                                );
-                            }
-                            if let Some(server_ids) = self
-                                .language_server_ids
-                                .get(&key)
-                            {
-                                debug_assert_eq!(server_ids.len(), 1);
-                                let server_id = server_ids.iter().cloned().next().unwrap();
-
-                                if let Some(state) = self.language_servers.get(&server_id) {
-                                    if let Ok(uri) = uri {
-                                        state.add_workspace_folder(uri);
-                                    };
-                                }
-                                server_id
-                            } else {
-                                unreachable!("Language server ID should be available, as it's registered on demand")
-                            }
-                        }
+                     }| {
+                        let server_id = match attach {
+                           language::Attach::InstancePerRoot => {
+                               // todo: handle instance per root proper.
+                               if let Some(server_ids) = self
+                                   .language_server_ids
+                                   .get(&(worktree_id, server_name.clone()))
+                               {
+                                   server_ids.iter().cloned().next().unwrap()
+                               } else {
+                                   let language_name = language.name();
+                                   let adapter = self.languages
+                                       .lsp_adapters(&language_name)
+                                       .into_iter()
+                                       .find(|adapter| &adapter.name() == server_name)
+                                       .expect("To find LSP adapter");
+                                   let server_id = self.start_language_server(
+                                       &worktree,
+                                       delegate.clone(),
+                                       adapter,
+                                       settings,
+                                       cx,
+                                   );
+                                   server_id
+                               }
+                           }
+                           language::Attach::Shared => {
+                               let uri = Url::from_file_path(
+                                   worktree.read(cx).abs_path().join(&path.path),
+                               );
+                               let key = (worktree_id, server_name.clone());
+                               if !self.language_server_ids.contains_key(&key) {
+                                   let language_name = language.name();
+                                   let adapter = self.languages
+                                       .lsp_adapters(&language_name)
+                                       .into_iter()
+                                       .find(|adapter| &adapter.name() == server_name)
+                                       .expect("To find LSP adapter");
+                                   self.start_language_server(
+                                       &worktree,
+                                       delegate.clone(),
+                                       adapter,
+                                       settings,
+                                       cx,
+                                   );
+                               }
+                               if let Some(server_ids) = self
+                                   .language_server_ids
+                                   .get(&key)
+                               {
+                                   debug_assert_eq!(server_ids.len(), 1);
+                                   let server_id = server_ids.iter().cloned().next().unwrap();
+                                   if let Some(state) = self.language_servers.get(&server_id) {
+                                       if let Ok(uri) = uri {
+                                           state.add_workspace_folder(uri);
+                                       };
+                                   }
+                                   server_id
+                               } else {
+                                   unreachable!("Language server ID should be available, as it's registered on demand")
+                               }
+                           }
+                        };
+                        let lsp_store = self.weak.clone();
+                        let server_name = server_node.name();
+                        let buffer_abs_path = abs_path.to_string_lossy().to_string();
+                        cx.defer(move |cx| {
+                            lsp_store.update(cx, |_, cx| cx.emit(LspStoreEvent::LanguageServerUpdate {
+                                language_server_id: server_id,
+                                name: server_name,
+                                message: proto::update_language_server::Variant::RegisteredForBuffer(proto::RegisteredForBuffer {
+                                    buffer_abs_path,
+                                })
+                            })).ok();
+                        });
+                        server_id
                     },
                 )?;
                 let server_state = self.language_servers.get(&server_id)?;
-                if let LanguageServerState::Running { server, .. } = server_state {
-                    Some(server.clone())
+                if let LanguageServerState::Running { server, adapter, .. } = server_state {
+                    Some((server.clone(), adapter.clone()))
                 } else {
                     None
                 }
             })
             .collect::<Vec<_>>();
-        for server in servers {
+        for (server, adapter) in servers_and_adapters {
             buffer_handle.update(cx, |buffer, cx| {
                 buffer.set_completion_triggers(
                     server.server_id(),
@@ -2391,48 +2525,94 @@ impl LocalLspStore {
                     cx,
                 );
             });
-        }
-        for adapter in self.languages.lsp_adapters(&language.name()) {
-            let servers = self
-                .language_server_ids
-                .get(&(worktree_id, adapter.name.clone()))
-                .map(|ids| {
-                    ids.iter().flat_map(|id| {
-                        self.language_servers.get(id).and_then(|server_state| {
-                            if let LanguageServerState::Running { server, .. } = server_state {
-                                Some(server.clone())
-                            } else {
-                                None
-                            }
-                        })
-                    })
-                });
-            let servers = match servers {
-                Some(server) => server,
-                None => continue,
+
+            let snapshot = LspBufferSnapshot {
+                version: 0,
+                snapshot: initial_snapshot.clone(),
             };
 
-            for server in servers {
-                let snapshot = LspBufferSnapshot {
-                    version: 0,
-                    snapshot: initial_snapshot.clone(),
-                };
-                self.buffer_snapshots
-                    .entry(buffer_id)
-                    .or_default()
-                    .entry(server.server_id())
-                    .or_insert_with(|| {
-                        server.register_buffer(
-                            uri.clone(),
-                            adapter.language_id(&language.name()),
-                            0,
-                            initial_snapshot.text(),
-                        );
+            self.buffer_snapshots
+                .entry(buffer_id)
+                .or_default()
+                .entry(server.server_id())
+                .or_insert_with(|| {
+                    server.register_buffer(
+                        uri.clone(),
+                        adapter.language_id(&language.name()),
+                        0,
+                        initial_snapshot.text(),
+                    );
 
-                        vec![snapshot]
-                    });
-            }
+                    vec![snapshot]
+                });
+
+            cx.emit(LspStoreEvent::LanguageServerUpdate {
+                language_server_id: server.server_id(),
+                name: None,
+                message: proto::update_language_server::Variant::RegisteredForBuffer(
+                    proto::RegisteredForBuffer {
+                        buffer_abs_path: abs_path.to_string_lossy().to_string(),
+                    },
+                ),
+            });
+        }
+    }
+
+    fn reuse_existing_language_server(
+        &self,
+        server_tree: &mut LanguageServerTree,
+        worktree: &Entity<Worktree>,
+        language_name: &LanguageName,
+        cx: &mut App,
+    ) -> Option<(Arc<LocalLspAdapterDelegate>, Vec<LanguageServerTreeNode>)> {
+        if worktree.read(cx).is_visible() {
+            return None;
+        }
+
+        let worktree_store = self.worktree_store.read(cx);
+        let servers = server_tree
+            .instances
+            .iter()
+            .filter(|(worktree_id, _)| {
+                worktree_store
+                    .worktree_for_id(**worktree_id, cx)
+                    .is_some_and(|worktree| worktree.read(cx).is_visible())
+            })
+            .flat_map(|(worktree_id, servers)| {
+                servers
+                    .roots
+                    .iter()
+                    .flat_map(|(_, language_servers)| language_servers)
+                    .map(move |(_, (server_node, server_languages))| {
+                        (worktree_id, server_node, server_languages)
+                    })
+                    .filter(|(_, _, server_languages)| server_languages.contains(language_name))
+                    .map(|(worktree_id, server_node, _)| {
+                        (
+                            *worktree_id,
+                            LanguageServerTreeNode::from(Arc::downgrade(server_node)),
+                        )
+                    })
+            })
+            .fold(HashMap::default(), |mut acc, (worktree_id, server_node)| {
+                acc.entry(worktree_id)
+                    .or_insert_with(Vec::new)
+                    .push(server_node);
+                acc
+            })
+            .into_values()
+            .max_by_key(|servers| servers.len())?;
+
+        for server_node in &servers {
+            server_tree.register_reused(
+                worktree.read(cx).id(),
+                language_name.clone(),
+                server_node.clone(),
+            );
         }
+
+        let delegate = LocalLspAdapterDelegate::from_local_lsp(self, worktree, cx);
+        Some((delegate, servers))
     }
 
     pub(crate) fn unregister_old_buffer_from_language_servers(
@@ -2493,9 +2673,7 @@ impl LocalLspStore {
                 // We detect this case and treat it as if the version was `None`.
                 return Ok(buffer.read(cx).text_snapshot());
             } else {
-                return Err(anyhow!(
-                    "no snapshots found for buffer {buffer_id} and server {server_id}"
-                ));
+                anyhow::bail!("no snapshots found for buffer {buffer_id} and server {server_id}");
             };
 
             let found_snapshot = snapshots
@@ -2540,7 +2718,7 @@ impl LocalLspStore {
         push_to_history: bool,
         project_transaction: &mut ProjectTransaction,
         cx: &mut AsyncApp,
-    ) -> Result<(), anyhow::Error> {
+    ) -> anyhow::Result<()> {
         for mut action in actions {
             Self::try_resolve_code_action(language_server, &mut action)
                 .await
@@ -2769,7 +2947,7 @@ impl LocalLspStore {
                     let abs_path = op
                         .uri
                         .to_file_path()
-                        .map_err(|_| anyhow!("can't convert URI to path"))?;
+                        .map_err(|()| anyhow!("can't convert URI to path"))?;
 
                     if let Some(parent_path) = abs_path.parent() {
                         fs.create_dir(parent_path).await?;
@@ -2794,11 +2972,11 @@ impl LocalLspStore {
                     let source_abs_path = op
                         .old_uri
                         .to_file_path()
-                        .map_err(|_| anyhow!("can't convert URI to path"))?;
+                        .map_err(|()| anyhow!("can't convert URI to path"))?;
                     let target_abs_path = op
                         .new_uri
                         .to_file_path()
-                        .map_err(|_| anyhow!("can't convert URI to path"))?;
+                        .map_err(|()| anyhow!("can't convert URI to path"))?;
                     fs.rename(
                         &source_abs_path,
                         &target_abs_path,
@@ -2816,7 +2994,7 @@ impl LocalLspStore {
                     let abs_path = op
                         .uri
                         .to_file_path()
-                        .map_err(|_| anyhow!("can't convert URI to path"))?;
+                        .map_err(|()| anyhow!("can't convert URI to path"))?;
                     let options = op
                         .options
                         .map(|options| fs::RemoveOptions {
@@ -2965,12 +3143,10 @@ impl LocalLspStore {
         adapter: Arc<CachedLspAdapter>,
         cx: &mut AsyncApp,
     ) -> Result<lsp::ApplyWorkspaceEditResponse> {
-        let this = this
-            .upgrade()
-            .ok_or_else(|| anyhow!("project project closed"))?;
+        let this = this.upgrade().context("project project closed")?;
         let language_server = this
-            .update(cx, |this, _| this.language_server_for_id(server_id))?
-            .ok_or_else(|| anyhow!("language server not found"))?;
+            .read_with(cx, |this, _| this.language_server_for_id(server_id))?
+            .context("language server not found")?;
         let transaction = Self::deserialize_workspace_edit(
             this.clone(),
             params.edit,
@@ -3024,12 +3200,14 @@ impl LocalLspStore {
                     server_ids.remove(server_id_to_remove);
                 });
             self.language_server_watched_paths
-                .remove(&server_id_to_remove);
+                .remove(server_id_to_remove);
             self.language_server_paths_watched_for_rename
-                .remove(&server_id_to_remove);
+                .remove(server_id_to_remove);
             self.last_workspace_edits_by_language_server
-                .remove(&server_id_to_remove);
-            self.language_servers.remove(&server_id_to_remove);
+                .remove(server_id_to_remove);
+            self.language_servers.remove(server_id_to_remove);
+            self.buffer_pull_diagnostics_result_ids
+                .remove(server_id_to_remove);
             cx.emit(LspStoreEvent::LanguageServerRemoved(*server_id_to_remove));
         }
         servers_to_remove.into_keys().collect()

crates/project/src/lsp_store/clangd_ext.rs 🔗

@@ -2,7 +2,7 @@ use std::sync::Arc;
 
 use ::serde::{Deserialize, Serialize};
 use gpui::WeakEntity;
-use language::{CachedLspAdapter, Diagnostic};
+use language::{CachedLspAdapter, Diagnostic, DiagnosticSourceKind};
 use lsp::LanguageServer;
 use util::ResultExt as _;
 
@@ -10,6 +10,7 @@ use crate::LspStore;
 
 pub const CLANGD_SERVER_NAME: &str = "clangd";
 const INACTIVE_REGION_MESSAGE: &str = "inactive region";
+const INACTIVE_DIAGNOSTIC_SEVERITY: lsp::DiagnosticSeverity = lsp::DiagnosticSeverity::INFORMATION;
 
 #[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize)]
 #[serde(rename_all = "camelCase")]
@@ -28,7 +29,16 @@ impl lsp::notification::Notification for InactiveRegions {
 
 pub fn is_inactive_region(diag: &Diagnostic) -> bool {
     diag.is_unnecessary
-        && diag.severity == lsp::DiagnosticSeverity::INFORMATION
+        && diag.severity == INACTIVE_DIAGNOSTIC_SEVERITY
+        && diag.message == INACTIVE_REGION_MESSAGE
+        && diag
+            .source
+            .as_ref()
+            .is_some_and(|v| v == CLANGD_SERVER_NAME)
+}
+
+pub fn is_lsp_inactive_region(diag: &lsp::Diagnostic) -> bool {
+    diag.severity == Some(INACTIVE_DIAGNOSTIC_SEVERITY)
         && diag.message == INACTIVE_REGION_MESSAGE
         && diag
             .source
@@ -59,11 +69,11 @@ pub fn register_notifications(
                         .into_iter()
                         .map(|range| lsp::Diagnostic {
                             range,
-                            severity: Some(lsp::DiagnosticSeverity::INFORMATION),
+                            severity: Some(INACTIVE_DIAGNOSTIC_SEVERITY),
                             source: Some(CLANGD_SERVER_NAME.to_string()),
                             message: INACTIVE_REGION_MESSAGE.to_string(),
                             tags: Some(vec![lsp::DiagnosticTag::UNNECESSARY]),
-                            ..Default::default()
+                            ..lsp::Diagnostic::default()
                         })
                         .collect();
                     let mapped_diagnostics = lsp::PublishDiagnosticsParams {
@@ -74,8 +84,10 @@ pub fn register_notifications(
                     this.merge_diagnostics(
                         server_id,
                         mapped_diagnostics,
+                        None,
+                        DiagnosticSourceKind::Pushed,
                         &adapter.disk_based_diagnostic_sources,
-                        |diag, _| !is_inactive_region(diag),
+                        |_, diag, _| !is_inactive_region(diag),
                         cx,
                     )
                     .log_err();

crates/project/src/lsp_store/lsp_ext_command.rs 🔗

@@ -1,8 +1,9 @@
 use crate::{
     LocationLink,
     lsp_command::{
-        LspCommand, location_link_from_lsp, location_link_from_proto, location_link_to_proto,
-        location_links_from_lsp, location_links_from_proto, location_links_to_proto,
+        LspCommand, file_path_to_lsp_url, location_link_from_lsp, location_link_from_proto,
+        location_link_to_proto, location_links_from_lsp, location_links_from_proto,
+        location_links_to_proto,
     },
     lsp_store::LspStore,
     make_lsp_text_document_position, make_text_document_identifier,
@@ -15,7 +16,7 @@ use language::{
     Buffer, point_to_lsp,
     proto::{deserialize_anchor, serialize_anchor},
 };
-use lsp::{LanguageServer, LanguageServerId};
+use lsp::{AdapterServerCapabilities, LanguageServer, LanguageServerId};
 use rpc::proto::{self, PeerId};
 use serde::{Deserialize, Serialize};
 use std::{
@@ -67,6 +68,10 @@ impl LspCommand for ExpandMacro {
         "Expand macro"
     }
 
+    fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool {
+        true
+    }
+
     fn to_lsp(
         &self,
         path: &Path,
@@ -117,7 +122,7 @@ impl LspCommand for ExpandMacro {
             .and_then(deserialize_anchor)
             .context("invalid position")?;
         Ok(Self {
-            position: buffer.update(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
+            position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
         })
     }
 
@@ -195,6 +200,10 @@ impl LspCommand for OpenDocs {
         "Open docs"
     }
 
+    fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool {
+        true
+    }
+
     fn to_lsp(
         &self,
         path: &Path,
@@ -247,7 +256,7 @@ impl LspCommand for OpenDocs {
             .and_then(deserialize_anchor)
             .context("invalid position")?;
         Ok(Self {
-            position: buffer.update(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
+            position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
         })
     }
 
@@ -325,6 +334,10 @@ impl LspCommand for SwitchSourceHeader {
         "Switch source header"
     }
 
+    fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool {
+        true
+    }
+
     fn to_lsp(
         &self,
         path: &Path,
@@ -403,6 +416,10 @@ impl LspCommand for GoToParentModule {
         "Go to parent module"
     }
 
+    fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool {
+        true
+    }
+
     fn to_lsp(
         &self,
         path: &Path,
@@ -452,7 +469,7 @@ impl LspCommand for GoToParentModule {
             .and_then(deserialize_anchor)
             .context("bad request with bad position")?;
         Ok(Self {
-            position: buffer.update(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
+            position: buffer.read_with(&mut cx, |buffer, _| position.to_point_utf16(buffer))?,
         })
     }
 
@@ -577,6 +594,10 @@ impl LspCommand for GetLspRunnables {
         "LSP Runnables"
     }
 
+    fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool {
+        true
+    }
+
     fn to_lsp(
         &self,
         path: &Path,
@@ -584,10 +605,7 @@ impl LspCommand for GetLspRunnables {
         _: &Arc<LanguageServer>,
         _: &App,
     ) -> Result<RunnablesParams> {
-        let url = match lsp::Url::from_file_path(path) {
-            Ok(url) => url,
-            Err(()) => anyhow::bail!("Failed to parse path {path:?} as lsp::Url"),
-        };
+        let url = file_path_to_lsp_url(path)?;
         Ok(RunnablesParams {
             text_document: lsp::TextDocumentIdentifier::new(url),
             position: self

crates/project/src/lsp_store/rust_analyzer_ext.rs 🔗

@@ -1,12 +1,11 @@
 use ::serde::{Deserialize, Serialize};
 use anyhow::Context as _;
-use gpui::{App, Entity, PromptLevel, Task, WeakEntity};
+use gpui::{App, Entity, Task, WeakEntity};
+use language::ServerHealth;
 use lsp::LanguageServer;
 use rpc::proto;
 
-use crate::{
-    LanguageServerPromptRequest, LspStore, LspStoreEvent, Project, ProjectPath, lsp_store,
-};
+use crate::{LspStore, LspStoreEvent, Project, ProjectPath, lsp_store};
 
 pub const RUST_ANALYZER_NAME: &str = "rust-analyzer";
 pub const CARGO_DIAGNOSTICS_SOURCE_NAME: &str = "rustc";
@@ -17,20 +16,10 @@ pub const CARGO_DIAGNOSTICS_SOURCE_NAME: &str = "rustc";
 #[derive(Debug)]
 enum ServerStatus {}
 
-/// Other(String) variant to handle unknown values due to this still being experimental
-#[derive(Debug, PartialEq, Deserialize, Serialize, Clone)]
-#[serde(rename_all = "camelCase")]
-enum ServerHealthStatus {
-    Ok,
-    Warning,
-    Error,
-    Other(String),
-}
-
 #[derive(Debug, PartialEq, Deserialize, Serialize, Clone)]
 #[serde(rename_all = "camelCase")]
 struct ServerStatusParams {
-    pub health: ServerHealthStatus,
+    pub health: ServerHealth,
     pub message: Option<String>,
 }
 
@@ -45,40 +34,49 @@ pub fn register_notifications(lsp_store: WeakEntity<LspStore>, language_server:
 
     language_server
         .on_notification::<ServerStatus, _>({
-            let name = name.to_string();
+            let name = name.clone();
             move |params, cx| {
-                let name = name.to_string();
-                if let Some(ref message) = params.message {
-                    let message = message.trim();
-                    if !message.is_empty() {
-                        let formatted_message = format!(
-                            "Language server {name} (id {server_id}) status update: {message}"
-                        );
-                        match params.health {
-                            ServerHealthStatus::Ok => log::info!("{formatted_message}"),
-                            ServerHealthStatus::Warning => log::warn!("{formatted_message}"),
-                            ServerHealthStatus::Error => {
-                                log::error!("{formatted_message}");
-                                let (tx, _rx) = smol::channel::bounded(1);
-                                let request = LanguageServerPromptRequest {
-                                    level: PromptLevel::Critical,
-                                    message: params.message.unwrap_or_default(),
-                                    actions: Vec::new(),
-                                    response_channel: tx,
-                                    lsp_name: name.clone(),
-                                };
-                                lsp_store
-                                    .update(cx, |_, cx| {
-                                        cx.emit(LspStoreEvent::LanguageServerPrompt(request));
-                                    })
-                                    .ok();
-                            }
-                            ServerHealthStatus::Other(status) => {
-                                log::info!("Unknown server health: {status}\n{formatted_message}")
-                            }
+                let message = params.message;
+                let log_message = message.as_ref().map(|message| {
+                    format!("Language server {name} (id {server_id}) status update: {message}")
+                });
+                let status = match &params.health {
+                    ServerHealth::Ok => {
+                        if let Some(log_message) = log_message {
+                            log::info!("{log_message}");
+                        }
+                        proto::ServerHealth::Ok
+                    }
+                    ServerHealth::Warning => {
+                        if let Some(log_message) = log_message {
+                            log::warn!("{log_message}");
+                        }
+                        proto::ServerHealth::Warning
+                    }
+                    ServerHealth::Error => {
+                        if let Some(log_message) = log_message {
+                            log::error!("{log_message}");
                         }
+                        proto::ServerHealth::Error
                     }
-                }
+                };
+
+                lsp_store
+                    .update(cx, |_, cx| {
+                        cx.emit(LspStoreEvent::LanguageServerUpdate {
+                            language_server_id: server_id,
+                            name: Some(name.clone()),
+                            message: proto::update_language_server::Variant::StatusUpdate(
+                                proto::StatusUpdate {
+                                    message,
+                                    status: Some(proto::status_update::Status::Health(
+                                        status as i32,
+                                    )),
+                                },
+                            ),
+                        });
+                    })
+                    .ok();
             }
         })
         .detach();
@@ -109,7 +107,7 @@ pub fn cancel_flycheck(
         else {
             return Ok(());
         };
-        let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id().to_proto())?;
+        let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id().to_proto())?;
 
         if let Some((client, project_id)) = upstream_client {
             let request = proto::LspExtCancelFlycheck {
@@ -123,7 +121,7 @@ pub fn cancel_flycheck(
                 .context("lsp ext cancel flycheck proto request")?;
         } else {
             lsp_store
-                .update(cx, |lsp_store, _| {
+                .read_with(cx, |lsp_store, _| {
                     if let Some(server) = lsp_store.language_server_for_id(rust_analyzer_server) {
                         server.notify::<lsp_store::lsp_ext_command::LspExtCancelFlycheck>(&())?;
                     }
@@ -160,7 +158,7 @@ pub fn run_flycheck(
         else {
             return Ok(());
         };
-        let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id().to_proto())?;
+        let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id().to_proto())?;
 
         if let Some((client, project_id)) = upstream_client {
             let request = proto::LspExtRunFlycheck {
@@ -175,7 +173,7 @@ pub fn run_flycheck(
                 .context("lsp ext run flycheck proto request")?;
         } else {
             lsp_store
-                .update(cx, |lsp_store, _| {
+                .read_with(cx, |lsp_store, _| {
                     if let Some(server) = lsp_store.language_server_for_id(rust_analyzer_server) {
                         server.notify::<lsp_store::lsp_ext_command::LspExtRunFlycheck>(
                             &lsp_store::lsp_ext_command::RunFlycheckParams {
@@ -216,7 +214,7 @@ pub fn clear_flycheck(
         else {
             return Ok(());
         };
-        let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id().to_proto())?;
+        let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id().to_proto())?;
 
         if let Some((client, project_id)) = upstream_client {
             let request = proto::LspExtClearFlycheck {
@@ -230,7 +228,7 @@ pub fn clear_flycheck(
                 .context("lsp ext clear flycheck proto request")?;
         } else {
             lsp_store
-                .update(cx, |lsp_store, _| {
+                .read_with(cx, |lsp_store, _| {
                     if let Some(server) = lsp_store.language_server_for_id(rust_analyzer_server) {
                         server.notify::<lsp_store::lsp_ext_command::LspExtClearFlycheck>(&())?;
                     }

crates/project/src/manifest_tree.rs 🔗

@@ -11,23 +11,26 @@ use std::{
     borrow::Borrow,
     collections::{BTreeMap, hash_map::Entry},
     ops::ControlFlow,
+    path::Path,
     sync::Arc,
 };
 
 use collections::HashMap;
 use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Subscription};
-use language::{LspAdapterDelegate, ManifestName, ManifestQuery};
+use language::{ManifestDelegate, ManifestName, ManifestQuery};
 pub use manifest_store::ManifestProviders;
 use path_trie::{LabelPresence, RootPathTrie, TriePath};
 use settings::{SettingsStore, WorktreeId};
-use worktree::{Event as WorktreeEvent, Worktree};
+use worktree::{Event as WorktreeEvent, Snapshot, Worktree};
 
 use crate::{
     ProjectPath,
     worktree_store::{WorktreeStore, WorktreeStoreEvent},
 };
 
-pub(crate) use server_tree::{AdapterQuery, LanguageServerTree, LaunchDisposition};
+pub(crate) use server_tree::{
+    AdapterQuery, LanguageServerTree, LanguageServerTreeNode, LaunchDisposition,
+};
 
 struct WorktreeRoots {
     roots: RootPathTrie<ManifestName>,
@@ -87,7 +90,7 @@ pub(crate) enum ManifestTreeEvent {
 impl EventEmitter<ManifestTreeEvent> for ManifestTree {}
 
 impl ManifestTree {
-    pub(crate) fn new(worktree_store: Entity<WorktreeStore>, cx: &mut App) -> Entity<Self> {
+    pub fn new(worktree_store: Entity<WorktreeStore>, cx: &mut App) -> Entity<Self> {
         cx.new(|cx| Self {
             root_points: Default::default(),
             _subscriptions: [
@@ -104,11 +107,11 @@ impl ManifestTree {
             worktree_store,
         })
     }
-    fn root_for_path(
+    pub(crate) fn root_for_path(
         &mut self,
         ProjectPath { worktree_id, path }: ProjectPath,
         manifests: &mut dyn Iterator<Item = ManifestName>,
-        delegate: Arc<dyn LspAdapterDelegate>,
+        delegate: Arc<dyn ManifestDelegate>,
         cx: &mut App,
     ) -> BTreeMap<ManifestName, ProjectPath> {
         debug_assert_eq!(delegate.worktree_id(), worktree_id);
@@ -131,7 +134,7 @@ impl ManifestTree {
         };
 
         let key = TriePath::from(&*path);
-        worktree_roots.update(cx, |this, _| {
+        worktree_roots.read_with(cx, |this, _| {
             this.roots.walk(&key, &mut |path, labels| {
                 for (label, presence) in labels {
                     if let Some((marked_path, current_presence)) = roots.get_mut(label) {
@@ -216,3 +219,26 @@ impl ManifestTree {
         }
     }
 }
+
+pub(crate) struct ManifestQueryDelegate {
+    worktree: Snapshot,
+}
+impl ManifestQueryDelegate {
+    pub fn new(worktree: Snapshot) -> Self {
+        Self { worktree }
+    }
+}
+
+impl ManifestDelegate for ManifestQueryDelegate {
+    fn exists(&self, path: &Path, is_dir: Option<bool>) -> bool {
+        self.worktree.entry_for_path(path).map_or(false, |entry| {
+            is_dir.map_or(true, |is_required_to_be_dir| {
+                is_required_to_be_dir == entry.is_dir()
+            })
+        })
+    }
+
+    fn worktree_id(&self) -> WorktreeId {
+        self.worktree.id()
+    }
+}

crates/project/src/manifest_tree/path_trie.rs 🔗

@@ -98,7 +98,7 @@ impl<Label: Ord + Clone> RootPathTrie<Label> {
             };
         }
         if !current.labels.is_empty() {
-            (callback)(&current.worktree_relative_path, &current.labels);
+            let _ = (callback)(&current.worktree_relative_path, &current.labels);
         }
     }
 

crates/project/src/manifest_tree/server_tree.rs 🔗

@@ -16,7 +16,7 @@ use std::{
 use collections::{HashMap, IndexMap};
 use gpui::{App, AppContext as _, Entity, Subscription};
 use language::{
-    Attach, CachedLspAdapter, LanguageName, LanguageRegistry, LspAdapterDelegate,
+    Attach, CachedLspAdapter, LanguageName, LanguageRegistry, ManifestDelegate,
     language_settings::AllLanguageSettings,
 };
 use lsp::LanguageServerName;
@@ -28,8 +28,8 @@ use crate::{LanguageServerId, ProjectPath, project_settings::LspSettings};
 use super::{ManifestTree, ManifestTreeEvent};
 
 #[derive(Debug, Default)]
-struct ServersForWorktree {
-    roots: BTreeMap<
+pub(crate) struct ServersForWorktree {
+    pub(crate) roots: BTreeMap<
         Arc<Path>,
         BTreeMap<LanguageServerName, (Arc<InnerTreeNode>, BTreeSet<LanguageName>)>,
     >,
@@ -37,7 +37,7 @@ struct ServersForWorktree {
 
 pub struct LanguageServerTree {
     manifest_tree: Entity<ManifestTree>,
-    instances: BTreeMap<WorktreeId, ServersForWorktree>,
+    pub(crate) instances: BTreeMap<WorktreeId, ServersForWorktree>,
     attach_kind_cache: HashMap<LanguageServerName, Attach>,
     languages: Arc<LanguageRegistry>,
     _subscriptions: Subscription,
@@ -47,7 +47,7 @@ pub struct LanguageServerTree {
 /// - A language server that has already been initialized/updated for a given project
 /// - A soon-to-be-initialized language server.
 #[derive(Clone)]
-pub(crate) struct LanguageServerTreeNode(Weak<InnerTreeNode>);
+pub struct LanguageServerTreeNode(Weak<InnerTreeNode>);
 
 /// Describes a request to launch a language server.
 #[derive(Debug)]
@@ -74,6 +74,7 @@ impl LanguageServerTreeNode {
     pub(crate) fn server_id(&self) -> Option<LanguageServerId> {
         self.0.upgrade()?.id.get().copied()
     }
+
     /// Returns a language server ID for this node if it has already been initialized; otherwise runs the provided closure to initialize the language server node in a tree.
     /// May return None if the node no longer belongs to the server tree it was created in.
     pub(crate) fn server_id_or_init(
@@ -87,6 +88,11 @@ impl LanguageServerTreeNode {
                 .get_or_init(|| init(LaunchDisposition::from(&*this))),
         )
     }
+
+    /// Returns a language server name as the language server adapter would return.
+    pub fn name(&self) -> Option<LanguageServerName> {
+        self.0.upgrade().map(|node| node.name.clone())
+    }
 }
 
 impl From<Weak<InnerTreeNode>> for LanguageServerTreeNode {
@@ -96,7 +102,7 @@ impl From<Weak<InnerTreeNode>> for LanguageServerTreeNode {
 }
 
 #[derive(Debug)]
-struct InnerTreeNode {
+pub struct InnerTreeNode {
     id: OnceLock<LanguageServerId>,
     name: LanguageServerName,
     attach: Attach,
@@ -151,7 +157,7 @@ impl LanguageServerTree {
         &'a mut self,
         path: ProjectPath,
         query: AdapterQuery<'_>,
-        delegate: Arc<dyn LspAdapterDelegate>,
+        delegate: Arc<dyn ManifestDelegate>,
         cx: &mut App,
     ) -> impl Iterator<Item = LanguageServerTreeNode> + 'a {
         let settings_location = SettingsLocation {
@@ -181,7 +187,7 @@ impl LanguageServerTree {
             LanguageServerName,
             (LspSettings, BTreeSet<LanguageName>, Arc<CachedLspAdapter>),
         >,
-        delegate: Arc<dyn LspAdapterDelegate>,
+        delegate: Arc<dyn ManifestDelegate>,
         cx: &mut App,
     ) -> impl Iterator<Item = LanguageServerTreeNode> + 'a {
         let worktree_id = path.worktree_id;
@@ -336,6 +342,28 @@ impl LanguageServerTree {
             }
         }
     }
+
+    pub(crate) fn register_reused(
+        &mut self,
+        worktree_id: WorktreeId,
+        language_name: LanguageName,
+        reused: LanguageServerTreeNode,
+    ) {
+        let Some(node) = reused.0.upgrade() else {
+            return;
+        };
+
+        self.instances
+            .entry(worktree_id)
+            .or_default()
+            .roots
+            .entry(Arc::from(Path::new("")))
+            .or_default()
+            .entry(node.name.clone())
+            .or_insert_with(|| (node, BTreeSet::new()))
+            .1
+            .insert(language_name);
+    }
 }
 
 pub(crate) struct ServerTreeRebase<'a> {
@@ -379,7 +407,7 @@ impl<'tree> ServerTreeRebase<'tree> {
         &'a mut self,
         path: ProjectPath,
         query: AdapterQuery<'_>,
-        delegate: Arc<dyn LspAdapterDelegate>,
+        delegate: Arc<dyn ManifestDelegate>,
         cx: &mut App,
     ) -> impl Iterator<Item = LanguageServerTreeNode> + 'a {
         let settings_location = SettingsLocation {
@@ -441,4 +469,8 @@ impl<'tree> ServerTreeRebase<'tree> {
             .filter(|(id, _)| !self.rebased_server_ids.contains(id))
             .collect()
     }
+
+    pub(crate) fn server_tree(&mut self) -> &mut LanguageServerTree {
+        &mut self.new_tree
+    }
 }

crates/project/src/prettier_store.rs 🔗

@@ -279,7 +279,7 @@ impl PrettierStore {
     ) -> PrettierTask {
         cx.spawn(async move |prettier_store, cx| {
             log::info!("Starting prettier at path {prettier_dir:?}");
-            let new_server_id = prettier_store.update(cx, |prettier_store, _| {
+            let new_server_id = prettier_store.read_with(cx, |prettier_store, _| {
                 prettier_store.languages.next_language_server_id()
             })?;
 
@@ -306,7 +306,7 @@ impl PrettierStore {
         cx: &mut Context<PrettierStore>,
     ) -> Task<anyhow::Result<PrettierTask>> {
         cx.spawn(async move |prettier_store, cx| {
-            let installation_task = prettier_store.update(cx, |prettier_store, _| {
+            let installation_task = prettier_store.read_with(cx, |prettier_store, _| {
                 match &prettier_store.default_prettier.prettier {
                     PrettierInstallation::NotInstalled {
                         installation_task, ..
@@ -407,7 +407,7 @@ impl PrettierStore {
                                     .read(cx)
                                     .worktree_for_id(id, cx)
                             })
-                            .map(|worktree| worktree.update(cx, |worktree, _| worktree.abs_path()));
+                            .map(|worktree| worktree.read(cx).abs_path());
                         let name = match worktree_path {
                             Some(worktree_path) => {
                                 if prettier_dir == worktree_path.as_ref() {
@@ -761,8 +761,7 @@ pub(super) async fn format_with_prettier(
                 .log_err();
 
             Some(Err(anyhow!(
-                "{} failed to spawn: {error:#}",
-                prettier_description
+                "{prettier_description} failed to spawn: {error:#}"
             )))
         }
     }

crates/project/src/project.rs 🔗

@@ -26,15 +26,19 @@ mod environment;
 use buffer_diff::BufferDiff;
 use context_server_store::ContextServerStore;
 pub use environment::{EnvironmentErrorMessage, ProjectEnvironmentEvent};
+use git::repository::get_git_committer;
 use git_store::{Repository, RepositoryId};
 pub mod search_history;
 mod yarn;
 
+use dap::inline_value::{InlineValueLocation, VariableLookupKind, VariableScope};
+
 use crate::git_store::GitStore;
 pub use git_store::{
     ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate,
     git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal},
 };
+pub use manifest_tree::ManifestTree;
 
 use anyhow::{Context as _, Result, anyhow};
 use buffer_store::{BufferStore, BufferStoreEvent};
@@ -43,10 +47,11 @@ use client::{
 };
 use clock::ReplicaId;
 
-use dap::{DapRegistry, client::DebugAdapterClient};
+use dap::client::DebugAdapterClient;
 
 use collections::{BTreeSet, HashMap, HashSet};
 use debounced_delay::DebouncedDelay;
+pub use debugger::breakpoint_store::BreakpointWithPosition;
 use debugger::{
     breakpoint_store::{ActiveStackFrame, BreakpointStore},
     dap_store::{DapStore, DapStoreEvent},
@@ -65,18 +70,18 @@ use image_store::{ImageItemEvent, ImageStoreEvent};
 
 use ::git::{blame::Blame, status::FileStatus};
 use gpui::{
-    AnyEntity, App, AppContext, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, Hsla,
-    SharedString, Task, WeakEntity, Window,
+    App, AppContext, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, Hsla, SharedString,
+    Task, WeakEntity, Window,
 };
 use itertools::Itertools;
 use language::{
-    Buffer, BufferEvent, Capability, CodeLabel, CursorShape, Language, LanguageName,
-    LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainList, Transaction,
-    Unclipped, language_settings::InlayHintKind, proto::split_operations,
+    Buffer, BufferEvent, Capability, CodeLabel, CursorShape, DiagnosticSourceKind, Language,
+    LanguageName, LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainList,
+    Transaction, Unclipped, language_settings::InlayHintKind, proto::split_operations,
 };
 use lsp::{
     CodeActionKind, CompletionContext, CompletionItemKind, DocumentHighlightKind, InsertTextMode,
-    LanguageServerId, LanguageServerName, MessageActionItem,
+    LanguageServerId, LanguageServerName, LanguageServerSelector, MessageActionItem,
 };
 use lsp_command::*;
 use lsp_store::{CompletionDocumentation, LspFormatTarget, OpenLspBufferHandle};
@@ -108,7 +113,7 @@ use std::{
 
 use task_store::TaskStore;
 use terminals::Terminals;
-use text::{Anchor, BufferId};
+use text::{Anchor, BufferId, Point};
 use toolchain_store::EmptyToolchainStore;
 use util::{
     ResultExt as _,
@@ -246,6 +251,7 @@ enum BufferOrderedMessage {
     LanguageServerUpdate {
         language_server_id: LanguageServerId,
         message: proto::update_language_server::Variant,
+        name: Option<LanguageServerName>,
     },
     Resync,
 }
@@ -465,7 +471,7 @@ impl CompletionSource {
         }
     }
 
-    pub fn lsp_completion(&self, apply_defaults: bool) -> Option<Cow<lsp::CompletionItem>> {
+    pub fn lsp_completion(&self, apply_defaults: bool) -> Option<Cow<'_, lsp::CompletionItem>> {
         if let Self::Lsp {
             lsp_completion,
             lsp_defaults,
@@ -553,6 +559,23 @@ impl std::fmt::Debug for Completion {
     }
 }
 
+/// Response from a source of completions.
+pub struct CompletionResponse {
+    pub completions: Vec<Completion>,
+    /// When false, indicates that the list is complete and so does not need to be re-queried if it
+    /// can be filtered instead.
+    pub is_incomplete: bool,
+}
+
+/// Response from language server completion request.
+#[derive(Clone, Debug, Default)]
+pub(crate) struct CoreCompletionResponse {
+    pub completions: Vec<CoreCompletion>,
+    /// When false, indicates that the list is complete and so does not need to be re-queried if it
+    /// can be filtered instead.
+    pub is_incomplete: bool,
+}
+
 /// A generic completion that can come from different sources.
 #[derive(Clone, Debug)]
 pub(crate) struct CoreCompletion {
@@ -748,16 +771,73 @@ pub struct DirectoryItem {
     pub is_dir: bool,
 }
 
+#[derive(Clone, Debug, PartialEq)]
+pub struct DocumentColor {
+    pub lsp_range: lsp::Range,
+    pub color: lsp::Color,
+    pub resolved: bool,
+    pub color_presentations: Vec<ColorPresentation>,
+}
+
+impl Eq for DocumentColor {}
+
+impl std::hash::Hash for DocumentColor {
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        self.lsp_range.hash(state);
+        self.color.red.to_bits().hash(state);
+        self.color.green.to_bits().hash(state);
+        self.color.blue.to_bits().hash(state);
+        self.color.alpha.to_bits().hash(state);
+        self.resolved.hash(state);
+        self.color_presentations.hash(state);
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ColorPresentation {
+    pub label: SharedString,
+    pub text_edit: Option<lsp::TextEdit>,
+    pub additional_text_edits: Vec<lsp::TextEdit>,
+}
+
+impl std::hash::Hash for ColorPresentation {
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        self.label.hash(state);
+        if let Some(ref edit) = self.text_edit {
+            edit.range.hash(state);
+            edit.new_text.hash(state);
+        }
+        self.additional_text_edits.len().hash(state);
+        for edit in &self.additional_text_edits {
+            edit.range.hash(state);
+            edit.new_text.hash(state);
+        }
+    }
+}
+
 #[derive(Clone)]
 pub enum DirectoryLister {
     Project(Entity<Project>),
-    Local(Arc<dyn Fs>),
+    Local(Entity<Project>, Arc<dyn Fs>),
+}
+
+impl std::fmt::Debug for DirectoryLister {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            DirectoryLister::Project(project) => {
+                write!(f, "DirectoryLister::Project({project:?})")
+            }
+            DirectoryLister::Local(project, _) => {
+                write!(f, "DirectoryLister::Local({project:?})")
+            }
+        }
+    }
 }
 
 impl DirectoryLister {
     pub fn is_local(&self, cx: &App) -> bool {
         match self {
-            DirectoryLister::Local(_) => true,
+            DirectoryLister::Local(..) => true,
             DirectoryLister::Project(project) => project.read(cx).is_local(),
         }
     }
@@ -771,12 +851,28 @@ impl DirectoryLister {
     }
 
     pub fn default_query(&self, cx: &mut App) -> String {
-        if let DirectoryLister::Project(project) = self {
-            if let Some(worktree) = project.read(cx).visible_worktrees(cx).next() {
-                return worktree.read(cx).abs_path().to_string_lossy().to_string();
+        let separator = std::path::MAIN_SEPARATOR_STR;
+        match self {
+            DirectoryLister::Project(project) => project,
+            DirectoryLister::Local(project, _) => project,
+        }
+        .read(cx)
+        .visible_worktrees(cx)
+        .next()
+        .map(|worktree| worktree.read(cx).abs_path())
+        .map(|dir| dir.to_string_lossy().to_string())
+        .or_else(|| std::env::home_dir().map(|dir| dir.to_string_lossy().to_string()))
+        .map(|mut s| {
+            s.push_str(separator);
+            s
+        })
+        .unwrap_or_else(|| {
+            if cfg!(target_os = "windows") {
+                format!("C:{separator}")
+            } else {
+                format!("~{separator}")
             }
-        };
-        format!("~{}", std::path::MAIN_SEPARATOR_STR)
+        })
     }
 
     pub fn list_directory(&self, path: String, cx: &mut App) -> Task<Result<Vec<DirectoryItem>>> {
@@ -784,7 +880,7 @@ impl DirectoryLister {
             DirectoryLister::Project(project) => {
                 project.update(cx, |project, cx| project.list_directory(path, cx))
             }
-            DirectoryLister::Local(fs) => {
+            DirectoryLister::Local(_, fs) => {
                 let fs = fs.clone();
                 cx.background_spawn(async move {
                     let mut results = vec![];
@@ -813,6 +909,34 @@ pub const DEFAULT_COMPLETION_CONTEXT: CompletionContext = CompletionContext {
     trigger_character: None,
 };
 
+/// An LSP diagnostics associated with a certain language server.
+#[derive(Clone, Debug, Default)]
+pub enum LspPullDiagnostics {
+    #[default]
+    Default,
+    Response {
+        /// The id of the language server that produced diagnostics.
+        server_id: LanguageServerId,
+        /// URI of the resource,
+        uri: lsp::Url,
+        /// The diagnostics produced by this language server.
+        diagnostics: PulledDiagnostics,
+    },
+}
+
+#[derive(Clone, Debug)]
+pub enum PulledDiagnostics {
+    Unchanged {
+        /// An ID the current pulled batch for this file.
+        /// If given, can be used to query workspace diagnostics partially.
+        result_id: String,
+    },
+    Changed {
+        result_id: Option<String>,
+        diagnostics: Vec<lsp::Diagnostic>,
+    },
+}
+
 impl Project {
     pub fn init_settings(cx: &mut App) {
         WorktreeSettings::register(cx);
@@ -873,11 +997,13 @@ impl Project {
                 cx.new(|cx| ContextServerStore::new(worktree_store.clone(), cx));
 
             let environment = cx.new(|_| ProjectEnvironment::new(env));
+            let manifest_tree = ManifestTree::new(worktree_store.clone(), cx);
             let toolchain_store = cx.new(|cx| {
                 ToolchainStore::local(
                     languages.clone(),
                     worktree_store.clone(),
                     environment.clone(),
+                    manifest_tree.clone(),
                     cx,
                 )
             });
@@ -919,6 +1045,7 @@ impl Project {
 
             let task_store = cx.new(|cx| {
                 TaskStore::local(
+                    fs.clone(),
                     buffer_store.downgrade(),
                     worktree_store.clone(),
                     toolchain_store.read(cx).as_language_toolchain_store(),
@@ -945,6 +1072,7 @@ impl Project {
                     prettier_store.clone(),
                     toolchain_store.clone(),
                     environment.clone(),
+                    manifest_tree,
                     languages.clone(),
                     client.http_client(),
                     fs.clone(),
@@ -1022,7 +1150,7 @@ impl Project {
             let (tx, rx) = mpsc::unbounded();
             cx.spawn(async move |this, cx| Self::send_buffer_ordered_messages(this, rx, cx).await)
                 .detach();
-            let global_snippets_dir = paths::config_dir().join("snippets");
+            let global_snippets_dir = paths::snippets_dir().to_owned();
             let snippets =
                 SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx);
 
@@ -1057,6 +1185,7 @@ impl Project {
                 .new(|cx| ToolchainStore::remote(SSH_PROJECT_ID, ssh.read(cx).proto_client(), cx));
             let task_store = cx.new(|cx| {
                 TaskStore::remote(
+                    fs.clone(),
                     buffer_store.downgrade(),
                     worktree_store.clone(),
                     toolchain_store.read(cx).as_language_toolchain_store(),
@@ -1242,9 +1371,12 @@ impl Project {
             ),
             EntitySubscription::DapStore(client.subscribe_to_entity::<DapStore>(remote_id)?),
         ];
+        let committer = get_git_committer(&cx).await;
         let response = client
             .request_envelope(proto::JoinProject {
                 project_id: remote_id,
+                committer_email: committer.email,
+                committer_name: committer.name,
             })
             .await?;
         Self::from_join_project_response(
@@ -1317,6 +1449,7 @@ impl Project {
         let task_store = cx.new(|cx| {
             if run_tasks {
                 TaskStore::remote(
+                    fs.clone(),
                     buffer_store.downgrade(),
                     worktree_store.clone(),
                     Arc::new(EmptyToolchainStore),
@@ -1539,7 +1672,7 @@ impl Project {
                 .unwrap()
                 .await
                 .unwrap();
-            tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
+            tree.read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete())
                 .unwrap()
                 .await;
         }
@@ -1578,7 +1711,7 @@ impl Project {
                 .await
                 .unwrap();
 
-            tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
+            tree.read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete())
                 .await;
         }
         project
@@ -1687,7 +1820,7 @@ impl Project {
     pub fn has_open_buffer(&self, path: impl Into<ProjectPath>, cx: &App) -> bool {
         self.buffer_store
             .read(cx)
-            .get_by_path(&path.into(), cx)
+            .get_by_path(&path.into())
             .is_some()
     }
 
@@ -1944,7 +2077,7 @@ impl Project {
         let lsp_store = self.lsp_store().downgrade();
         cx.spawn(async move |_, cx| {
             let (old_abs_path, new_abs_path) = {
-                let root_path = worktree.update(cx, |this, _| this.abs_path())?;
+                let root_path = worktree.read_with(cx, |this, _| this.abs_path())?;
                 let new_abs_path = if is_root_entry {
                     root_path.parent().unwrap().join(&new_path)
                 } else {
@@ -1969,7 +2102,7 @@ impl Project {
                 .await?;
 
             lsp_store
-                .update(cx, |this, _| {
+                .read_with(cx, |this, _| {
                     this.did_rename_entry(worktree_id, &old_abs_path, &new_abs_path, is_dir);
                 })
                 .ok();
@@ -2021,7 +2154,7 @@ impl Project {
             worktree.expand_all_for_entry(entry_id, cx)
         });
         Some(cx.spawn(async move |this, cx| {
-            task.ok_or_else(|| anyhow!("no task"))?.await?;
+            task.context("no task")?.await?;
             this.update(cx, |_, cx| {
                 cx.emit(Event::ExpandedAllForEntry(worktree_id, entry_id));
             })?;
@@ -2030,9 +2163,10 @@ impl Project {
     }
 
     pub fn shared(&mut self, project_id: u64, cx: &mut Context<Self>) -> Result<()> {
-        if !matches!(self.client_state, ProjectClientState::Local) {
-            return Err(anyhow!("project was already shared"));
-        }
+        anyhow::ensure!(
+            matches!(self.client_state, ProjectClientState::Local),
+            "project was already shared"
+        );
 
         self.client_subscriptions.extend([
             self.client
@@ -2150,9 +2284,10 @@ impl Project {
     }
 
     fn unshare_internal(&mut self, cx: &mut App) -> Result<()> {
-        if self.is_via_collab() {
-            return Err(anyhow!("attempted to unshare a remote project"));
-        }
+        anyhow::ensure!(
+            !self.is_via_collab(),
+            "attempted to unshare a remote project"
+        );
 
         if let ProjectClientState::Shared { remote_id, .. } = self.client_state {
             self.client_state = ProjectClientState::Local;
@@ -2188,7 +2323,7 @@ impl Project {
                 .ok();
             Ok(())
         } else {
-            Err(anyhow!("attempted to unshare an unshared project"))
+            anyhow::bail!("attempted to unshare an unshared project");
         }
     }
 
@@ -2319,7 +2454,7 @@ impl Project {
         &mut self,
         path: ProjectPath,
         cx: &mut Context<Self>,
-    ) -> Task<Result<(Option<ProjectEntryId>, AnyEntity)>> {
+    ) -> Task<Result<(Option<ProjectEntryId>, Entity<Buffer>)>> {
         let task = self.open_buffer(path.clone(), cx);
         cx.spawn(async move |_project, cx| {
             let buffer = task.await?;
@@ -2327,8 +2462,7 @@ impl Project {
                 File::from_dyn(buffer.file()).and_then(|file| file.project_entry_id(cx))
             })?;
 
-            let buffer: &AnyEntity = &buffer;
-            Ok((project_entry_id, buffer.clone()))
+            Ok((project_entry_id, buffer))
         })
     }
 
@@ -2337,11 +2471,14 @@ impl Project {
         abs_path: impl AsRef<Path>,
         cx: &mut Context<Self>,
     ) -> Task<Result<Entity<Buffer>>> {
-        if let Some((worktree, relative_path)) = self.find_worktree(abs_path.as_ref(), cx) {
-            self.open_buffer((worktree.read(cx).id(), relative_path), cx)
-        } else {
-            Task::ready(Err(anyhow!("no such path")))
-        }
+        let worktree_task = self.find_or_create_worktree(abs_path.as_ref(), false, cx);
+        cx.spawn(async move |this, cx| {
+            let (worktree, relative_path) = worktree_task.await?;
+            this.update(cx, |this, cx| {
+                this.open_buffer((worktree.read(cx).id(), relative_path), cx)
+            })?
+            .await
+        })
     }
 
     #[cfg(any(test, feature = "test-support"))]
@@ -2393,7 +2530,7 @@ impl Project {
         cx: &mut App,
     ) -> OpenLspBufferHandle {
         self.lsp_store.update(cx, |lsp_store, cx| {
-            lsp_store.register_buffer_with_language_servers(&buffer, false, cx)
+            lsp_store.register_buffer_with_language_servers(&buffer, HashSet::default(), false, cx)
         })
     }
 
@@ -2430,7 +2567,7 @@ impl Project {
         if let Some(buffer) = self.buffer_for_id(id, cx) {
             Task::ready(Ok(buffer))
         } else if self.is_local() || self.is_via_ssh() {
-            Task::ready(Err(anyhow!("buffer {} does not exist", id)))
+            Task::ready(Err(anyhow!("buffer {id} does not exist")))
         } else if let Some(project_id) = self.remote_id() {
             let request = self.client.request(proto::OpenBufferById {
                 project_id,
@@ -2483,7 +2620,7 @@ impl Project {
     }
 
     pub fn get_open_buffer(&self, path: &ProjectPath, cx: &App) -> Option<Entity<Buffer>> {
-        self.buffer_store.read(cx).get_by_path(path, cx)
+        self.buffer_store.read(cx).get_by_path(path)
     }
 
     fn register_buffer(&mut self, buffer: &Entity<Buffer>, cx: &mut Context<Self>) -> Result<()> {
@@ -2520,9 +2657,7 @@ impl Project {
         let weak_project = cx.entity().downgrade();
         cx.spawn(async move |_, cx| {
             let image_item = open_image_task.await?;
-            let project = weak_project
-                .upgrade()
-                .ok_or_else(|| anyhow!("Project dropped"))?;
+            let project = weak_project.upgrade().context("Project dropped")?;
 
             let metadata = ImageItem::load_image_metadata(image_item.clone(), project, cx).await?;
             image_item.update(cx, |image_item, cx| {
@@ -2535,7 +2670,7 @@ impl Project {
     }
 
     async fn send_buffer_ordered_messages(
-        this: WeakEntity<Self>,
+        project: WeakEntity<Self>,
         rx: UnboundedReceiver<BufferOrderedMessage>,
         cx: &mut AsyncApp,
     ) -> Result<()> {
@@ -2550,7 +2685,7 @@ impl Project {
             cx: &mut AsyncApp,
         ) -> Result<()> {
             for (buffer_id, operations) in operations_by_buffer_id.drain() {
-                let request = this.update(cx, |this, _| {
+                let request = this.read_with(cx, |this, _| {
                     let project_id = this.remote_id()?;
                     Some(this.client.request(proto::UpdateBuffer {
                         buffer_id: buffer_id.into(),
@@ -2572,7 +2707,7 @@ impl Project {
         let mut changes = rx.ready_chunks(MAX_BATCH_SIZE);
 
         while let Some(changes) = changes.next().await {
-            let is_local = this.update(cx, |this, _| this.is_local())?;
+            let is_local = project.read_with(cx, |this, _| this.is_local())?;
 
             for change in changes {
                 match change {
@@ -2592,7 +2727,7 @@ impl Project {
 
                     BufferOrderedMessage::Resync => {
                         operations_by_buffer_id.clear();
-                        if this
+                        if project
                             .update(cx, |this, cx| this.synchronize_remote_buffers(cx))?
                             .await
                             .is_ok()
@@ -2604,9 +2739,10 @@ impl Project {
                     BufferOrderedMessage::LanguageServerUpdate {
                         language_server_id,
                         message,
+                        name,
                     } => {
                         flush_operations(
-                            &this,
+                            &project,
                             &mut operations_by_buffer_id,
                             &mut needs_resync_with_host,
                             is_local,
@@ -2614,12 +2750,14 @@ impl Project {
                         )
                         .await?;
 
-                        this.update(cx, |this, _| {
-                            if let Some(project_id) = this.remote_id() {
-                                this.client
+                        project.read_with(cx, |project, _| {
+                            if let Some(project_id) = project.remote_id() {
+                                project
+                                    .client
                                     .send(proto::UpdateLanguageServer {
                                         project_id,
-                                        language_server_id: language_server_id.0 as u64,
+                                        server_name: name.map(|name| String::from(name.0)),
+                                        language_server_id: language_server_id.to_proto(),
                                         variant: Some(message),
                                     })
                                     .log_err();
@@ -2630,7 +2768,7 @@ impl Project {
             }
 
             flush_operations(
-                &this,
+                &project,
                 &mut operations_by_buffer_id,
                 &mut needs_resync_with_host,
                 is_local,
@@ -2751,12 +2889,14 @@ impl Project {
             LspStoreEvent::LanguageServerUpdate {
                 language_server_id,
                 message,
+                name,
             } => {
                 if self.is_local() {
                     self.enqueue_buffer_ordered_message(
                         BufferOrderedMessage::LanguageServerUpdate {
                             language_server_id: *language_server_id,
                             message: message.clone(),
+                            name: name.clone(),
                         },
                     )
                     .ok();
@@ -2860,7 +3000,7 @@ impl Project {
             WorktreeStoreEvent::WorktreeUpdatedEntries(worktree_id, changes) => {
                 self.client()
                     .telemetry()
-                    .report_discovered_project_events(*worktree_id, changes);
+                    .report_discovered_project_type_events(*worktree_id, changes);
                 cx.emit(Event::WorktreeUpdatedEntries(*worktree_id, changes.clone()))
             }
             WorktreeStoreEvent::WorktreeDeletedEntry(worktree_id, id) => {
@@ -3035,20 +3175,22 @@ impl Project {
     pub fn restart_language_servers_for_buffers(
         &mut self,
         buffers: Vec<Entity<Buffer>>,
+        only_restart_servers: HashSet<LanguageServerSelector>,
         cx: &mut Context<Self>,
     ) {
         self.lsp_store.update(cx, |lsp_store, cx| {
-            lsp_store.restart_language_servers_for_buffers(buffers, cx)
+            lsp_store.restart_language_servers_for_buffers(buffers, only_restart_servers, cx)
         })
     }
 
     pub fn stop_language_servers_for_buffers(
         &mut self,
         buffers: Vec<Entity<Buffer>>,
+        also_restart_servers: HashSet<LanguageServerSelector>,
         cx: &mut Context<Self>,
     ) {
         self.lsp_store.update(cx, |lsp_store, cx| {
-            lsp_store.stop_language_servers_for_buffers(buffers, cx)
+            lsp_store.stop_language_servers_for_buffers(buffers, also_restart_servers, cx)
         })
     }
 
@@ -3084,16 +3226,13 @@ impl Project {
         path: ProjectPath,
         language_name: LanguageName,
         cx: &App,
-    ) -> Task<Option<ToolchainList>> {
-        if let Some(toolchain_store) = self.toolchain_store.clone() {
+    ) -> Task<Option<(ToolchainList, Arc<Path>)>> {
+        if let Some(toolchain_store) = self.toolchain_store.as_ref().map(Entity::downgrade) {
             cx.spawn(async move |cx| {
-                cx.update(|cx| {
-                    toolchain_store
-                        .read(cx)
-                        .list_toolchains(path, language_name, cx)
-                })
-                .ok()?
-                .await
+                toolchain_store
+                    .update(cx, |this, cx| this.list_toolchains(path, language_name, cx))
+                    .ok()?
+                    .await
             })
         } else {
             Task::ready(None)
@@ -3429,7 +3568,7 @@ impl Project {
         position: T,
         context: CompletionContext,
         cx: &mut Context<Self>,
-    ) -> Task<Result<Option<Vec<Completion>>>> {
+    ) -> Task<Result<Vec<CompletionResponse>>> {
         let position = position.to_point_utf16(buffer.read(cx));
         self.lsp_store.update(cx, |lsp_store, cx| {
             lsp_store.completions(buffer, position, context, cx)
@@ -3567,33 +3706,15 @@ impl Project {
         range: Range<text::Anchor>,
         cx: &mut Context<Self>,
     ) -> Task<anyhow::Result<Vec<InlayHint>>> {
-        let language_name = buffer_handle
-            .read(cx)
-            .language()
-            .map(|language| language.name().to_string());
-
-        let Some(inline_value_provider) = language_name
-            .and_then(|language| DapRegistry::global(cx).inline_value_provider(&language))
-        else {
-            return Task::ready(Err(anyhow::anyhow!("Inline value provider not found")));
-        };
-
         let snapshot = buffer_handle.read(cx).snapshot();
 
-        let root_node = snapshot.syntax_root_ancestor(range.end).unwrap();
+        let captures = snapshot.debug_variables_query(Anchor::MIN..range.end);
 
         let row = snapshot
             .summary_for_anchor::<text::PointUtf16>(&range.end)
             .row as usize;
 
-        let inline_value_locations = inline_value_provider.provide(
-            root_node,
-            snapshot
-                .text_for_range(Anchor::MIN..range.end)
-                .collect::<String>()
-                .as_str(),
-            row,
-        );
+        let inline_value_locations = provide_inline_values(captures, &snapshot, row);
 
         let stack_frame_id = active_stack_frame.stack_frame_id;
         cx.spawn(async move |this, cx| {
@@ -3637,6 +3758,27 @@ impl Project {
         })
     }
 
+    pub fn update_diagnostics(
+        &mut self,
+        language_server_id: LanguageServerId,
+        source_kind: DiagnosticSourceKind,
+        result_id: Option<String>,
+        params: lsp::PublishDiagnosticsParams,
+        disk_based_sources: &[String],
+        cx: &mut Context<Self>,
+    ) -> Result<(), anyhow::Error> {
+        self.lsp_store.update(cx, |lsp_store, cx| {
+            lsp_store.update_diagnostics(
+                language_server_id,
+                params,
+                result_id,
+                source_kind,
+                disk_based_sources,
+                cx,
+            )
+        })
+    }
+
     pub fn search(&mut self, query: SearchQuery, cx: &mut Context<Self>) -> Receiver<SearchResult> {
         let (result_tx, result_rx) = smol::channel::unbounded();
 
@@ -3651,7 +3793,7 @@ impl Project {
             let mut buffer_count = 0;
             let mut limit_reached = false;
             let query = Arc::new(query);
-            let mut chunks = matching_buffers_rx.ready_chunks(64);
+            let chunks = matching_buffers_rx.ready_chunks(64);
 
             // Now that we know what paths match the query, we will load at most
             // 64 buffers at a time to avoid overwhelming the main thread. For each
@@ -3659,9 +3801,8 @@ impl Project {
             // ranges in the buffer matched by the query.
             let mut chunks = pin!(chunks);
             'outer: while let Some(matching_buffer_chunk) = chunks.next().await {
-                let mut chunk_results = Vec::new();
+                let mut chunk_results = Vec::with_capacity(matching_buffer_chunk.len());
                 for buffer in matching_buffer_chunk {
-                    let buffer = buffer.clone();
                     let query = query.clone();
                     let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
                     chunk_results.push(cx.background_spawn(async move {
@@ -3869,9 +4010,7 @@ impl Project {
     }
 
     pub fn find_worktree(&self, abs_path: &Path, cx: &App) -> Option<(Entity<Worktree>, PathBuf)> {
-        self.worktree_store.read_with(cx, |worktree_store, cx| {
-            worktree_store.find_worktree(abs_path, cx)
-        })
+        self.worktree_store.read(cx).find_worktree(abs_path, cx)
     }
 
     pub fn is_shared(&self) -> bool {
@@ -4008,7 +4147,7 @@ impl Project {
         cx: &mut AsyncApp,
     ) -> Option<ResolvedPath> {
         worktree
-            .update(cx, |worktree, _| {
+            .read_with(cx, |worktree, _| {
                 let root_entry_path = &worktree.root_entry()?.path;
                 let resolved = resolve_path(root_entry_path, path);
                 let stripped = resolved.strip_prefix(root_entry_path).unwrap_or(&resolved);
@@ -4032,7 +4171,7 @@ impl Project {
         cx: &mut Context<Self>,
     ) -> Task<Result<Vec<DirectoryItem>>> {
         if self.is_local() {
-            DirectoryLister::Local(self.fs.clone()).list_directory(query, cx)
+            DirectoryLister::Local(cx.entity(), self.fs.clone()).list_directory(query, cx)
         } else if let Some(session) = self.ssh_client.as_ref() {
             let path_buf = PathBuf::from(query);
             let request = proto::ListRemoteDirectory {
@@ -4269,7 +4408,7 @@ impl Project {
             .payload
             .collaborator
             .take()
-            .ok_or_else(|| anyhow!("empty collaborator"))?;
+            .context("empty collaborator")?;
 
         let collaborator = Collaborator::from_proto(collaborator)?;
         this.update(&mut cx, |this, cx| {
@@ -4293,16 +4432,16 @@ impl Project {
         let old_peer_id = envelope
             .payload
             .old_peer_id
-            .ok_or_else(|| anyhow!("missing old peer id"))?;
+            .context("missing old peer id")?;
         let new_peer_id = envelope
             .payload
             .new_peer_id
-            .ok_or_else(|| anyhow!("missing new peer id"))?;
+            .context("missing new peer id")?;
         this.update(&mut cx, |this, cx| {
             let collaborator = this
                 .collaborators
                 .remove(&old_peer_id)
-                .ok_or_else(|| anyhow!("received UpdateProjectCollaborator for unknown peer"))?;
+                .context("received UpdateProjectCollaborator for unknown peer")?;
             let is_host = collaborator.is_host;
             this.collaborators.insert(new_peer_id, collaborator);
 
@@ -4333,14 +4472,11 @@ impl Project {
         mut cx: AsyncApp,
     ) -> Result<()> {
         this.update(&mut cx, |this, cx| {
-            let peer_id = envelope
-                .payload
-                .peer_id
-                .ok_or_else(|| anyhow!("invalid peer id"))?;
+            let peer_id = envelope.payload.peer_id.context("invalid peer id")?;
             let replica_id = this
                 .collaborators
                 .remove(&peer_id)
-                .ok_or_else(|| anyhow!("unknown peer {:?}", peer_id))?
+                .with_context(|| format!("unknown peer {peer_id:?}"))?
                 .replica_id;
             this.buffer_store.update(cx, |buffer_store, cx| {
                 buffer_store.forget_shared_buffers_for(&peer_id);
@@ -4390,7 +4526,7 @@ impl Project {
         envelope: TypedEnvelope<proto::LanguageServerPromptRequest>,
         mut cx: AsyncApp,
     ) -> Result<proto::LanguageServerPromptResponse> {
-        let (tx, mut rx) = smol::channel::bounded(1);
+        let (tx, rx) = smol::channel::bounded(1);
         let actions: Vec<_> = envelope
             .payload
             .actions
@@ -4554,11 +4690,7 @@ impl Project {
     ) -> Result<proto::FindSearchCandidatesResponse> {
         let peer_id = envelope.original_sender_id()?;
         let message = envelope.payload;
-        let query = SearchQuery::from_proto(
-            message
-                .query
-                .ok_or_else(|| anyhow!("missing query field"))?,
-        )?;
+        let query = SearchQuery::from_proto(message.query.context("missing query field")?)?;
         let results = this.update(&mut cx, |this, cx| {
             this.find_search_candidate_buffers(&query, message.limit as _, cx)
         })?;
@@ -4636,13 +4768,10 @@ impl Project {
                 .file()
                 .map(|f| f.is_private())
                 .unwrap_or_default();
-            if is_private {
-                Err(anyhow!(ErrorCode::UnsharedItem))
-            } else {
-                Ok(proto::OpenBufferResponse {
-                    buffer_id: this.create_buffer_for_peer(&buffer, peer_id, cx).into(),
-                })
-            }
+            anyhow::ensure!(!is_private, ErrorCode::UnsharedItem);
+            Ok(proto::OpenBufferResponse {
+                buffer_id: this.create_buffer_for_peer(&buffer, peer_id, cx).into(),
+            })
         })?
     }
 
@@ -4961,6 +5090,12 @@ impl Project {
     pub fn agent_location(&self) -> Option<AgentLocation> {
         self.agent_location.clone()
     }
+
+    pub fn mark_buffer_as_non_searchable(&self, buffer_id: BufferId, cx: &mut Context<Project>) {
+        self.buffer_store.update(cx, |buffer_store, _| {
+            buffer_store.mark_buffer_as_non_searchable(buffer_id)
+        });
+    }
 }
 
 pub struct PathMatchCandidateSet {
@@ -5206,17 +5341,18 @@ impl Completion {
     /// A key that can be used to sort completions when displaying
     /// them to the user.
     pub fn sort_key(&self) -> (usize, &str) {
-        const DEFAULT_KIND_KEY: usize = 3;
+        const DEFAULT_KIND_KEY: usize = 4;
         let kind_key = self
             .kind()
             .and_then(|lsp_completion_kind| match lsp_completion_kind {
                 lsp::CompletionItemKind::KEYWORD => Some(0),
                 lsp::CompletionItemKind::VARIABLE => Some(1),
                 lsp::CompletionItemKind::CONSTANT => Some(2),
+                lsp::CompletionItemKind::PROPERTY => Some(3),
                 _ => None,
             })
             .unwrap_or(DEFAULT_KIND_KEY);
-        (kind_key, &self.label.text[self.label.filter_range.clone()])
+        (kind_key, &self.label.filter_text())
     }
 
     /// Whether this completion is a snippet.
@@ -5260,3 +5396,69 @@ fn proto_to_prompt(level: proto::language_server_prompt_request::Level) -> gpui:
         proto::language_server_prompt_request::Level::Critical(_) => gpui::PromptLevel::Critical,
     }
 }
+
+fn provide_inline_values(
+    captures: impl Iterator<Item = (Range<usize>, language::DebuggerTextObject)>,
+    snapshot: &language::BufferSnapshot,
+    max_row: usize,
+) -> Vec<InlineValueLocation> {
+    let mut variables = Vec::new();
+    let mut variable_position = HashSet::default();
+    let mut scopes = Vec::new();
+
+    let active_debug_line_offset = snapshot.point_to_offset(Point::new(max_row as u32, 0));
+
+    for (capture_range, capture_kind) in captures {
+        match capture_kind {
+            language::DebuggerTextObject::Variable => {
+                let variable_name = snapshot
+                    .text_for_range(capture_range.clone())
+                    .collect::<String>();
+                let point = snapshot.offset_to_point(capture_range.end);
+
+                while scopes.last().map_or(false, |scope: &Range<_>| {
+                    !scope.contains(&capture_range.start)
+                }) {
+                    scopes.pop();
+                }
+
+                if point.row as usize > max_row {
+                    break;
+                }
+
+                let scope = if scopes
+                    .last()
+                    .map_or(true, |scope| !scope.contains(&active_debug_line_offset))
+                {
+                    VariableScope::Global
+                } else {
+                    VariableScope::Local
+                };
+
+                if variable_position.insert(capture_range.end) {
+                    variables.push(InlineValueLocation {
+                        variable_name,
+                        scope,
+                        lookup: VariableLookupKind::Variable,
+                        row: point.row as usize,
+                        column: point.column as usize,
+                    });
+                }
+            }
+            language::DebuggerTextObject::Scope => {
+                while scopes.last().map_or_else(
+                    || false,
+                    |scope: &Range<usize>| {
+                        !(scope.contains(&capture_range.start)
+                            && scope.contains(&capture_range.end))
+                    },
+                ) {
+                    scopes.pop();
+                }
+                scopes.push(capture_range);
+            }
+        }
+    }
+
+    variables
+}

crates/project/src/project_settings.rs 🔗

@@ -36,6 +36,7 @@ use crate::{
 };
 
 #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
+#[schemars(deny_unknown_fields)]
 pub struct ProjectSettings {
     /// Configuration for language servers.
     ///
@@ -48,13 +49,17 @@ pub struct ProjectSettings {
     #[serde(default)]
     pub lsp: HashMap<LanguageServerName, LspSettings>,
 
+    /// Common language server settings.
+    #[serde(default)]
+    pub global_lsp_settings: GlobalLspSettings,
+
     /// Configuration for Debugger-related features
     #[serde(default)]
     pub dap: HashMap<DebugAdapterName, DapSettings>,
 
     /// Settings for context servers used for AI-related features.
     #[serde(default)]
-    pub context_servers: HashMap<Arc<str>, ContextServerConfiguration>,
+    pub context_servers: HashMap<Arc<str>, ContextServerSettings>,
 
     /// Configuration for Diagnostics-related features.
     #[serde(default)]
@@ -81,19 +86,65 @@ pub struct ProjectSettings {
 #[serde(rename_all = "snake_case")]
 pub struct DapSettings {
     pub binary: Option<String>,
+    #[serde(default)]
+    pub args: Vec<String>,
 }
 
-#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug, Default)]
-pub struct ContextServerConfiguration {
-    /// The command to run this context server.
-    ///
-    /// This will override the command set by an extension.
-    pub command: Option<ContextServerCommand>,
-    /// The settings for this context server.
+#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
+#[serde(tag = "source", rename_all = "snake_case")]
+pub enum ContextServerSettings {
+    Custom {
+        /// Whether the context server is enabled.
+        #[serde(default = "default_true")]
+        enabled: bool,
+        /// The command to run this context server.
+        ///
+        /// This will override the command set by an extension.
+        command: ContextServerCommand,
+    },
+    Extension {
+        /// Whether the context server is enabled.
+        #[serde(default = "default_true")]
+        enabled: bool,
+        /// The settings for this context server specified by the extension.
+        ///
+        /// Consult the documentation for the context server to see what settings
+        /// are supported.
+        settings: serde_json::Value,
+    },
+}
+
+/// Common language server settings.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
+pub struct GlobalLspSettings {
+    /// Whether to show the LSP servers button in the status bar.
     ///
-    /// Consult the documentation for the context server to see what settings
-    /// are supported.
-    pub settings: Option<serde_json::Value>,
+    /// Default: `true`
+    #[serde(default = "default_true")]
+    pub button: bool,
+}
+
+impl ContextServerSettings {
+    pub fn default_extension() -> Self {
+        Self::Extension {
+            enabled: true,
+            settings: serde_json::json!({}),
+        }
+    }
+
+    pub fn enabled(&self) -> bool {
+        match self {
+            ContextServerSettings::Custom { enabled, .. } => *enabled,
+            ContextServerSettings::Extension { enabled, .. } => *enabled,
+        }
+    }
+
+    pub fn set_enabled(&mut self, enabled: bool) {
+        match self {
+            ContextServerSettings::Custom { enabled: e, .. } => *e = enabled,
+            ContextServerSettings::Extension { enabled: e, .. } => *e = enabled,
+        }
+    }
 }
 
 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
@@ -117,18 +168,22 @@ pub enum DirenvSettings {
     Direct,
 }
 
-#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(default)]
 pub struct DiagnosticsSettings {
-    /// Whether or not to include warning diagnostics
-    #[serde(default = "true_value")]
+    /// Whether to show the project diagnostics button in the status bar.
+    pub button: bool,
+
+    /// Whether or not to include warning diagnostics.
     pub include_warnings: bool,
 
-    /// Settings for showing inline diagnostics
-    #[serde(default)]
+    /// Settings for using LSP pull diagnostics mechanism in Zed.
+    pub lsp_pull_diagnostics: LspPullDiagnosticsSettings,
+
+    /// Settings for showing inline diagnostics.
     pub inline: InlineDiagnosticsSettings,
 
     /// Configuration, related to Rust language diagnostics.
-    #[serde(default)]
     pub cargo: Option<CargoDiagnosticsSettings>,
 }
 
@@ -141,17 +196,37 @@ impl DiagnosticsSettings {
 }
 
 #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(default)]
+pub struct LspPullDiagnosticsSettings {
+    /// Whether to pull for diagnostics or not.
+    ///
+    /// Default: true
+    #[serde(default = "default_true")]
+    pub enabled: bool,
+    /// Minimum time to wait before pulling diagnostics from the language server(s).
+    /// 0 turns the debounce off.
+    ///
+    /// Default: 50
+    #[serde(default = "default_lsp_diagnostics_pull_debounce_ms")]
+    pub debounce_ms: u64,
+}
+
+fn default_lsp_diagnostics_pull_debounce_ms() -> u64 {
+    50
+}
+
+#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(default)]
 pub struct InlineDiagnosticsSettings {
     /// Whether or not to show inline diagnostics
     ///
     /// Default: false
-    #[serde(default)]
     pub enabled: bool,
     /// Whether to only show the inline diagnostics after a delay after the
     /// last editor event.
     ///
     /// Default: 150
-    #[serde(default = "default_inline_diagnostics_debounce_ms")]
+    #[serde(default = "default_inline_diagnostics_update_debounce_ms")]
     pub update_debounce_ms: u64,
     /// The amount of padding between the end of the source line and the start
     /// of the inline diagnostic in units of columns.
@@ -164,13 +239,60 @@ pub struct InlineDiagnosticsSettings {
     /// longer than this value will still push diagnostics further to the right.
     ///
     /// Default: 0
-    #[serde(default)]
     pub min_column: u32,
 
-    #[serde(default)]
     pub max_severity: Option<DiagnosticSeverity>,
 }
 
+fn default_inline_diagnostics_update_debounce_ms() -> u64 {
+    150
+}
+
+fn default_inline_diagnostics_padding() -> u32 {
+    4
+}
+
+impl Default for DiagnosticsSettings {
+    fn default() -> Self {
+        Self {
+            button: true,
+            include_warnings: true,
+            lsp_pull_diagnostics: LspPullDiagnosticsSettings::default(),
+            inline: InlineDiagnosticsSettings::default(),
+            cargo: None,
+        }
+    }
+}
+
+impl Default for LspPullDiagnosticsSettings {
+    fn default() -> Self {
+        Self {
+            enabled: true,
+            debounce_ms: default_lsp_diagnostics_pull_debounce_ms(),
+        }
+    }
+}
+
+impl Default for InlineDiagnosticsSettings {
+    fn default() -> Self {
+        Self {
+            enabled: false,
+            update_debounce_ms: default_inline_diagnostics_update_debounce_ms(),
+            padding: default_inline_diagnostics_padding(),
+            min_column: 0,
+            max_severity: None,
+        }
+    }
+}
+
+impl Default for GlobalLspSettings {
+    fn default() -> Self {
+        Self {
+            button: default_true(),
+        }
+    }
+}
+
 #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 pub struct CargoDiagnosticsSettings {
     /// When enabled, Zed disables rust-analyzer's check on save and starts to query
@@ -206,26 +328,6 @@ impl DiagnosticSeverity {
     }
 }
 
-impl Default for InlineDiagnosticsSettings {
-    fn default() -> Self {
-        Self {
-            enabled: false,
-            update_debounce_ms: default_inline_diagnostics_debounce_ms(),
-            padding: default_inline_diagnostics_padding(),
-            min_column: 0,
-            max_severity: None,
-        }
-    }
-}
-
-fn default_inline_diagnostics_debounce_ms() -> u64 {
-    150
-}
-
-fn default_inline_diagnostics_padding() -> u32 {
-    4
-}
-
 #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 pub struct GitSettings {
     /// Whether or not to show the git gutter.
@@ -304,7 +406,7 @@ pub struct InlineBlameSettings {
     /// the currently focused line.
     ///
     /// Default: true
-    #[serde(default = "true_value")]
+    #[serde(default = "default_true")]
     pub enabled: bool,
     /// Whether to only show the inline blame information
     /// after a delay once the cursor stops moving.
@@ -322,10 +424,6 @@ pub struct InlineBlameSettings {
     pub show_commit_summary: bool,
 }
 
-const fn true_value() -> bool {
-    true
-}
-
 #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
 pub struct BinarySettings {
     pub path: Option<String>,
@@ -432,13 +530,13 @@ impl Settings for ProjectSettings {
                 .extend(mcp.iter().filter_map(|(k, v)| {
                     Some((
                         k.clone().into(),
-                        ContextServerConfiguration {
-                            command: Some(
-                                serde_json::from_value::<VsCodeContextServerCommand>(v.clone())
-                                    .ok()?
-                                    .into(),
-                            ),
-                            settings: None,
+                        ContextServerSettings::Custom {
+                            enabled: true,
+                            command: serde_json::from_value::<VsCodeContextServerCommand>(
+                                v.clone(),
+                            )
+                            .ok()?
+                            .into(),
                         },
                     ))
                 }));
@@ -905,7 +1003,7 @@ impl SettingsObserver {
         let user_tasks_content = cx.background_executor().block(user_tasks_file_rx.next());
         let weak_entry = cx.weak_entity();
         cx.spawn(async move |settings_observer, cx| {
-            let Ok(task_store) = settings_observer.update(cx, |settings_observer, _| {
+            let Ok(task_store) = settings_observer.read_with(cx, |settings_observer, _| {
                 settings_observer.task_store.clone()
             }) else {
                 return;

crates/project/src/project_tests.rs 🔗

@@ -11,6 +11,7 @@ use buffer_diff::{
 use fs::FakeFs;
 use futures::{StreamExt, future};
 use git::{
+    GitHostingProviderRegistry,
     repository::RepoPath,
     status::{StatusCode, TrackedStatus},
 };
@@ -41,7 +42,6 @@ use unindent::Unindent as _;
 use util::{
     TryFutureExt as _, assert_set_eq, maybe, path,
     paths::PathMatcher,
-    separator,
     test::{TempTree, marked_text_offsets},
     uri,
 };
@@ -216,6 +216,71 @@ async fn test_editorconfig_support(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_git_provider_project_setting(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+    cx.update(|cx| {
+        GitHostingProviderRegistry::default_global(cx);
+        git_hosting_providers::init(cx);
+    });
+
+    let fs = FakeFs::new(cx.executor());
+    let str_path = path!("/dir");
+    let path = Path::new(str_path);
+
+    fs.insert_tree(
+        path!("/dir"),
+        json!({
+            ".zed": {
+                "settings.json": r#"{
+                    "git_hosting_providers": [
+                        {
+                            "provider": "gitlab",
+                            "base_url": "https://google.com",
+                            "name": "foo"
+                        }
+                    ]
+                }"#
+            },
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+    let (_worktree, _) =
+        project.read_with(cx, |project, cx| project.find_worktree(path, cx).unwrap());
+    cx.executor().run_until_parked();
+
+    cx.update(|cx| {
+        let provider = GitHostingProviderRegistry::global(cx);
+        assert!(
+            provider
+                .list_hosting_providers()
+                .into_iter()
+                .any(|provider| provider.name() == "foo")
+        );
+    });
+
+    fs.atomic_write(
+        Path::new(path!("/dir/.zed/settings.json")).to_owned(),
+        "{}".into(),
+    )
+    .await
+    .unwrap();
+
+    cx.run_until_parked();
+
+    cx.update(|cx| {
+        let provider = GitHostingProviderRegistry::global(cx);
+        assert!(
+            !provider
+                .list_hosting_providers()
+                .into_iter()
+                .any(|provider| provider.name() == "foo")
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext) {
     init_test(cx);
@@ -263,6 +328,7 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext)
 
     let mut task_contexts = TaskContexts::default();
     task_contexts.active_worktree_context = Some((worktree_id, TaskContext::default()));
+    let task_contexts = Arc::new(task_contexts);
 
     let topmost_local_task_source_kind = TaskSourceKind::Worktree {
         id: worktree_id,
@@ -288,8 +354,9 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext)
             assert_eq!(settings_a.tab_size.get(), 8);
             assert_eq!(settings_b.tab_size.get(), 2);
 
-            get_all_tasks(&project, &task_contexts, cx)
+            get_all_tasks(&project, task_contexts.clone(), cx)
         })
+        .await
         .into_iter()
         .map(|(source_kind, task)| {
             let resolved = task.resolved;
@@ -307,7 +374,7 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext)
             (
                 TaskSourceKind::Worktree {
                     id: worktree_id,
-                    directory_in_worktree: PathBuf::from(separator!("b/.zed")),
+                    directory_in_worktree: PathBuf::from(path!("b/.zed")),
                     id_base: if cfg!(windows) {
                         "local worktree tasks from directory \"b\\\\.zed\"".into()
                     } else {
@@ -328,7 +395,8 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext)
     );
 
     let (_, resolved_task) = cx
-        .update(|cx| get_all_tasks(&project, &task_contexts, cx))
+        .update(|cx| get_all_tasks(&project, task_contexts.clone(), cx))
+        .await
         .into_iter()
         .find(|(source_kind, _)| source_kind == &topmost_local_task_source_kind)
         .expect("should have one global task");
@@ -366,7 +434,8 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext)
     cx.run_until_parked();
 
     let all_tasks = cx
-        .update(|cx| get_all_tasks(&project, &task_contexts, cx))
+        .update(|cx| get_all_tasks(&project, task_contexts.clone(), cx))
+        .await
         .into_iter()
         .map(|(source_kind, task)| {
             let resolved = task.resolved;
@@ -390,7 +459,7 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext)
             (
                 TaskSourceKind::Worktree {
                     id: worktree_id,
-                    directory_in_worktree: PathBuf::from(separator!("b/.zed")),
+                    directory_in_worktree: PathBuf::from(path!("b/.zed")),
                     id_base: if cfg!(windows) {
                         "local worktree tasks from directory \"b\\\\.zed\"".into()
                     } else {
@@ -453,43 +522,47 @@ async fn test_fallback_to_single_worktree_tasks(cx: &mut gpui::TestAppContext) {
         })
     });
 
-    let active_non_worktree_item_tasks = cx.update(|cx| {
-        get_all_tasks(
-            &project,
-            &TaskContexts {
-                active_item_context: Some((Some(worktree_id), None, TaskContext::default())),
-                active_worktree_context: None,
-                other_worktree_contexts: Vec::new(),
-                lsp_task_sources: HashMap::default(),
-                latest_selection: None,
-            },
-            cx,
-        )
-    });
+    let active_non_worktree_item_tasks = cx
+        .update(|cx| {
+            get_all_tasks(
+                &project,
+                Arc::new(TaskContexts {
+                    active_item_context: Some((Some(worktree_id), None, TaskContext::default())),
+                    active_worktree_context: None,
+                    other_worktree_contexts: Vec::new(),
+                    lsp_task_sources: HashMap::default(),
+                    latest_selection: None,
+                }),
+                cx,
+            )
+        })
+        .await;
     assert!(
         active_non_worktree_item_tasks.is_empty(),
         "A task can not be resolved with context with no ZED_WORKTREE_ROOT data"
     );
 
-    let active_worktree_tasks = cx.update(|cx| {
-        get_all_tasks(
-            &project,
-            &TaskContexts {
-                active_item_context: Some((Some(worktree_id), None, TaskContext::default())),
-                active_worktree_context: Some((worktree_id, {
-                    let mut worktree_context = TaskContext::default();
-                    worktree_context
-                        .task_variables
-                        .insert(task::VariableName::WorktreeRoot, "/dir".to_string());
-                    worktree_context
-                })),
-                other_worktree_contexts: Vec::new(),
-                lsp_task_sources: HashMap::default(),
-                latest_selection: None,
-            },
-            cx,
-        )
-    });
+    let active_worktree_tasks = cx
+        .update(|cx| {
+            get_all_tasks(
+                &project,
+                Arc::new(TaskContexts {
+                    active_item_context: Some((Some(worktree_id), None, TaskContext::default())),
+                    active_worktree_context: Some((worktree_id, {
+                        let mut worktree_context = TaskContext::default();
+                        worktree_context
+                            .task_variables
+                            .insert(task::VariableName::WorktreeRoot, "/dir".to_string());
+                        worktree_context
+                    })),
+                    other_worktree_contexts: Vec::new(),
+                    lsp_task_sources: HashMap::default(),
+                    latest_selection: None,
+                }),
+                cx,
+            )
+        })
+        .await;
     assert_eq!(
         active_worktree_tasks
             .into_iter()
@@ -501,7 +574,7 @@ async fn test_fallback_to_single_worktree_tasks(cx: &mut gpui::TestAppContext) {
         vec![(
             TaskSourceKind::Worktree {
                 id: worktree_id,
-                directory_in_worktree: PathBuf::from(separator!(".zed")),
+                directory_in_worktree: PathBuf::from(path!(".zed")),
                 id_base: if cfg!(windows) {
                     "local worktree tasks from directory \".zed\"".into()
                 } else {
@@ -845,6 +918,7 @@ async fn test_managing_language_servers(cx: &mut gpui::TestAppContext) {
     project.update(cx, |project, cx| {
         project.restart_language_servers_for_buffers(
             vec![rust_buffer.clone(), json_buffer.clone()],
+            HashSet::default(),
             cx,
         );
     });
@@ -1266,6 +1340,8 @@ async fn test_single_file_worktrees_diagnostics(cx: &mut gpui::TestAppContext) {
                         ..Default::default()
                     }],
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -1283,6 +1359,8 @@ async fn test_single_file_worktrees_diagnostics(cx: &mut gpui::TestAppContext) {
                         ..Default::default()
                     }],
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -1373,6 +1451,8 @@ async fn test_omitted_diagnostics(cx: &mut gpui::TestAppContext) {
                         ..Default::default()
                     }],
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -1390,6 +1470,8 @@ async fn test_omitted_diagnostics(cx: &mut gpui::TestAppContext) {
                         ..Default::default()
                     }],
                 },
+                None,
+                DiagnosticSourceKind::Pushed,
                 &[],
                 cx,
             )
@@ -1567,7 +1649,8 @@ async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) {
                     message: "undefined variable 'A'".to_string(),
                     group_id: 0,
                     is_primary: true,
-                    ..Default::default()
+                    source_kind: DiagnosticSourceKind::Pushed,
+                    ..Diagnostic::default()
                 }
             }]
         )
@@ -1633,12 +1716,16 @@ async fn test_restarting_server_with_diagnostics_running(cx: &mut gpui::TestAppC
 
     // Restart the server before the diagnostics finish updating.
     project.update(cx, |project, cx| {
-        project.restart_language_servers_for_buffers(vec![buffer], cx);
+        project.restart_language_servers_for_buffers(vec![buffer], HashSet::default(), cx);
     });
     let mut events = cx.events(&project);
 
     // Simulate the newly started server sending more diagnostics.
     let fake_server = fake_servers.next().await.unwrap();
+    assert_eq!(
+        events.next().await.unwrap(),
+        Event::LanguageServerRemoved(LanguageServerId(0))
+    );
     assert_eq!(
         events.next().await.unwrap(),
         Event::LanguageServerAdded(
@@ -1738,7 +1825,7 @@ async fn test_restarting_server_with_diagnostics_published(cx: &mut gpui::TestAp
     });
 
     project.update(cx, |project, cx| {
-        project.restart_language_servers_for_buffers(vec![buffer.clone()], cx);
+        project.restart_language_servers_for_buffers(vec![buffer.clone()], HashSet::default(), cx);
     });
 
     // The diagnostics are cleared.
@@ -1793,7 +1880,7 @@ async fn test_restarted_server_reporting_invalid_buffer_version(cx: &mut gpui::T
     });
     cx.executor().run_until_parked();
     project.update(cx, |project, cx| {
-        project.restart_language_servers_for_buffers(vec![buffer.clone()], cx);
+        project.restart_language_servers_for_buffers(vec![buffer.clone()], HashSet::default(), cx);
     });
 
     let mut fake_server = fake_servers.next().await.unwrap();
@@ -2083,7 +2170,8 @@ async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) {
                         is_disk_based: true,
                         group_id: 1,
                         is_primary: true,
-                        ..Default::default()
+                        source_kind: DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
                     },
                 },
                 DiagnosticEntry {
@@ -2095,7 +2183,8 @@ async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) {
                         is_disk_based: true,
                         group_id: 2,
                         is_primary: true,
-                        ..Default::default()
+                        source_kind: DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
                     }
                 }
             ]
@@ -2161,7 +2250,8 @@ async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) {
                         is_disk_based: true,
                         group_id: 4,
                         is_primary: true,
-                        ..Default::default()
+                        source_kind: DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
                     }
                 },
                 DiagnosticEntry {
@@ -2173,7 +2263,8 @@ async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) {
                         is_disk_based: true,
                         group_id: 3,
                         is_primary: true,
-                        ..Default::default()
+                        source_kind: DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
                     },
                 }
             ]
@@ -2253,7 +2344,8 @@ async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) {
                         is_disk_based: true,
                         group_id: 6,
                         is_primary: true,
-                        ..Default::default()
+                        source_kind: DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
                     }
                 },
                 DiagnosticEntry {
@@ -2265,7 +2357,8 @@ async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) {
                         is_disk_based: true,
                         group_id: 5,
                         is_primary: true,
-                        ..Default::default()
+                        source_kind: DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
                     },
                 }
             ]
@@ -2299,6 +2392,7 @@ async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) {
                     LanguageServerId(0),
                     PathBuf::from("/dir/a.rs"),
                     None,
+                    None,
                     vec![
                         DiagnosticEntry {
                             range: Unclipped(PointUtf16::new(0, 10))
@@ -2306,7 +2400,8 @@ async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) {
                             diagnostic: Diagnostic {
                                 severity: DiagnosticSeverity::ERROR,
                                 message: "syntax error 1".to_string(),
-                                ..Default::default()
+                                source_kind: DiagnosticSourceKind::Pushed,
+                                ..Diagnostic::default()
                             },
                         },
                         DiagnosticEntry {
@@ -2315,7 +2410,8 @@ async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) {
                             diagnostic: Diagnostic {
                                 severity: DiagnosticSeverity::ERROR,
                                 message: "syntax error 2".to_string(),
-                                ..Default::default()
+                                source_kind: DiagnosticSourceKind::Pushed,
+                                ..Diagnostic::default()
                             },
                         },
                     ],
@@ -2363,13 +2459,15 @@ async fn test_diagnostics_from_multiple_language_servers(cx: &mut gpui::TestAppC
                 LanguageServerId(0),
                 Path::new("/dir/a.rs").to_owned(),
                 None,
+                None,
                 vec![DiagnosticEntry {
                     range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 3)),
                     diagnostic: Diagnostic {
                         severity: DiagnosticSeverity::ERROR,
                         is_primary: true,
                         message: "syntax error a1".to_string(),
-                        ..Default::default()
+                        source_kind: DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
                     },
                 }],
                 cx,
@@ -2380,13 +2478,15 @@ async fn test_diagnostics_from_multiple_language_servers(cx: &mut gpui::TestAppC
                 LanguageServerId(1),
                 Path::new("/dir/a.rs").to_owned(),
                 None,
+                None,
                 vec![DiagnosticEntry {
                     range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 3)),
                     diagnostic: Diagnostic {
                         severity: DiagnosticSeverity::ERROR,
                         is_primary: true,
                         message: "syntax error b1".to_string(),
-                        ..Default::default()
+                        source_kind: DiagnosticSourceKind::Pushed,
+                        ..Diagnostic::default()
                     },
                 }],
                 cx,
@@ -3014,7 +3114,12 @@ async fn test_completions_with_text_edit(cx: &mut gpui::TestAppContext) {
         .next()
         .await;
 
-    let completions = completions.await.unwrap().unwrap();
+    let completions = completions
+        .await
+        .unwrap()
+        .into_iter()
+        .flat_map(|response| response.completions)
+        .collect::<Vec<_>>();
     let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
 
     assert_eq!(completions.len(), 1);
@@ -3097,7 +3202,12 @@ async fn test_completions_with_edit_ranges(cx: &mut gpui::TestAppContext) {
             .next()
             .await;
 
-        let completions = completions.await.unwrap().unwrap();
+        let completions = completions
+            .await
+            .unwrap()
+            .into_iter()
+            .flat_map(|response| response.completions)
+            .collect::<Vec<_>>();
         let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
 
         assert_eq!(completions.len(), 1);
@@ -3139,7 +3249,12 @@ async fn test_completions_with_edit_ranges(cx: &mut gpui::TestAppContext) {
             .next()
             .await;
 
-        let completions = completions.await.unwrap().unwrap();
+        let completions = completions
+            .await
+            .unwrap()
+            .into_iter()
+            .flat_map(|response| response.completions)
+            .collect::<Vec<_>>();
         let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
 
         assert_eq!(completions.len(), 1);
@@ -3210,7 +3325,12 @@ async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) {
         })
         .next()
         .await;
-    let completions = completions.await.unwrap().unwrap();
+    let completions = completions
+        .await
+        .unwrap()
+        .into_iter()
+        .flat_map(|response| response.completions)
+        .collect::<Vec<_>>();
     let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
     assert_eq!(completions.len(), 1);
     assert_eq!(completions[0].new_text, "fullyQualifiedName");
@@ -3237,7 +3357,12 @@ async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) {
         })
         .next()
         .await;
-    let completions = completions.await.unwrap().unwrap();
+    let completions = completions
+        .await
+        .unwrap()
+        .into_iter()
+        .flat_map(|response| response.completions)
+        .collect::<Vec<_>>();
     let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
     assert_eq!(completions.len(), 1);
     assert_eq!(completions[0].new_text, "component");
@@ -3305,7 +3430,12 @@ async fn test_completions_with_carriage_returns(cx: &mut gpui::TestAppContext) {
         })
         .next()
         .await;
-    let completions = completions.await.unwrap().unwrap();
+    let completions = completions
+        .await
+        .unwrap()
+        .into_iter()
+        .flat_map(|response| response.completions)
+        .collect::<Vec<_>>();
     assert_eq!(completions.len(), 1);
     assert_eq!(completions[0].new_text, "fully\nQualified\nName");
 }
@@ -3488,6 +3618,86 @@ async fn test_save_file(cx: &mut gpui::TestAppContext) {
     assert_eq!(new_text, buffer.update(cx, |buffer, _| buffer.text()));
 }
 
+#[gpui::test(iterations = 10)]
+async fn test_save_file_spawns_language_server(cx: &mut gpui::TestAppContext) {
+    // Issue: #24349
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(path!("/dir"), json!({})).await;
+
+    let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+
+    language_registry.add(rust_lang());
+    let mut fake_rust_servers = language_registry.register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            name: "the-rust-language-server",
+            capabilities: lsp::ServerCapabilities {
+                completion_provider: Some(lsp::CompletionOptions {
+                    trigger_characters: Some(vec![".".to_string(), "::".to_string()]),
+                    ..Default::default()
+                }),
+                text_document_sync: Some(lsp::TextDocumentSyncCapability::Options(
+                    lsp::TextDocumentSyncOptions {
+                        save: Some(lsp::TextDocumentSyncSaveOptions::Supported(true)),
+                        ..Default::default()
+                    },
+                )),
+                ..Default::default()
+            },
+            ..Default::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |this, cx| this.create_buffer(cx))
+        .unwrap()
+        .await;
+    project.update(cx, |this, cx| {
+        this.register_buffer_with_language_servers(&buffer, cx);
+        buffer.update(cx, |buffer, cx| {
+            assert!(!this.has_language_servers_for(buffer, cx));
+        })
+    });
+
+    project
+        .update(cx, |this, cx| {
+            let worktree_id = this.worktrees(cx).next().unwrap().read(cx).id();
+            this.save_buffer_as(
+                buffer.clone(),
+                ProjectPath {
+                    worktree_id,
+                    path: Arc::from("file.rs".as_ref()),
+                },
+                cx,
+            )
+        })
+        .await
+        .unwrap();
+    // A server is started up, and it is notified about Rust files.
+    let mut fake_rust_server = fake_rust_servers.next().await.unwrap();
+    assert_eq!(
+        fake_rust_server
+            .receive_notification::<lsp::notification::DidOpenTextDocument>()
+            .await
+            .text_document,
+        lsp::TextDocumentItem {
+            uri: lsp::Url::from_file_path(path!("/dir/file.rs")).unwrap(),
+            version: 0,
+            text: "".to_string(),
+            language_id: "rust".to_string(),
+        }
+    );
+
+    project.update(cx, |this, cx| {
+        buffer.update(cx, |buffer, cx| {
+            assert!(this.has_language_servers_for(buffer, cx));
+        })
+    });
+}
+
 #[gpui::test(iterations = 30)]
 async fn test_file_changes_multiple_times_on_disk(cx: &mut gpui::TestAppContext) {
     init_test(cx);
@@ -3781,12 +3991,12 @@ async fn test_rescan_and_remote_updates(cx: &mut gpui::TestAppContext) {
                 .collect::<Vec<_>>(),
             vec![
                 "a",
-                separator!("a/file1"),
-                separator!("a/file2.new"),
+                path!("a/file1"),
+                path!("a/file2.new"),
                 "b",
                 "d",
-                separator!("d/file3"),
-                separator!("d/file4"),
+                path!("d/file3"),
+                path!("d/file4"),
             ]
         );
     });
@@ -3849,12 +4059,12 @@ async fn test_rescan_and_remote_updates(cx: &mut gpui::TestAppContext) {
                 .collect::<Vec<_>>(),
             vec![
                 "a",
-                separator!("a/file1"),
-                separator!("a/file2.new"),
+                path!("a/file1"),
+                path!("a/file2.new"),
                 "b",
                 "d",
-                separator!("d/file3"),
-                separator!("d/file4"),
+                path!("d/file3"),
+                path!("d/file4"),
             ]
         );
     });
@@ -4402,7 +4612,14 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
 
     lsp_store
         .update(cx, |lsp_store, cx| {
-            lsp_store.update_diagnostics(LanguageServerId(0), message, &[], cx)
+            lsp_store.update_diagnostics(
+                LanguageServerId(0),
+                message,
+                None,
+                DiagnosticSourceKind::Pushed,
+                &[],
+                cx,
+            )
         })
         .unwrap();
     let buffer = buffer.update(cx, |buffer, _| buffer.snapshot());
@@ -4419,7 +4636,8 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
                     message: "error 1".to_string(),
                     group_id: 1,
                     is_primary: true,
-                    ..Default::default()
+                    source_kind: DiagnosticSourceKind::Pushed,
+                    ..Diagnostic::default()
                 }
             },
             DiagnosticEntry {
@@ -4429,7 +4647,8 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
                     message: "error 1 hint 1".to_string(),
                     group_id: 1,
                     is_primary: false,
-                    ..Default::default()
+                    source_kind: DiagnosticSourceKind::Pushed,
+                    ..Diagnostic::default()
                 }
             },
             DiagnosticEntry {
@@ -4439,7 +4658,8 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
                     message: "error 2 hint 1".to_string(),
                     group_id: 0,
                     is_primary: false,
-                    ..Default::default()
+                    source_kind: DiagnosticSourceKind::Pushed,
+                    ..Diagnostic::default()
                 }
             },
             DiagnosticEntry {
@@ -4449,7 +4669,8 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
                     message: "error 2 hint 2".to_string(),
                     group_id: 0,
                     is_primary: false,
-                    ..Default::default()
+                    source_kind: DiagnosticSourceKind::Pushed,
+                    ..Diagnostic::default()
                 }
             },
             DiagnosticEntry {
@@ -4459,7 +4680,8 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
                     message: "error 2".to_string(),
                     group_id: 0,
                     is_primary: true,
-                    ..Default::default()
+                    source_kind: DiagnosticSourceKind::Pushed,
+                    ..Diagnostic::default()
                 }
             }
         ]
@@ -4475,7 +4697,8 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
                     message: "error 2 hint 1".to_string(),
                     group_id: 0,
                     is_primary: false,
-                    ..Default::default()
+                    source_kind: DiagnosticSourceKind::Pushed,
+                    ..Diagnostic::default()
                 }
             },
             DiagnosticEntry {
@@ -4485,7 +4708,8 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
                     message: "error 2 hint 2".to_string(),
                     group_id: 0,
                     is_primary: false,
-                    ..Default::default()
+                    source_kind: DiagnosticSourceKind::Pushed,
+                    ..Diagnostic::default()
                 }
             },
             DiagnosticEntry {
@@ -4495,7 +4719,8 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
                     message: "error 2".to_string(),
                     group_id: 0,
                     is_primary: true,
-                    ..Default::default()
+                    source_kind: DiagnosticSourceKind::Pushed,
+                    ..Diagnostic::default()
                 }
             }
         ]
@@ -4511,7 +4736,8 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
                     message: "error 1".to_string(),
                     group_id: 1,
                     is_primary: true,
-                    ..Default::default()
+                    source_kind: DiagnosticSourceKind::Pushed,
+                    ..Diagnostic::default()
                 }
             },
             DiagnosticEntry {
@@ -4521,7 +4747,8 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
                     message: "error 1 hint 1".to_string(),
                     group_id: 1,
                     is_primary: false,
-                    ..Default::default()
+                    source_kind: DiagnosticSourceKind::Pushed,
+                    ..Diagnostic::default()
                 }
             },
         ]
@@ -4832,8 +5059,8 @@ async fn test_search(cx: &mut gpui::TestAppContext) {
         .await
         .unwrap(),
         HashMap::from_iter([
-            (separator!("dir/two.rs").to_string(), vec![6..9]),
-            (separator!("dir/three.rs").to_string(), vec![37..40])
+            (path!("dir/two.rs").to_string(), vec![6..9]),
+            (path!("dir/three.rs").to_string(), vec![37..40])
         ])
     );
 
@@ -4867,9 +5094,9 @@ async fn test_search(cx: &mut gpui::TestAppContext) {
         .await
         .unwrap(),
         HashMap::from_iter([
-            (separator!("dir/two.rs").to_string(), vec![6..9]),
-            (separator!("dir/three.rs").to_string(), vec![37..40]),
-            (separator!("dir/four.rs").to_string(), vec![25..28, 36..39])
+            (path!("dir/two.rs").to_string(), vec![6..9]),
+            (path!("dir/three.rs").to_string(), vec![37..40]),
+            (path!("dir/four.rs").to_string(), vec![25..28, 36..39])
         ])
     );
 }
@@ -4934,8 +5161,8 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
         .await
         .unwrap(),
         HashMap::from_iter([
-            (separator!("dir/one.rs").to_string(), vec![8..12]),
-            (separator!("dir/two.rs").to_string(), vec![8..12]),
+            (path!("dir/one.rs").to_string(), vec![8..12]),
+            (path!("dir/two.rs").to_string(), vec![8..12]),
         ]),
         "Rust only search should give only Rust files"
     );
@@ -4959,8 +5186,8 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
         .await
         .unwrap(),
         HashMap::from_iter([
-            (separator!("dir/one.ts").to_string(), vec![14..18]),
-            (separator!("dir/two.ts").to_string(), vec![14..18]),
+            (path!("dir/one.ts").to_string(), vec![14..18]),
+            (path!("dir/two.ts").to_string(), vec![14..18]),
         ]),
         "TypeScript only search should give only TypeScript files, even if other inclusions don't match anything"
     );
@@ -4985,10 +5212,10 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
         .await
         .unwrap(),
         HashMap::from_iter([
-            (separator!("dir/two.ts").to_string(), vec![14..18]),
-            (separator!("dir/one.rs").to_string(), vec![8..12]),
-            (separator!("dir/one.ts").to_string(), vec![14..18]),
-            (separator!("dir/two.rs").to_string(), vec![8..12]),
+            (path!("dir/two.ts").to_string(), vec![14..18]),
+            (path!("dir/one.rs").to_string(), vec![8..12]),
+            (path!("dir/one.ts").to_string(), vec![14..18]),
+            (path!("dir/two.rs").to_string(), vec![8..12]),
         ]),
         "Rust and typescript search should give both Rust and TypeScript files, even if other inclusions don't match anything"
     );
@@ -5032,10 +5259,10 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
         .await
         .unwrap(),
         HashMap::from_iter([
-            (separator!("dir/one.rs").to_string(), vec![8..12]),
-            (separator!("dir/one.ts").to_string(), vec![14..18]),
-            (separator!("dir/two.rs").to_string(), vec![8..12]),
-            (separator!("dir/two.ts").to_string(), vec![14..18]),
+            (path!("dir/one.rs").to_string(), vec![8..12]),
+            (path!("dir/one.ts").to_string(), vec![14..18]),
+            (path!("dir/two.rs").to_string(), vec![8..12]),
+            (path!("dir/two.ts").to_string(), vec![14..18]),
         ]),
         "If no exclusions match, all files should be returned"
     );
@@ -5059,8 +5286,8 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
         .await
         .unwrap(),
         HashMap::from_iter([
-            (separator!("dir/one.ts").to_string(), vec![14..18]),
-            (separator!("dir/two.ts").to_string(), vec![14..18]),
+            (path!("dir/one.ts").to_string(), vec![14..18]),
+            (path!("dir/two.ts").to_string(), vec![14..18]),
         ]),
         "Rust exclusion search should give only TypeScript files"
     );
@@ -5084,8 +5311,134 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
         .await
         .unwrap(),
         HashMap::from_iter([
-            (separator!("dir/one.rs").to_string(), vec![8..12]),
-            (separator!("dir/two.rs").to_string(), vec![8..12]),
+            (path!("dir/one.rs").to_string(), vec![8..12]),
+            (path!("dir/two.rs").to_string(), vec![8..12]),
+        ]),
+        "TypeScript exclusion search should give only Rust files, even if other exclusions don't match anything"
+    );
+
+    assert!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                false,
+                Default::default(),
+                PathMatcher::new(&["*.rs".to_owned(), "*.ts".to_owned(), "*.odd".to_owned()])
+                    .unwrap(),
+                false,
+                None,
+            )
+            .unwrap(),
+            cx
+        )
+        .await
+        .unwrap()
+        .is_empty(),
+        "Rust and typescript exclusion should give no files, even if other exclusions don't match anything"
+    );
+}
+
+#[gpui::test]
+async fn test_search_with_buffer_exclusions(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let search_query = "file";
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/dir"),
+        json!({
+            "one.rs": r#"// Rust file one"#,
+            "one.ts": r#"// TypeScript file one"#,
+            "two.rs": r#"// Rust file two"#,
+            "two.ts": r#"// TypeScript file two"#,
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+    let _buffer = project.update(cx, |project, cx| {
+        let buffer = project.create_local_buffer("file", None, cx);
+        project.mark_buffer_as_non_searchable(buffer.read(cx).remote_id(), cx);
+        buffer
+    });
+
+    assert_eq!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                false,
+                Default::default(),
+                PathMatcher::new(&["*.odd".to_owned()]).unwrap(),
+                false,
+                None,
+            )
+            .unwrap(),
+            cx
+        )
+        .await
+        .unwrap(),
+        HashMap::from_iter([
+            (path!("dir/one.rs").to_string(), vec![8..12]),
+            (path!("dir/one.ts").to_string(), vec![14..18]),
+            (path!("dir/two.rs").to_string(), vec![8..12]),
+            (path!("dir/two.ts").to_string(), vec![14..18]),
+        ]),
+        "If no exclusions match, all files should be returned"
+    );
+
+    assert_eq!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                false,
+                Default::default(),
+                PathMatcher::new(&["*.rs".to_owned()]).unwrap(),
+                false,
+                None,
+            )
+            .unwrap(),
+            cx
+        )
+        .await
+        .unwrap(),
+        HashMap::from_iter([
+            (path!("dir/one.ts").to_string(), vec![14..18]),
+            (path!("dir/two.ts").to_string(), vec![14..18]),
+        ]),
+        "Rust exclusion search should give only TypeScript files"
+    );
+
+    assert_eq!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                false,
+                Default::default(),
+                PathMatcher::new(&["*.ts".to_owned(), "*.odd".to_owned()]).unwrap(),
+                false,
+                None,
+            )
+            .unwrap(),
+            cx
+        )
+        .await
+        .unwrap(),
+        HashMap::from_iter([
+            (path!("dir/one.rs").to_string(), vec![8..12]),
+            (path!("dir/two.rs").to_string(), vec![8..12]),
         ]),
         "TypeScript exclusion search should give only Rust files, even if other exclusions don't match anything"
     );
@@ -5218,8 +5571,8 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex
         .await
         .unwrap(),
         HashMap::from_iter([
-            (separator!("dir/one.ts").to_string(), vec![14..18]),
-            (separator!("dir/two.ts").to_string(), vec![14..18]),
+            (path!("dir/one.ts").to_string(), vec![14..18]),
+            (path!("dir/two.ts").to_string(), vec![14..18]),
         ]),
         "Non-intersecting TypeScript inclusions and Rust exclusions should return TypeScript files"
     );

crates/project/src/search.rs 🔗

@@ -521,10 +521,8 @@ pub fn deserialize_path_matches(glob_set: &str) -> anyhow::Result<PathMatcher> {
     let globs = glob_set
         .split(',')
         .map(str::trim)
-        .filter(|&glob_str| (!glob_str.is_empty()))
-        .map(|glob_str| glob_str.to_owned())
-        .collect::<Vec<_>>();
-    Ok(PathMatcher::new(&globs)?)
+        .filter(|&glob_str| !glob_str.is_empty());
+    Ok(PathMatcher::new(globs)?)
 }
 
 #[cfg(test)]

crates/project/src/search_history.rs 🔗

@@ -1,3 +1,5 @@
+use std::collections::VecDeque;
+
 /// Determines the behavior to use when inserting a new query into the search history.
 #[derive(Default, Debug, Clone, PartialEq)]
 pub enum QueryInsertionBehavior {
@@ -28,7 +30,7 @@ impl SearchHistoryCursor {
 
 #[derive(Debug, Clone)]
 pub struct SearchHistory {
-    history: Vec<String>,
+    history: VecDeque<String>,
     max_history_len: Option<usize>,
     insertion_behavior: QueryInsertionBehavior,
 }
@@ -38,7 +40,7 @@ impl SearchHistory {
         SearchHistory {
             max_history_len,
             insertion_behavior,
-            history: Vec::new(),
+            history: VecDeque::new(),
         }
     }
 
@@ -50,7 +52,7 @@ impl SearchHistory {
         }
 
         if self.insertion_behavior == QueryInsertionBehavior::ReplacePreviousIfContains {
-            if let Some(previously_searched) = self.history.last_mut() {
+            if let Some(previously_searched) = self.history.back_mut() {
                 if search_string.contains(previously_searched.as_str()) {
                     *previously_searched = search_string;
                     cursor.selection = Some(self.history.len() - 1);
@@ -59,12 +61,12 @@ impl SearchHistory {
             }
         }
 
-        self.history.push(search_string);
         if let Some(max_history_len) = self.max_history_len {
-            if self.history.len() > max_history_len {
-                self.history.remove(0);
+            if self.history.len() >= max_history_len {
+                self.history.pop_front();
             }
         }
+        self.history.push_back(search_string);
 
         cursor.selection = Some(self.history.len() - 1);
     }

crates/project/src/task_inventory.rs 🔗

@@ -10,10 +10,12 @@ use std::{
 
 use anyhow::Result;
 use collections::{HashMap, HashSet, VecDeque};
-use gpui::{App, AppContext as _, Entity, SharedString, Task};
+use dap::DapRegistry;
+use fs::Fs;
+use gpui::{App, AppContext as _, Context, Entity, SharedString, Task};
 use itertools::Itertools;
 use language::{
-    Buffer, ContextProvider, File, Language, LanguageToolchainStore, Location,
+    Buffer, ContextLocation, ContextProvider, File, Language, LanguageToolchainStore, Location,
     language_settings::language_settings,
 };
 use lsp::{LanguageServerId, LanguageServerName};
@@ -30,13 +32,25 @@ use worktree::WorktreeId;
 use crate::{task_store::TaskSettingsLocation, worktree_store::WorktreeStore};
 
 /// Inventory tracks available tasks for a given project.
-#[derive(Debug, Default)]
 pub struct Inventory {
+    fs: Arc<dyn Fs>,
     last_scheduled_tasks: VecDeque<(TaskSourceKind, ResolvedTask)>,
+    last_scheduled_scenarios: VecDeque<DebugScenario>,
     templates_from_settings: InventoryFor<TaskTemplate>,
     scenarios_from_settings: InventoryFor<DebugScenario>,
 }
 
+impl std::fmt::Debug for Inventory {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("Inventory")
+            .field("last_scheduled_tasks", &self.last_scheduled_tasks)
+            .field("last_scheduled_scenarios", &self.last_scheduled_scenarios)
+            .field("templates_from_settings", &self.templates_from_settings)
+            .field("scenarios_from_settings", &self.scenarios_from_settings)
+            .finish()
+    }
+}
+
 // Helper trait for better error messages in [InventoryFor]
 trait InventoryContents: Clone {
     const GLOBAL_SOURCE_FILE: &'static str;
@@ -63,30 +77,28 @@ struct InventoryFor<T> {
 impl<T: InventoryContents> InventoryFor<T> {
     fn worktree_scenarios(
         &self,
-        worktree: Option<WorktreeId>,
+        worktree: WorktreeId,
     ) -> impl '_ + Iterator<Item = (TaskSourceKind, T)> {
-        worktree.into_iter().flat_map(|worktree| {
-            self.worktree
-                .get(&worktree)
-                .into_iter()
-                .flatten()
-                .flat_map(|(directory, templates)| {
-                    templates.iter().map(move |template| (directory, template))
-                })
-                .map(move |(directory, template)| {
-                    (
-                        TaskSourceKind::Worktree {
-                            id: worktree,
-                            directory_in_worktree: directory.to_path_buf(),
-                            id_base: Cow::Owned(format!(
-                                "local worktree {} from directory {directory:?}",
-                                T::LABEL
-                            )),
-                        },
-                        template.clone(),
-                    )
-                })
-        })
+        self.worktree
+            .get(&worktree)
+            .into_iter()
+            .flatten()
+            .flat_map(|(directory, templates)| {
+                templates.iter().map(move |template| (directory, template))
+            })
+            .map(move |(directory, template)| {
+                (
+                    TaskSourceKind::Worktree {
+                        id: worktree,
+                        directory_in_worktree: directory.to_path_buf(),
+                        id_base: Cow::Owned(format!(
+                            "local worktree {} from directory {directory:?}",
+                            T::LABEL
+                        )),
+                    },
+                    template.clone(),
+                )
+            })
     }
 
     fn global_scenarios(&self) -> impl '_ + Iterator<Item = (TaskSourceKind, T)> {
@@ -132,7 +144,10 @@ pub enum TaskSourceKind {
     /// Languages-specific tasks coming from extensions.
     Language { name: SharedString },
     /// Language-specific tasks coming from LSP servers.
-    Lsp(LanguageServerId),
+    Lsp {
+        language_name: SharedString,
+        server: LanguageServerId,
+    },
 }
 
 /// A collection of task contexts, derived from the current state of the workspace.
@@ -168,6 +183,13 @@ impl TaskContexts {
             .and_then(|(_, location, _)| location.as_ref())
     }
 
+    pub fn file(&self, cx: &App) -> Option<Arc<dyn File>> {
+        self.active_item_context
+            .as_ref()
+            .and_then(|(_, location, _)| location.as_ref())
+            .and_then(|location| location.buffer.read(cx).file().cloned())
+    }
+
     pub fn worktree(&self) -> Option<WorktreeId> {
         self.active_item_context
             .as_ref()
@@ -204,26 +226,101 @@ impl TaskSourceKind {
                 format!("{id_base}_{id}_{}", directory_in_worktree.display())
             }
             Self::Language { name } => format!("language_{name}"),
-            Self::Lsp(server_id) => format!("lsp_{server_id}"),
+            Self::Lsp {
+                server,
+                language_name,
+            } => format!("lsp_{language_name}_{server}"),
         }
     }
 }
 
 impl Inventory {
-    pub fn new(cx: &mut App) -> Entity<Self> {
-        cx.new(|_| Self::default())
+    pub fn new(fs: Arc<dyn Fs>, cx: &mut App) -> Entity<Self> {
+        cx.new(|_| Self {
+            fs,
+            last_scheduled_tasks: VecDeque::default(),
+            last_scheduled_scenarios: VecDeque::default(),
+            templates_from_settings: InventoryFor::default(),
+            scenarios_from_settings: InventoryFor::default(),
+        })
+    }
+
+    pub fn scenario_scheduled(&mut self, scenario: DebugScenario) {
+        self.last_scheduled_scenarios
+            .retain(|s| s.label != scenario.label);
+        self.last_scheduled_scenarios.push_back(scenario);
+        if self.last_scheduled_scenarios.len() > 5_000 {
+            self.last_scheduled_scenarios.pop_front();
+        }
+    }
+
+    pub fn last_scheduled_scenario(&self) -> Option<&DebugScenario> {
+        self.last_scheduled_scenarios.back()
     }
 
     pub fn list_debug_scenarios(
         &self,
-        worktrees: impl Iterator<Item = WorktreeId>,
-    ) -> Vec<(TaskSourceKind, DebugScenario)> {
-        let global_scenarios = self.global_debug_scenarios_from_settings();
+        task_contexts: &TaskContexts,
+        lsp_tasks: Vec<(TaskSourceKind, task::ResolvedTask)>,
+        current_resolved_tasks: Vec<(TaskSourceKind, task::ResolvedTask)>,
+        add_current_language_tasks: bool,
+        cx: &mut App,
+    ) -> Task<(Vec<DebugScenario>, Vec<(TaskSourceKind, DebugScenario)>)> {
+        let mut scenarios = Vec::new();
 
-        worktrees
-            .flat_map(|tree_id| self.worktree_scenarios_from_settings(Some(tree_id)))
-            .chain(global_scenarios)
-            .collect()
+        if let Some(worktree_id) = task_contexts
+            .active_worktree_context
+            .iter()
+            .chain(task_contexts.other_worktree_contexts.iter())
+            .map(|context| context.0)
+            .next()
+        {
+            scenarios.extend(self.worktree_scenarios_from_settings(worktree_id));
+        }
+        scenarios.extend(self.global_debug_scenarios_from_settings());
+
+        let last_scheduled_scenarios = self.last_scheduled_scenarios.iter().cloned().collect();
+
+        let adapter = task_contexts.location().and_then(|location| {
+            let (file, language) = {
+                let buffer = location.buffer.read(cx);
+                (buffer.file(), buffer.language())
+            };
+            let language_name = language.as_ref().map(|l| l.name());
+            let adapter = language_settings(language_name, file, cx)
+                .debuggers
+                .first()
+                .map(SharedString::from)
+                .or_else(|| {
+                    language.and_then(|l| l.config().debuggers.first().map(SharedString::from))
+                });
+            adapter.map(|adapter| (adapter, DapRegistry::global(cx).locators()))
+        });
+        cx.background_spawn(async move {
+            if let Some((adapter, locators)) = adapter {
+                for (kind, task) in
+                    lsp_tasks
+                        .into_iter()
+                        .chain(current_resolved_tasks.into_iter().filter(|(kind, _)| {
+                            add_current_language_tasks
+                                || !matches!(kind, TaskSourceKind::Language { .. })
+                        }))
+                {
+                    let adapter = adapter.clone().into();
+
+                    for locator in locators.values() {
+                        if let Some(scenario) = locator
+                            .create_scenario(&task.original_task(), &task.display_label(), &adapter)
+                            .await
+                        {
+                            scenarios.push((kind, scenario));
+                            break;
+                        }
+                    }
+                }
+            }
+            (last_scheduled_scenarios, scenarios)
+        })
     }
 
     pub fn task_template_by_label(
@@ -232,7 +329,7 @@ impl Inventory {
         worktree_id: Option<WorktreeId>,
         label: &str,
         cx: &App,
-    ) -> Option<TaskTemplate> {
+    ) -> Task<Option<TaskTemplate>> {
         let (buffer_worktree_id, file, language) = buffer
             .map(|buffer| {
                 let buffer = buffer.read(cx);
@@ -245,10 +342,15 @@ impl Inventory {
             })
             .unwrap_or((None, None, None));
 
-        self.list_tasks(file, language, worktree_id.or(buffer_worktree_id), cx)
-            .iter()
-            .find(|(_, template)| template.label == label)
-            .map(|val| val.1.clone())
+        let tasks = self.list_tasks(file, language, worktree_id.or(buffer_worktree_id), cx);
+        let label = label.to_owned();
+        cx.background_spawn(async move {
+            tasks
+                .await
+                .into_iter()
+                .find(|(_, template)| template.label == label)
+                .map(|val| val.1)
+        })
     }
 
     /// Pulls its task sources relevant to the worktree and the language given,
@@ -260,9 +362,13 @@ impl Inventory {
         language: Option<Arc<Language>>,
         worktree: Option<WorktreeId>,
         cx: &App,
-    ) -> Vec<(TaskSourceKind, TaskTemplate)> {
-        let global_tasks = self.global_templates_from_settings();
-        let worktree_tasks = self.worktree_templates_from_settings(worktree);
+    ) -> Task<Vec<(TaskSourceKind, TaskTemplate)>> {
+        let global_tasks = self.global_templates_from_settings().collect::<Vec<_>>();
+        let fs = self.fs.clone();
+        let mut worktree_tasks = worktree
+            .into_iter()
+            .flat_map(|worktree| self.worktree_templates_from_settings(worktree))
+            .collect::<Vec<_>>();
         let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language {
             name: language.name().into(),
         });
@@ -272,33 +378,41 @@ impl Inventory {
                     .tasks
                     .enabled
             })
-            .and_then(|language| language.context_provider()?.associated_tasks(file, cx))
-            .into_iter()
-            .flat_map(|tasks| tasks.0.into_iter())
-            .flat_map(|task| Some((task_source_kind.clone()?, task)));
-
-        worktree_tasks
-            .chain(language_tasks)
-            .chain(global_tasks)
-            .collect()
+            .and_then(|language| {
+                language
+                    .context_provider()
+                    .map(|provider| provider.associated_tasks(fs, file, cx))
+            });
+        cx.background_spawn(async move {
+            if let Some(t) = language_tasks {
+                worktree_tasks.extend(t.await.into_iter().flat_map(|tasks| {
+                    tasks
+                        .0
+                        .into_iter()
+                        .filter_map(|task| Some((task_source_kind.clone()?, task)))
+                }));
+            }
+            worktree_tasks.extend(global_tasks);
+            worktree_tasks
+        })
     }
 
     /// Pulls its task sources relevant to the worktree and the language given and resolves them with the [`TaskContexts`] given.
     /// Joins the new resolutions with the resolved tasks that were used (spawned) before,
     /// orders them so that the most recently used come first, all equally used ones are ordered so that the most specific tasks come first.
     /// Deduplicates the tasks by their labels and context and splits the ordered list into two: used tasks and the rest, newly resolved tasks.
-    pub fn used_and_current_resolved_tasks<'a>(
-        &'a self,
-        task_contexts: &'a TaskContexts,
-        cx: &'a App,
-    ) -> (
+    pub fn used_and_current_resolved_tasks(
+        &self,
+        task_contexts: Arc<TaskContexts>,
+        cx: &mut Context<Self>,
+    ) -> Task<(
         Vec<(TaskSourceKind, ResolvedTask)>,
         Vec<(TaskSourceKind, ResolvedTask)>,
-    ) {
+    )> {
+        let fs = self.fs.clone();
         let worktree = task_contexts.worktree();
         let location = task_contexts.location();
-        let language = location
-            .and_then(|location| location.buffer.read(cx).language_at(location.range.start));
+        let language = location.and_then(|location| location.buffer.read(cx).language());
         let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language {
             name: language.name().into(),
         });
@@ -342,84 +456,103 @@ impl Inventory {
             .collect::<Vec<_>>();
 
         let not_used_score = post_inc(&mut lru_score);
-        let global_tasks = self.global_templates_from_settings();
-
-        let language_tasks = language
+        let global_tasks = self.global_templates_from_settings().collect::<Vec<_>>();
+        let associated_tasks = language
             .filter(|language| {
                 language_settings(Some(language.name()), file.as_ref(), cx)
                     .tasks
                     .enabled
             })
-            .and_then(|language| language.context_provider()?.associated_tasks(file, cx))
+            .and_then(|language| {
+                language
+                    .context_provider()
+                    .map(|provider| provider.associated_tasks(fs, file, cx))
+            });
+        let worktree_tasks = worktree
             .into_iter()
-            .flat_map(|tasks| tasks.0.into_iter())
-            .flat_map(|task| Some((task_source_kind.clone()?, task)));
-        let worktree_tasks = self
-            .worktree_templates_from_settings(worktree)
-            .chain(language_tasks)
-            .chain(global_tasks);
-
-        let new_resolved_tasks = worktree_tasks
-            .flat_map(|(kind, task)| {
-                let id_base = kind.to_id_base();
-                if let TaskSourceKind::Worktree { id, .. } = &kind {
-                    None.or_else(|| {
-                        let (_, _, item_context) = task_contexts
-                            .active_item_context
-                            .as_ref()
-                            .filter(|(worktree_id, _, _)| Some(id) == worktree_id.as_ref())?;
-                        task.resolve_task(&id_base, item_context)
-                    })
-                    .or_else(|| {
-                        let (_, worktree_context) = task_contexts
-                            .active_worktree_context
-                            .as_ref()
-                            .filter(|(worktree_id, _)| id == worktree_id)?;
-                        task.resolve_task(&id_base, worktree_context)
-                    })
-                    .or_else(|| {
-                        if let TaskSourceKind::Worktree { id, .. } = &kind {
-                            let worktree_context = task_contexts
-                                .other_worktree_contexts
-                                .iter()
-                                .find(|(worktree_id, _)| worktree_id == id)
-                                .map(|(_, context)| context)?;
+            .flat_map(|worktree| self.worktree_templates_from_settings(worktree))
+            .collect::<Vec<_>>();
+        let task_contexts = task_contexts.clone();
+        cx.background_spawn(async move {
+            let language_tasks = if let Some(task) = associated_tasks {
+                task.await.map(|templates| {
+                    templates
+                        .0
+                        .into_iter()
+                        .flat_map(|task| Some((task_source_kind.clone()?, task)))
+                })
+            } else {
+                None
+            };
+
+            let worktree_tasks = worktree_tasks
+                .into_iter()
+                .chain(language_tasks.into_iter().flatten())
+                .chain(global_tasks);
+
+            let new_resolved_tasks = worktree_tasks
+                .flat_map(|(kind, task)| {
+                    let id_base = kind.to_id_base();
+                    if let TaskSourceKind::Worktree { id, .. } = &kind {
+                        None.or_else(|| {
+                            let (_, _, item_context) =
+                                task_contexts.active_item_context.as_ref().filter(
+                                    |(worktree_id, _, _)| Some(id) == worktree_id.as_ref(),
+                                )?;
+                            task.resolve_task(&id_base, item_context)
+                        })
+                        .or_else(|| {
+                            let (_, worktree_context) = task_contexts
+                                .active_worktree_context
+                                .as_ref()
+                                .filter(|(worktree_id, _)| id == worktree_id)?;
                             task.resolve_task(&id_base, worktree_context)
-                        } else {
-                            None
-                        }
-                    })
-                } else {
-                    None.or_else(|| {
-                        let (_, _, item_context) = task_contexts.active_item_context.as_ref()?;
-                        task.resolve_task(&id_base, item_context)
-                    })
-                    .or_else(|| {
-                        let (_, worktree_context) =
-                            task_contexts.active_worktree_context.as_ref()?;
-                        task.resolve_task(&id_base, worktree_context)
-                    })
-                }
-                .or_else(|| task.resolve_task(&id_base, &TaskContext::default()))
-                .map(move |resolved_task| (kind.clone(), resolved_task, not_used_score))
-            })
-            .filter(|(_, resolved_task, _)| {
-                match task_labels_to_ids.entry(resolved_task.resolved_label.clone()) {
-                    hash_map::Entry::Occupied(mut o) => {
-                        // Allow new tasks with the same label, if their context is different
-                        o.get_mut().insert(resolved_task.id.clone())
+                        })
+                        .or_else(|| {
+                            if let TaskSourceKind::Worktree { id, .. } = &kind {
+                                let worktree_context = task_contexts
+                                    .other_worktree_contexts
+                                    .iter()
+                                    .find(|(worktree_id, _)| worktree_id == id)
+                                    .map(|(_, context)| context)?;
+                                task.resolve_task(&id_base, worktree_context)
+                            } else {
+                                None
+                            }
+                        })
+                    } else {
+                        None.or_else(|| {
+                            let (_, _, item_context) =
+                                task_contexts.active_item_context.as_ref()?;
+                            task.resolve_task(&id_base, item_context)
+                        })
+                        .or_else(|| {
+                            let (_, worktree_context) =
+                                task_contexts.active_worktree_context.as_ref()?;
+                            task.resolve_task(&id_base, worktree_context)
+                        })
                     }
-                    hash_map::Entry::Vacant(v) => {
-                        v.insert(HashSet::from_iter(Some(resolved_task.id.clone())));
-                        true
+                    .or_else(|| task.resolve_task(&id_base, &TaskContext::default()))
+                    .map(move |resolved_task| (kind.clone(), resolved_task, not_used_score))
+                })
+                .filter(|(_, resolved_task, _)| {
+                    match task_labels_to_ids.entry(resolved_task.resolved_label.clone()) {
+                        hash_map::Entry::Occupied(mut o) => {
+                            // Allow new tasks with the same label, if their context is different
+                            o.get_mut().insert(resolved_task.id.clone())
+                        }
+                        hash_map::Entry::Vacant(v) => {
+                            v.insert(HashSet::from_iter(Some(resolved_task.id.clone())));
+                            true
+                        }
                     }
-                }
-            })
-            .sorted_unstable_by(task_lru_comparator)
-            .map(|(kind, task, _)| (kind, task))
-            .collect::<Vec<_>>();
+                })
+                .sorted_unstable_by(task_lru_comparator)
+                .map(|(kind, task, _)| (kind, task))
+                .collect::<Vec<_>>();
 
-        (previously_spawned_tasks, new_resolved_tasks)
+            (previously_spawned_tasks, new_resolved_tasks)
+        })
     }
 
     /// Returns the last scheduled task by task_id if provided.
@@ -471,14 +604,14 @@ impl Inventory {
 
     fn worktree_scenarios_from_settings(
         &self,
-        worktree: Option<WorktreeId>,
+        worktree: WorktreeId,
     ) -> impl '_ + Iterator<Item = (TaskSourceKind, DebugScenario)> {
         self.scenarios_from_settings.worktree_scenarios(worktree)
     }
 
     fn worktree_templates_from_settings(
         &self,
-        worktree: Option<WorktreeId>,
+        worktree: WorktreeId,
     ) -> impl '_ + Iterator<Item = (TaskSourceKind, TaskTemplate)> {
         self.templates_from_settings.worktree_scenarios(worktree)
     }
@@ -519,6 +652,13 @@ impl Inventory {
                     .global
                     .entry(path.to_owned())
                     .insert_entry(new_templates.collect());
+                self.last_scheduled_tasks.retain(|(kind, _)| {
+                    if let TaskSourceKind::AbsPath { abs_path, .. } = kind {
+                        abs_path != path
+                    } else {
+                        true
+                    }
+                });
             }
             TaskSettingsLocation::Worktree(location) => {
                 let new_templates = new_templates.collect::<Vec<_>>();
@@ -535,6 +675,18 @@ impl Inventory {
                         .or_default()
                         .insert(Arc::from(location.path), new_templates);
                 }
+                self.last_scheduled_tasks.retain(|(kind, _)| {
+                    if let TaskSourceKind::Worktree {
+                        directory_in_worktree,
+                        id,
+                        ..
+                    } = kind
+                    {
+                        *id != location.worktree_id || directory_in_worktree != location.path
+                    } else {
+                        true
+                    }
+                });
             }
         }
 
@@ -567,20 +719,37 @@ impl Inventory {
             }
         };
 
-        let new_templates = raw_tasks.into_iter().filter_map(|raw_template| {
-            serde_json::from_value::<DebugScenario>(raw_template).log_err()
-        });
+        let new_templates = raw_tasks
+            .into_iter()
+            .filter_map(|raw_template| {
+                serde_json::from_value::<DebugScenario>(raw_template).log_err()
+            })
+            .collect::<Vec<_>>();
 
         let parsed_scenarios = &mut self.scenarios_from_settings;
+        let mut new_definitions: HashMap<_, _> = new_templates
+            .iter()
+            .map(|template| (template.label.clone(), template.clone()))
+            .collect();
+        let previously_existing_scenarios;
+
         match location {
             TaskSettingsLocation::Global(path) => {
+                previously_existing_scenarios = parsed_scenarios
+                    .global_scenarios()
+                    .map(|(_, scenario)| scenario.label.clone())
+                    .collect::<HashSet<_>>();
                 parsed_scenarios
                     .global
                     .entry(path.to_owned())
-                    .insert_entry(new_templates.collect());
+                    .insert_entry(new_templates);
             }
             TaskSettingsLocation::Worktree(location) => {
-                let new_templates = new_templates.collect::<Vec<_>>();
+                previously_existing_scenarios = parsed_scenarios
+                    .worktree_scenarios(location.worktree_id)
+                    .map(|(_, scenario)| scenario.label.clone())
+                    .collect::<HashSet<_>>();
+
                 if new_templates.is_empty() {
                     if let Some(worktree_tasks) =
                         parsed_scenarios.worktree.get_mut(&location.worktree_id)
@@ -596,6 +765,17 @@ impl Inventory {
                 }
             }
         }
+        self.last_scheduled_scenarios.retain_mut(|scenario| {
+            if !previously_existing_scenarios.contains(&scenario.label) {
+                return true;
+            }
+            if let Some(new_definition) = new_definitions.remove(&scenario.label) {
+                *scenario = new_definition;
+                true
+            } else {
+                false
+            }
+        });
 
         Ok(())
     }
@@ -626,7 +806,7 @@ fn task_lru_comparator(
 
 fn task_source_kind_preference(kind: &TaskSourceKind) -> u32 {
     match kind {
-        TaskSourceKind::Lsp(..) => 0,
+        TaskSourceKind::Lsp { .. } => 0,
         TaskSourceKind::Language { .. } => 1,
         TaskSourceKind::UserInput => 2,
         TaskSourceKind::Worktree { .. } => 3,
@@ -645,7 +825,7 @@ fn task_variables_preference(task: &ResolvedTask) -> Reverse<usize> {
 
 #[cfg(test)]
 mod test_inventory {
-    use gpui::{Entity, TestAppContext};
+    use gpui::{AppContext as _, Entity, Task, TestAppContext};
     use itertools::Itertools;
     use task::TaskContext;
     use worktree::WorktreeId;
@@ -658,10 +838,13 @@ mod test_inventory {
         inventory: &Entity<Inventory>,
         worktree: Option<WorktreeId>,
         cx: &mut TestAppContext,
-    ) -> Vec<String> {
-        inventory.update(cx, |inventory, cx| {
-            inventory
-                .list_tasks(None, None, worktree, cx)
+    ) -> Task<Vec<String>> {
+        let new_tasks = inventory.update(cx, |inventory, cx| {
+            inventory.list_tasks(None, None, worktree, cx)
+        });
+        cx.background_spawn(async move {
+            new_tasks
+                .await
                 .into_iter()
                 .map(|(_, task)| task.label)
                 .sorted()
@@ -673,20 +856,66 @@ mod test_inventory {
         inventory: &Entity<Inventory>,
         task_name: &str,
         cx: &mut TestAppContext,
-    ) {
-        inventory.update(cx, |inventory, cx| {
-            let (task_source_kind, task) = inventory
-                .list_tasks(None, None, None, cx)
+    ) -> Task<()> {
+        let tasks = inventory.update(cx, |inventory, cx| {
+            inventory.list_tasks(None, None, None, cx)
+        });
+
+        let task_name = task_name.to_owned();
+        let inventory = inventory.clone();
+        cx.spawn(|mut cx| async move {
+            let (task_source_kind, task) = tasks
+                .await
                 .into_iter()
                 .find(|(_, task)| task.label == task_name)
                 .unwrap_or_else(|| panic!("Failed to find task with name {task_name}"));
+
             let id_base = task_source_kind.to_id_base();
-            inventory.task_scheduled(
-                task_source_kind.clone(),
-                task.resolve_task(&id_base, &TaskContext::default())
-                    .unwrap_or_else(|| panic!("Failed to resolve task with name {task_name}")),
-            );
+            inventory
+                .update(&mut cx, |inventory, _| {
+                    inventory.task_scheduled(
+                        task_source_kind.clone(),
+                        task.resolve_task(&id_base, &TaskContext::default())
+                            .unwrap_or_else(|| {
+                                panic!("Failed to resolve task with name {task_name}")
+                            }),
+                    )
+                })
+                .unwrap();
+        })
+    }
+
+    pub(super) fn register_worktree_task_used(
+        inventory: &Entity<Inventory>,
+        worktree_id: WorktreeId,
+        task_name: &str,
+        cx: &mut TestAppContext,
+    ) -> Task<()> {
+        let tasks = inventory.update(cx, |inventory, cx| {
+            inventory.list_tasks(None, None, Some(worktree_id), cx)
         });
+
+        let inventory = inventory.clone();
+        let task_name = task_name.to_owned();
+        cx.spawn(|mut cx| async move {
+            let (task_source_kind, task) = tasks
+                .await
+                .into_iter()
+                .find(|(_, task)| task.label == task_name)
+                .unwrap_or_else(|| panic!("Failed to find task with name {task_name}"));
+            let id_base = task_source_kind.to_id_base();
+            inventory
+                .update(&mut cx, |inventory, _| {
+                    inventory.task_scheduled(
+                        task_source_kind.clone(),
+                        task.resolve_task(&id_base, &TaskContext::default())
+                            .unwrap_or_else(|| {
+                                panic!("Failed to resolve task with name {task_name}")
+                            }),
+                    );
+                })
+                .unwrap();
+        })
     }
 
     pub(super) async fn list_tasks(
@@ -694,18 +923,19 @@ mod test_inventory {
         worktree: Option<WorktreeId>,
         cx: &mut TestAppContext,
     ) -> Vec<(TaskSourceKind, String)> {
-        inventory.update(cx, |inventory, cx| {
-            let task_context = &TaskContext::default();
-            inventory
-                .list_tasks(None, None, worktree, cx)
-                .into_iter()
-                .filter_map(|(source_kind, task)| {
-                    let id_base = source_kind.to_id_base();
-                    Some((source_kind, task.resolve_task(&id_base, task_context)?))
-                })
-                .map(|(source_kind, resolved_task)| (source_kind, resolved_task.resolved_label))
-                .collect()
-        })
+        let task_context = &TaskContext::default();
+        inventory
+            .update(cx, |inventory, cx| {
+                inventory.list_tasks(None, None, worktree, cx)
+            })
+            .await
+            .into_iter()
+            .filter_map(|(source_kind, task)| {
+                let id_base = source_kind.to_id_base();
+                Some((source_kind, task.resolve_task(&id_base, task_context)?))
+            })
+            .map(|(source_kind, resolved_task)| (source_kind, resolved_task.resolved_label))
+            .collect()
     }
 }
 
@@ -724,11 +954,12 @@ impl ContextProvider for BasicContextProvider {
     fn build_context(
         &self,
         _: &TaskVariables,
-        location: &Location,
+        location: ContextLocation<'_>,
         _: Option<HashMap<String, String>>,
         _: Arc<dyn LanguageToolchainStore>,
         cx: &mut App,
     ) -> Task<Result<TaskVariables>> {
+        let location = location.file_location;
         let buffer = location.buffer.read(cx);
         let buffer_snapshot = buffer.snapshot();
         let symbols = buffer_snapshot.symbols_containing(location.range.start, None);
@@ -780,11 +1011,21 @@ impl ContextProvider for BasicContextProvider {
             );
             if let Some(full_path) = current_file.as_ref() {
                 let relative_path = pathdiff::diff_paths(full_path, worktree_path);
-                if let Some(relative_path) = relative_path {
+                if let Some(relative_file) = relative_path {
                     task_variables.insert(
                         VariableName::RelativeFile,
-                        relative_path.to_sanitized_string(),
+                        relative_file.to_sanitized_string(),
                     );
+                    if let Some(relative_dir) = relative_file.parent() {
+                        task_variables.insert(
+                            VariableName::RelativeDir,
+                            if relative_dir.as_os_str().is_empty() {
+                                String::from(".")
+                            } else {
+                                relative_dir.to_sanitized_string()
+                            },
+                        );
+                    }
                 }
             }
         }
@@ -826,15 +1067,17 @@ impl ContextProviderWithTasks {
 impl ContextProvider for ContextProviderWithTasks {
     fn associated_tasks(
         &self,
-        _: Option<Arc<dyn language::File>>,
+        _: Arc<dyn Fs>,
+        _: Option<Arc<dyn File>>,
         _: &App,
-    ) -> Option<TaskTemplates> {
-        Some(self.templates.clone())
+    ) -> Task<Option<TaskTemplates>> {
+        Task::ready(Some(self.templates.clone()))
     }
 }
 
 #[cfg(test)]
 mod tests {
+    use fs::FakeFs;
     use gpui::TestAppContext;
     use paths::tasks_file;
     use pretty_assertions::assert_eq;
@@ -849,13 +1092,14 @@ mod tests {
     #[gpui::test]
     async fn test_task_list_sorting(cx: &mut TestAppContext) {
         init_test(cx);
-        let inventory = cx.update(Inventory::new);
-        let initial_tasks = resolved_task_names(&inventory, None, cx);
+        let fs = FakeFs::new(cx.executor());
+        let inventory = cx.update(|cx| Inventory::new(fs, cx));
+        let initial_tasks = resolved_task_names(&inventory, None, cx).await;
         assert!(
             initial_tasks.is_empty(),
             "No tasks expected for empty inventory, but got {initial_tasks:?}"
         );
-        let initial_tasks = task_template_names(&inventory, None, cx);
+        let initial_tasks = task_template_names(&inventory, None, cx).await;
         assert!(
             initial_tasks.is_empty(),
             "No tasks expected for empty inventory, but got {initial_tasks:?}"
@@ -879,22 +1123,22 @@ mod tests {
                 .unwrap();
         });
         assert_eq!(
-            task_template_names(&inventory, None, cx),
+            task_template_names(&inventory, None, cx).await,
             &expected_initial_state,
         );
         assert_eq!(
-            resolved_task_names(&inventory, None, cx),
+            resolved_task_names(&inventory, None, cx).await,
             &expected_initial_state,
             "Tasks with equal amount of usages should be sorted alphanumerically"
         );
 
-        register_task_used(&inventory, "2_task", cx);
+        register_task_used(&inventory, "2_task", cx).await;
         assert_eq!(
-            task_template_names(&inventory, None, cx),
+            task_template_names(&inventory, None, cx).await,
             &expected_initial_state,
         );
         assert_eq!(
-            resolved_task_names(&inventory, None, cx),
+            resolved_task_names(&inventory, None, cx).await,
             vec![
                 "2_task".to_string(),
                 "1_a_task".to_string(),
@@ -903,22 +1147,69 @@ mod tests {
             ],
         );
 
-        register_task_used(&inventory, "1_task", cx);
-        register_task_used(&inventory, "1_task", cx);
-        register_task_used(&inventory, "1_task", cx);
-        register_task_used(&inventory, "3_task", cx);
+        register_task_used(&inventory, "1_task", cx).await;
+        register_task_used(&inventory, "1_task", cx).await;
+        register_task_used(&inventory, "1_task", cx).await;
+        register_task_used(&inventory, "3_task", cx).await;
         assert_eq!(
-            task_template_names(&inventory, None, cx),
+            task_template_names(&inventory, None, cx).await,
             &expected_initial_state,
         );
         assert_eq!(
-            resolved_task_names(&inventory, None, cx),
+            resolved_task_names(&inventory, None, cx).await,
             vec![
                 "3_task".to_string(),
                 "1_task".to_string(),
                 "2_task".to_string(),
                 "1_a_task".to_string(),
             ],
+            "Most recently used task should be at the top"
+        );
+
+        let worktree_id = WorktreeId::from_usize(0);
+        let local_worktree_location = SettingsLocation {
+            worktree_id,
+            path: Path::new("foo"),
+        };
+        inventory.update(cx, |inventory, _| {
+            inventory
+                .update_file_based_tasks(
+                    TaskSettingsLocation::Worktree(local_worktree_location),
+                    Some(&mock_tasks_from_names(["worktree_task_1"])),
+                )
+                .unwrap();
+        });
+        assert_eq!(
+            resolved_task_names(&inventory, None, cx).await,
+            vec![
+                "3_task".to_string(),
+                "1_task".to_string(),
+                "2_task".to_string(),
+                "1_a_task".to_string(),
+            ],
+            "Most recently used task should be at the top"
+        );
+        assert_eq!(
+            resolved_task_names(&inventory, Some(worktree_id), cx).await,
+            vec![
+                "3_task".to_string(),
+                "1_task".to_string(),
+                "2_task".to_string(),
+                "worktree_task_1".to_string(),
+                "1_a_task".to_string(),
+            ],
+        );
+        register_worktree_task_used(&inventory, worktree_id, "worktree_task_1", cx).await;
+        assert_eq!(
+            resolved_task_names(&inventory, Some(worktree_id), cx).await,
+            vec![
+                "worktree_task_1".to_string(),
+                "3_task".to_string(),
+                "1_task".to_string(),
+                "2_task".to_string(),
+                "1_a_task".to_string(),
+            ],
+            "Most recently used worktree task should be at the top"
         );
 
         inventory.update(cx, |inventory, _| {
@@ -943,43 +1234,156 @@ mod tests {
             "3_task".to_string(),
         ];
         assert_eq!(
-            task_template_names(&inventory, None, cx),
+            task_template_names(&inventory, None, cx).await,
             &expected_updated_state,
         );
         assert_eq!(
-            resolved_task_names(&inventory, None, cx),
+            resolved_task_names(&inventory, None, cx).await,
             vec![
-                "3_task".to_string(),
+                "worktree_task_1".to_string(),
+                "1_a_task".to_string(),
                 "1_task".to_string(),
                 "2_task".to_string(),
-                "1_a_task".to_string(),
+                "3_task".to_string(),
                 "10_hello".to_string(),
                 "11_hello".to_string(),
             ],
+            "After global tasks update, worktree task usage is not erased and it's the first still; global task is back to regular order as its file was updated"
         );
 
-        register_task_used(&inventory, "11_hello", cx);
+        register_task_used(&inventory, "11_hello", cx).await;
         assert_eq!(
-            task_template_names(&inventory, None, cx),
+            task_template_names(&inventory, None, cx).await,
             &expected_updated_state,
         );
         assert_eq!(
-            resolved_task_names(&inventory, None, cx),
+            resolved_task_names(&inventory, None, cx).await,
             vec![
                 "11_hello".to_string(),
-                "3_task".to_string(),
+                "worktree_task_1".to_string(),
+                "1_a_task".to_string(),
                 "1_task".to_string(),
                 "2_task".to_string(),
-                "1_a_task".to_string(),
+                "3_task".to_string(),
                 "10_hello".to_string(),
             ],
         );
     }
 
+    #[gpui::test]
+    async fn test_reloading_debug_scenarios(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        let inventory = cx.update(|cx| Inventory::new(fs, cx));
+        inventory.update(cx, |inventory, _| {
+            inventory
+                .update_file_based_scenarios(
+                    TaskSettingsLocation::Global(Path::new("")),
+                    Some(
+                        r#"
+                        [{
+                            "label": "test scenario",
+                            "adapter": "CodeLLDB",
+                            "request": "launch",
+                            "program": "wowzer",
+                        }]
+                        "#,
+                    ),
+                )
+                .unwrap();
+        });
+
+        let (_, scenario) = inventory
+            .update(cx, |this, cx| {
+                this.list_debug_scenarios(&TaskContexts::default(), vec![], vec![], false, cx)
+            })
+            .await
+            .1
+            .first()
+            .unwrap()
+            .clone();
+
+        inventory.update(cx, |this, _| {
+            this.scenario_scheduled(scenario.clone());
+        });
+
+        assert_eq!(
+            inventory
+                .update(cx, |this, cx| {
+                    this.list_debug_scenarios(&TaskContexts::default(), vec![], vec![], false, cx)
+                })
+                .await
+                .0
+                .first()
+                .unwrap()
+                .clone(),
+            scenario
+        );
+
+        inventory.update(cx, |this, _| {
+            this.update_file_based_scenarios(
+                TaskSettingsLocation::Global(Path::new("")),
+                Some(
+                    r#"
+                        [{
+                            "label": "test scenario",
+                            "adapter": "Delve",
+                            "request": "launch",
+                            "program": "wowzer",
+                        }]
+                        "#,
+                ),
+            )
+            .unwrap();
+        });
+
+        assert_eq!(
+            inventory
+                .update(cx, |this, cx| {
+                    this.list_debug_scenarios(&TaskContexts::default(), vec![], vec![], false, cx)
+                })
+                .await
+                .0
+                .first()
+                .unwrap()
+                .adapter,
+            "Delve",
+        );
+
+        inventory.update(cx, |this, _| {
+            this.update_file_based_scenarios(
+                TaskSettingsLocation::Global(Path::new("")),
+                Some(
+                    r#"
+                        [{
+                            "label": "testing scenario",
+                            "adapter": "Delve",
+                            "request": "launch",
+                            "program": "wowzer",
+                        }]
+                        "#,
+                ),
+            )
+            .unwrap();
+        });
+
+        assert_eq!(
+            inventory
+                .update(cx, |this, cx| {
+                    this.list_debug_scenarios(&TaskContexts::default(), vec![], vec![], false, cx)
+                })
+                .await
+                .0
+                .first(),
+            None
+        );
+    }
+
     #[gpui::test]
     async fn test_inventory_static_task_filters(cx: &mut TestAppContext) {
         init_test(cx);
-        let inventory = cx.update(Inventory::new);
+        let fs = FakeFs::new(cx.executor());
+        let inventory = cx.update(|cx| Inventory::new(fs, cx));
         let common_name = "common_task_name";
         let worktree_1 = WorktreeId::from_usize(1);
         let worktree_2 = WorktreeId::from_usize(2);

crates/project/src/task_store.rs 🔗

@@ -5,9 +5,10 @@ use std::{
 
 use anyhow::Context as _;
 use collections::HashMap;
+use fs::Fs;
 use gpui::{App, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity};
 use language::{
-    ContextProvider as _, LanguageToolchainStore, Location,
+    ContextLocation, ContextProvider as _, LanguageToolchainStore, Location,
     proto::{deserialize_anchor, serialize_anchor},
 };
 use rpc::{AnyProtoClient, TypedEnvelope, proto};
@@ -21,7 +22,7 @@ use crate::{
     worktree_store::WorktreeStore,
 };
 
-#[allow(clippy::large_enum_variant)] // platform-dependent warning
+// platform-dependent warning
 pub enum TaskStore {
     Functional(StoreState),
     Noop,
@@ -70,7 +71,7 @@ impl TaskStore {
             .payload
             .location
             .context("no location given for task context handling")?;
-        let (buffer_store, is_remote) = store.update(&mut cx, |store, _| {
+        let (buffer_store, is_remote) = store.read_with(&mut cx, |store, _| {
             Ok(match store {
                 TaskStore::Functional(state) => (
                     state.buffer_store.clone(),
@@ -158,6 +159,7 @@ impl TaskStore {
     }
 
     pub fn local(
+        fs: Arc<dyn Fs>,
         buffer_store: WeakEntity<BufferStore>,
         worktree_store: Entity<WorktreeStore>,
         toolchain_store: Arc<dyn LanguageToolchainStore>,
@@ -169,7 +171,7 @@ impl TaskStore {
                 downstream_client: None,
                 environment,
             },
-            task_inventory: Inventory::new(cx),
+            task_inventory: Inventory::new(fs, cx),
             buffer_store,
             toolchain_store,
             worktree_store,
@@ -177,6 +179,7 @@ impl TaskStore {
     }
 
     pub fn remote(
+        fs: Arc<dyn Fs>,
         buffer_store: WeakEntity<BufferStore>,
         worktree_store: Entity<WorktreeStore>,
         toolchain_store: Arc<dyn LanguageToolchainStore>,
@@ -189,7 +192,7 @@ impl TaskStore {
                 upstream_client,
                 project_id,
             },
-            task_inventory: Inventory::new(cx),
+            task_inventory: Inventory::new(fs, cx),
             buffer_store,
             toolchain_store,
             worktree_store,
@@ -311,6 +314,7 @@ fn local_task_context_for_location(
     let worktree_abs_path = worktree_id
         .and_then(|worktree_id| worktree_store.read(cx).worktree_for_id(worktree_id, cx))
         .and_then(|worktree| worktree.read(cx).root_dir());
+    let fs = worktree_store.read(cx).fs();
 
     cx.spawn(async move |cx| {
         let project_env = environment
@@ -324,6 +328,8 @@ fn local_task_context_for_location(
             .update(|cx| {
                 combine_task_variables(
                     captured_variables,
+                    fs,
+                    worktree_store.clone(),
                     location,
                     project_env.clone(),
                     BasicContextProvider::new(worktree_store),
@@ -358,9 +364,15 @@ fn remote_task_context_for_location(
         // We need to gather a client context, as the headless one may lack certain information (e.g. tree-sitter parsing is disabled there, so symbols are not available).
         let mut remote_context = cx
             .update(|cx| {
+                let worktree_root = worktree_root(&worktree_store, &location, cx);
+
                 BasicContextProvider::new(worktree_store).build_context(
                     &TaskVariables::default(),
-                    &location,
+                    ContextLocation {
+                        fs: None,
+                        worktree_root,
+                        file_location: &location,
+                    },
                     None,
                     toolchain_store,
                     cx,
@@ -408,8 +420,34 @@ fn remote_task_context_for_location(
     })
 }
 
+fn worktree_root(
+    worktree_store: &Entity<WorktreeStore>,
+    location: &Location,
+    cx: &mut App,
+) -> Option<PathBuf> {
+    location
+        .buffer
+        .read(cx)
+        .file()
+        .map(|f| f.worktree_id(cx))
+        .and_then(|worktree_id| worktree_store.read(cx).worktree_for_id(worktree_id, cx))
+        .and_then(|worktree| {
+            let worktree = worktree.read(cx);
+            if !worktree.is_visible() {
+                return None;
+            }
+            let root_entry = worktree.root_entry()?;
+            if !root_entry.is_dir() {
+                return None;
+            }
+            worktree.absolutize(&root_entry.path).ok()
+        })
+}
+
 fn combine_task_variables(
     mut captured_variables: TaskVariables,
+    fs: Option<Arc<dyn Fs>>,
+    worktree_store: Entity<WorktreeStore>,
     location: Location,
     project_env: Option<HashMap<String, String>>,
     baseline: BasicContextProvider,
@@ -424,9 +462,14 @@ fn combine_task_variables(
     cx.spawn(async move |cx| {
         let baseline = cx
             .update(|cx| {
+                let worktree_root = worktree_root(&worktree_store, &location, cx);
                 baseline.build_context(
                     &captured_variables,
-                    &location,
+                    ContextLocation {
+                        fs: fs.clone(),
+                        worktree_root,
+                        file_location: &location,
+                    },
                     project_env.clone(),
                     toolchain_store.clone(),
                     cx,
@@ -438,9 +481,14 @@ fn combine_task_variables(
         if let Some(provider) = language_context_provider {
             captured_variables.extend(
                 cx.update(|cx| {
+                    let worktree_root = worktree_root(&worktree_store, &location, cx);
                     provider.build_context(
                         &captured_variables,
-                        &location,
+                        ContextLocation {
+                            fs,
+                            worktree_root,
+                            file_location: &location,
+                        },
                         project_env,
                         toolchain_store,
                         cx,

crates/project/src/terminals.rs 🔗

@@ -24,7 +24,7 @@ pub struct Terminals {
 }
 
 /// Terminals are opened either for the users shell, or to run a task.
-#[allow(clippy::large_enum_variant)]
+
 #[derive(Debug)]
 pub enum TerminalKind {
     /// Run a shell at the given path (or $HOME if None)
@@ -108,14 +108,14 @@ impl Project {
                 });
             }
         }
-        let settings = TerminalSettings::get(settings_location, cx).clone();
+        let venv = TerminalSettings::get(settings_location, cx)
+            .detect_venv
+            .clone();
 
         cx.spawn(async move |project, cx| {
-            let python_venv_directory = if let Some(path) = path.clone() {
+            let python_venv_directory = if let Some(path) = path {
                 project
-                    .update(cx, |this, cx| {
-                        this.python_venv_directory(path, settings.detect_venv.clone(), cx)
-                    })?
+                    .update(cx, |this, cx| this.python_venv_directory(path, venv, cx))?
                     .await
             } else {
                 None
@@ -148,7 +148,7 @@ impl Project {
         let ssh_details = self.ssh_details(cx);
         let settings = self.terminal_settings(&path, cx).clone();
 
-        let builder = ShellBuilder::new(ssh_details.is_none(), &settings.shell);
+        let builder = ShellBuilder::new(ssh_details.is_none(), &settings.shell).non_interactive();
         let (command, args) = builder.build(command, &Vec::new());
 
         let mut env = self
@@ -264,7 +264,7 @@ impl Project {
                             },
                         )
                     }
-                    None => (None, settings.shell.clone()),
+                    None => (None, settings.shell),
                 }
             }
             TerminalKind::Task(spawn_task) => {
@@ -514,7 +514,7 @@ impl Project {
         terminal_handle: &Entity<Terminal>,
         cx: &mut App,
     ) {
-        terminal_handle.update(cx, |terminal, _| terminal.input(command));
+        terminal_handle.update(cx, |terminal, _| terminal.input(command.into_bytes()));
     }
 
     pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {

crates/project/src/toolchain_store.rs 🔗

@@ -19,7 +19,11 @@ use rpc::{
 use settings::WorktreeId;
 use util::ResultExt as _;
 
-use crate::{ProjectEnvironment, ProjectPath, worktree_store::WorktreeStore};
+use crate::{
+    ProjectEnvironment, ProjectPath,
+    manifest_tree::{ManifestQueryDelegate, ManifestTree},
+    worktree_store::WorktreeStore,
+};
 
 pub struct ToolchainStore(ToolchainStoreInner);
 enum ToolchainStoreInner {
@@ -42,6 +46,7 @@ impl ToolchainStore {
         languages: Arc<LanguageRegistry>,
         worktree_store: Entity<WorktreeStore>,
         project_environment: Entity<ProjectEnvironment>,
+        manifest_tree: Entity<ManifestTree>,
         cx: &mut Context<Self>,
     ) -> Self {
         let entity = cx.new(|_| LocalToolchainStore {
@@ -49,6 +54,7 @@ impl ToolchainStore {
             worktree_store,
             project_environment,
             active_toolchains: Default::default(),
+            manifest_tree,
         });
         let subscription = cx.subscribe(&entity, |_, _, e: &ToolchainStoreEvent, cx| {
             cx.emit(e.clone())
@@ -80,11 +86,11 @@ impl ToolchainStore {
         &self,
         path: ProjectPath,
         language_name: LanguageName,
-        cx: &App,
-    ) -> Task<Option<ToolchainList>> {
+        cx: &mut Context<Self>,
+    ) -> Task<Option<(ToolchainList, Arc<Path>)>> {
         match &self.0 {
             ToolchainStoreInner::Local(local, _) => {
-                local.read(cx).list_toolchains(path, language_name, cx)
+                local.update(cx, |this, cx| this.list_toolchains(path, language_name, cx))
             }
             ToolchainStoreInner::Remote(remote) => {
                 remote.read(cx).list_toolchains(path, language_name, cx)
@@ -181,7 +187,7 @@ impl ToolchainStore {
             })?
             .await;
         let has_values = toolchains.is_some();
-        let groups = if let Some(toolchains) = &toolchains {
+        let groups = if let Some((toolchains, _)) = &toolchains {
             toolchains
                 .groups
                 .iter()
@@ -195,8 +201,8 @@ impl ToolchainStore {
         } else {
             vec![]
         };
-        let toolchains = if let Some(toolchains) = toolchains {
-            toolchains
+        let (toolchains, relative_path) = if let Some((toolchains, relative_path)) = toolchains {
+            let toolchains = toolchains
                 .toolchains
                 .into_iter()
                 .map(|toolchain| {
@@ -207,15 +213,17 @@ impl ToolchainStore {
                         raw_json: toolchain.as_json.to_string(),
                     }
                 })
-                .collect::<Vec<_>>()
+                .collect::<Vec<_>>();
+            (toolchains, relative_path)
         } else {
-            vec![]
+            (vec![], Arc::from(Path::new("")))
         };
 
         Ok(proto::ListToolchainsResponse {
             has_values,
             toolchains,
             groups,
+            relative_worktree_path: Some(relative_path.to_string_lossy().into_owned()),
         })
     }
     pub fn as_language_toolchain_store(&self) -> Arc<dyn LanguageToolchainStore> {
@@ -231,6 +239,7 @@ struct LocalToolchainStore {
     worktree_store: Entity<WorktreeStore>,
     project_environment: Entity<ProjectEnvironment>,
     active_toolchains: BTreeMap<(WorktreeId, LanguageName), BTreeMap<Arc<Path>, Toolchain>>,
+    manifest_tree: Entity<ManifestTree>,
 }
 
 #[async_trait(?Send)]
@@ -269,7 +278,7 @@ impl language::LanguageToolchainStore for RemoteStore {
     }
 }
 
-pub(crate) struct EmptyToolchainStore;
+pub struct EmptyToolchainStore;
 #[async_trait(?Send)]
 impl language::LanguageToolchainStore for EmptyToolchainStore {
     async fn active_toolchain(
@@ -312,36 +321,73 @@ impl LocalToolchainStore {
         })
     }
     pub(crate) fn list_toolchains(
-        &self,
+        &mut self,
         path: ProjectPath,
         language_name: LanguageName,
-        cx: &App,
-    ) -> Task<Option<ToolchainList>> {
+        cx: &mut Context<Self>,
+    ) -> Task<Option<(ToolchainList, Arc<Path>)>> {
         let registry = self.languages.clone();
-        let Some(abs_path) = self
-            .worktree_store
-            .read(cx)
-            .worktree_for_id(path.worktree_id, cx)
-            .map(|worktree| worktree.read(cx).abs_path())
-        else {
-            return Task::ready(None);
-        };
+
+        let manifest_tree = self.manifest_tree.downgrade();
+
         let environment = self.project_environment.clone();
-        cx.spawn(async move |cx| {
+        cx.spawn(async move |this, cx| {
+            let language = cx
+                .background_spawn(registry.language_for_name(language_name.as_ref()))
+                .await
+                .ok()?;
+            let toolchains = language.toolchain_lister()?;
+            let manifest_name = toolchains.manifest_name();
+            let (snapshot, worktree) = this
+                .update(cx, |this, cx| {
+                    this.worktree_store
+                        .read(cx)
+                        .worktree_for_id(path.worktree_id, cx)
+                        .map(|worktree| (worktree.read(cx).snapshot(), worktree))
+                })
+                .ok()
+                .flatten()?;
+            let worktree_id = snapshot.id();
+            let worktree_root = snapshot.abs_path().to_path_buf();
+            let relative_path = manifest_tree
+                .update(cx, |this, cx| {
+                    this.root_for_path(
+                        path,
+                        &mut std::iter::once(manifest_name.clone()),
+                        Arc::new(ManifestQueryDelegate::new(snapshot)),
+                        cx,
+                    )
+                })
+                .ok()?
+                .remove(&manifest_name)
+                .unwrap_or_else(|| ProjectPath {
+                    path: Arc::from(Path::new("")),
+                    worktree_id,
+                });
+            let abs_path = worktree
+                .update(cx, |this, _| this.absolutize(&relative_path.path).ok())
+                .ok()
+                .flatten()?;
+
             let project_env = environment
                 .update(cx, |environment, cx| {
-                    environment.get_directory_environment(abs_path.clone(), cx)
+                    environment.get_directory_environment(abs_path.as_path().into(), cx)
                 })
                 .ok()?
                 .await;
 
             cx.background_spawn(async move {
-                let language = registry
-                    .language_for_name(language_name.as_ref())
-                    .await
-                    .ok()?;
-                let toolchains = language.toolchain_lister()?;
-                Some(toolchains.list(abs_path.to_path_buf(), project_env).await)
+                Some((
+                    toolchains
+                        .list(
+                            worktree_root,
+                            Some(relative_path.path.clone())
+                                .filter(|_| *relative_path.path != *Path::new("")),
+                            project_env,
+                        )
+                        .await,
+                    relative_path.path,
+                ))
             })
             .await
         })
@@ -404,7 +450,7 @@ impl RemoteToolchainStore {
         path: ProjectPath,
         language_name: LanguageName,
         cx: &App,
-    ) -> Task<Option<ToolchainList>> {
+    ) -> Task<Option<(ToolchainList, Arc<Path>)>> {
         let project_id = self.project_id;
         let client = self.client.clone();
         cx.background_spawn(async move {
@@ -444,11 +490,20 @@ impl RemoteToolchainStore {
                     Some((usize::try_from(group.start_index).ok()?, group.name.into()))
                 })
                 .collect();
-            Some(ToolchainList {
-                toolchains,
-                default: None,
-                groups,
-            })
+            let relative_path = Arc::from(Path::new(
+                response
+                    .relative_worktree_path
+                    .as_deref()
+                    .unwrap_or_default(),
+            ));
+            Some((
+                ToolchainList {
+                    toolchains,
+                    default: None,
+                    groups,
+                },
+                relative_path,
+            ))
         })
     }
     pub(crate) fn active_toolchain(

crates/project/src/worktree_store.rs 🔗

@@ -367,7 +367,7 @@ impl WorktreeStore {
 
         let handle_id = worktree.entity_id();
         cx.subscribe(worktree, |_, worktree, event, cx| {
-            let worktree_id = worktree.update(cx, |worktree, _| worktree.id());
+            let worktree_id = worktree.read(cx).id();
             match event {
                 worktree::Event::UpdatedEntries(changes) => {
                     cx.emit(WorktreeStoreEvent::WorktreeUpdatedEntries(
@@ -450,10 +450,7 @@ impl WorktreeStore {
             })
             .collect::<HashMap<_, _>>();
 
-        let (client, project_id) = self
-            .upstream_client()
-            .clone()
-            .ok_or_else(|| anyhow!("invalid project"))?;
+        let (client, project_id) = self.upstream_client().clone().context("invalid project")?;
 
         for worktree in worktrees {
             if let Some(old_worktree) =
@@ -876,7 +873,7 @@ impl WorktreeStore {
 
     async fn filter_paths(
         fs: &Arc<dyn Fs>,
-        mut input: Receiver<MatchingEntry>,
+        input: Receiver<MatchingEntry>,
         query: &SearchQuery,
     ) -> Result<()> {
         let mut input = pin!(input);
@@ -916,7 +913,7 @@ impl WorktreeStore {
         let worktree = this.update(&mut cx, |this, cx| {
             let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
             this.worktree_for_id(worktree_id, cx)
-                .ok_or_else(|| anyhow!("worktree not found"))
+                .context("worktree not found")
         })??;
         Worktree::handle_create_entry(worktree, envelope.payload, cx).await
     }
@@ -929,7 +926,7 @@ impl WorktreeStore {
         let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
         let worktree = this.update(&mut cx, |this, cx| {
             this.worktree_for_entry(entry_id, cx)
-                .ok_or_else(|| anyhow!("worktree not found"))
+                .context("worktree not found")
         })??;
         Worktree::handle_copy_entry(worktree, envelope.payload, cx).await
     }
@@ -942,7 +939,7 @@ impl WorktreeStore {
         let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
         let worktree = this.update(&mut cx, |this, cx| {
             this.worktree_for_entry(entry_id, cx)
-                .ok_or_else(|| anyhow!("worktree not found"))
+                .context("worktree not found")
         })??;
         Worktree::handle_delete_entry(worktree, envelope.payload, cx).await
     }
@@ -955,7 +952,7 @@ impl WorktreeStore {
         let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
         let worktree = this
             .update(&mut cx, |this, cx| this.worktree_for_entry(entry_id, cx))?
-            .ok_or_else(|| anyhow!("invalid request"))?;
+            .context("invalid request")?;
         Worktree::handle_expand_entry(worktree, envelope.payload, cx).await
     }
 
@@ -967,9 +964,16 @@ impl WorktreeStore {
         let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
         let worktree = this
             .update(&mut cx, |this, cx| this.worktree_for_entry(entry_id, cx))?
-            .ok_or_else(|| anyhow!("invalid request"))?;
+            .context("invalid request")?;
         Worktree::handle_expand_all_for_entry(worktree, envelope.payload, cx).await
     }
+
+    pub fn fs(&self) -> Option<Arc<dyn Fs>> {
+        match &self.state {
+            WorktreeStoreState::Local { fs } => Some(fs.clone()),
+            WorktreeStoreState::Remote { .. } => None,
+        }
+    }
 }
 
 #[derive(Clone, Debug)]

crates/project/src/yarn.rs 🔗

@@ -15,7 +15,7 @@ use anyhow::Result;
 use collections::HashMap;
 use fs::Fs;
 use gpui::{App, AppContext as _, Context, Entity, Task};
-use util::ResultExt;
+use util::{ResultExt, archive::extract_zip};
 
 pub(crate) struct YarnPathStore {
     temp_dirs: HashMap<Arc<Path>, tempfile::TempDir>,
@@ -93,7 +93,7 @@ impl YarnPathStore {
             let zip_file: Arc<Path> = Arc::from(zip_file);
             cx.spawn(async move |this, cx| {
                 let dir = this
-                    .update(cx, |this, _| {
+                    .read_with(cx, |this, _| {
                         this.temp_dirs
                             .get(&zip_file)
                             .map(|temp| temp.path().to_owned())
@@ -131,7 +131,7 @@ fn zip_path(path: &Path) -> Option<&Path> {
 async fn dump_zip(path: Arc<Path>, fs: Arc<dyn Fs>) -> Result<tempfile::TempDir> {
     let dir = tempfile::tempdir()?;
     let contents = fs.load_bytes(&path).await?;
-    node_runtime::extract_zip(dir.path(), futures::io::Cursor::new(contents)).await?;
+    extract_zip(dir.path(), futures::io::Cursor::new(contents)).await?;
     Ok(dir)
 }
 

crates/project_panel/src/project_panel.rs 🔗

@@ -1,7 +1,7 @@
 mod project_panel_settings;
 mod utils;
 
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use client::{ErrorCode, ErrorExt};
 use collections::{BTreeSet, HashMap, hash_map};
 use command_palette_hooks::CommandPaletteFilter;
@@ -12,17 +12,18 @@ use editor::{
         entry_diagnostic_aware_icon_decoration_and_color,
         entry_diagnostic_aware_icon_name_and_color, entry_git_aware_label_color,
     },
-    scroll::{Autoscroll, ScrollbarAutoHide},
+    scroll::ScrollbarAutoHide,
 };
 use file_icons::FileIcons;
 use git::status::GitSummary;
 use gpui::{
     Action, AnyElement, App, ArcCow, AsyncWindowContext, Bounds, ClipboardItem, Context,
-    DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable,
-    Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
-    MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy,
-    Stateful, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions,
-    anchored, deferred, div, impl_actions, point, px, size, uniform_list,
+    CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths,
+    FocusHandle, Focusable, Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior,
+    ListSizingBehavior, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent,
+    ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy, Stateful, Styled,
+    Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred,
+    div, point, px, size, transparent_white, uniform_list,
 };
 use indexmap::IndexMap;
 use language::DiagnosticSeverity;
@@ -65,6 +66,7 @@ use workspace::{
     notifications::{DetachAndPromptErr, NotifyTaskExt},
 };
 use worktree::CreatedEntry;
+use zed_actions::OpenRecent;
 
 const PROJECT_PANEL_KEY: &str = "ProjectPanel";
 const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
@@ -84,8 +86,7 @@ pub struct ProjectPanel {
     ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
     folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
     last_worktree_root_id: Option<ProjectEntryId>,
-    last_selection_drag_over_entry: Option<ProjectEntryId>,
-    last_external_paths_drag_over_entry: Option<ProjectEntryId>,
+    drag_target_entry: Option<DragTargetEntry>,
     expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
     unfolded_dir_ids: HashSet<ProjectEntryId>,
     // Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
@@ -109,6 +110,14 @@ pub struct ProjectPanel {
     // in case a user clicks to open a file.
     mouse_down: bool,
     hover_expand_task: Option<Task<()>>,
+    previous_drag_position: Option<Point<Pixels>>,
+}
+
+struct DragTargetEntry {
+    /// The entry currently under the mouse cursor during a drag operation
+    entry_id: ProjectEntryId,
+    /// Highlight this entry along with all of its children
+    highlight_entry_id: Option<ProjectEntryId>,
 }
 
 #[derive(Copy, Clone, Debug)]
@@ -172,22 +181,22 @@ struct EntryDetails {
     canonical_path: Option<Arc<Path>>,
 }
 
-#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
+#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
+#[action(namespace = project_panel)]
 #[serde(deny_unknown_fields)]
 struct Delete {
     #[serde(default)]
     pub skip_prompt: bool,
 }
 
-#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
+#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
+#[action(namespace = project_panel)]
 #[serde(deny_unknown_fields)]
 struct Trash {
     #[serde(default)]
     pub skip_prompt: bool,
 }
 
-impl_actions!(project_panel, [Delete, Trash]);
-
 actions!(
     project_panel,
     [
@@ -251,6 +260,14 @@ pub fn init(cx: &mut App) {
                 setting.hide_gitignore = Some(!setting.hide_gitignore.unwrap_or(false));
             })
         });
+
+        workspace.register_action(|workspace, action: &CollapseAllEntries, window, cx| {
+            if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
+                panel.update(cx, |panel, cx| {
+                    panel.collapse_all_entries(action, window, cx);
+                });
+            }
+        });
     })
     .detach();
 }
@@ -455,6 +472,9 @@ impl ProjectPanel {
                     if project_panel_settings.hide_gitignore != new_settings.hide_gitignore {
                         this.update_visible_entries(None, cx);
                     }
+                    if project_panel_settings.hide_root != new_settings.hide_root {
+                        this.update_visible_entries(None, cx);
+                    }
                     project_panel_settings = new_settings;
                     this.update_diagnostics(cx);
                     cx.notify();
@@ -471,9 +491,8 @@ impl ProjectPanel {
                 visible_entries: Default::default(),
                 ancestors: Default::default(),
                 folded_directory_drag_target: None,
+                drag_target_entry: None,
                 last_worktree_root_id: Default::default(),
-                last_external_paths_drag_over_entry: None,
-                last_selection_drag_over_entry: None,
                 expanded_dir_ids: Default::default(),
                 unfolded_dir_ids: Default::default(),
                 selection: None,
@@ -497,6 +516,7 @@ impl ProjectPanel {
                 scroll_handle,
                 mouse_down: false,
                 hover_expand_task: None,
+                previous_drag_position: None,
             };
             this.update_visible_entries(None, cx);
 
@@ -592,16 +612,25 @@ impl ProjectPanel {
         workspace: WeakEntity<Workspace>,
         mut cx: AsyncWindowContext,
     ) -> Result<Entity<Self>> {
-        let serialized_panel = cx
-            .background_spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
-            .await
-            .map_err(|e| anyhow!("Failed to load project panel: {}", e))
-            .log_err()
+        let serialized_panel = match workspace
+            .read_with(&cx, |workspace, _| {
+                ProjectPanel::serialization_key(workspace)
+            })
+            .ok()
             .flatten()
-            .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
-            .transpose()
-            .log_err()
-            .flatten();
+        {
+            Some(serialization_key) => cx
+                .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
+                .await
+                .context("loading project panel")
+                .log_err()
+                .flatten()
+                .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
+                .transpose()
+                .log_err()
+                .flatten(),
+            None => None,
+        };
 
         workspace.update_in(&mut cx, |workspace, window, cx| {
             let panel = ProjectPanel::new(workspace, window, cx);
@@ -673,13 +702,31 @@ impl ProjectPanel {
             .or_insert(diagnostic_severity);
     }
 
+    fn serialization_key(workspace: &Workspace) -> Option<String> {
+        workspace
+            .database_id()
+            .map(|id| i64::from(id).to_string())
+            .or(workspace.session_id())
+            .map(|id| format!("{}-{:?}", PROJECT_PANEL_KEY, id))
+    }
+
     fn serialize(&mut self, cx: &mut Context<Self>) {
+        let Some(serialization_key) = self
+            .workspace
+            .read_with(cx, |workspace, _| {
+                ProjectPanel::serialization_key(workspace)
+            })
+            .ok()
+            .flatten()
+        else {
+            return;
+        };
         let width = self.width;
         self.pending_serialization = cx.background_spawn(
             async move {
                 KEY_VALUE_STORE
                     .write_kvp(
-                        PROJECT_PANEL_KEY.into(),
+                        serialization_key,
                         serde_json::to_string(&SerializedProjectPanel { width })?,
                     )
                     .await?;
@@ -732,6 +779,12 @@ impl ProjectPanel {
             let is_remote = project.is_via_collab();
             let is_local = project.is_local();
 
+            let settings = ProjectPanelSettings::get_global(cx);
+            let visible_worktrees_count = project.visible_worktrees(cx).count();
+            let should_hide_rename = is_root
+                && (cfg!(target_os = "windows")
+                    || (settings.hide_root && visible_worktrees_count == 1));
+
             let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
                 menu.context(self.focus_handle.clone()).map(|menu| {
                     if is_read_only {
@@ -781,7 +834,7 @@ impl ProjectPanel {
                                 Box::new(zed_actions::workspace::CopyRelativePath),
                             )
                             .separator()
-                            .when(!is_root || !cfg!(target_os = "windows"), |menu| {
+                            .when(!should_hide_rename, |menu| {
                                 menu.action("Rename", Box::new(Rename))
                             })
                             .when(!is_root & !is_remote, |menu| {
@@ -1347,6 +1400,8 @@ impl ProjectPanel {
 
     fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
         if cx.stop_active_drag(window) {
+            self.drag_target_entry.take();
+            self.hover_expand_task.take();
             return;
         }
 
@@ -1500,6 +1555,16 @@ impl ProjectPanel {
                     if Some(entry) == worktree.read(cx).root_entry() {
                         return;
                     }
+
+                    if Some(entry) == worktree.read(cx).root_entry() {
+                        let settings = ProjectPanelSettings::get_global(cx);
+                        let visible_worktrees_count =
+                            self.project.read(cx).visible_worktrees(cx).count();
+                        if settings.hide_root && visible_worktrees_count == 1 {
+                            return;
+                        }
+                    }
+
                     self.edit_state = Some(EditState {
                         worktree_id,
                         entry_id: sub_entry_id,
@@ -1524,7 +1589,7 @@ impl ProjectPanel {
                     });
                     self.filename_editor.update(cx, |editor, cx| {
                         editor.set_text(file_name, window, cx);
-                        editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+                        editor.change_selections(Default::default(), window, cx, |s| {
                             s.select_ranges([selection])
                         });
                         window.focus(&editor.focus_handle(cx));
@@ -2068,19 +2133,11 @@ impl ProjectPanel {
     }
 
     fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
-        let worktree = self
-            .visible_entries
-            .first()
-            .and_then(|(worktree_id, _, _)| {
-                self.project.read(cx).worktree_for_id(*worktree_id, cx)
-            });
-        if let Some(worktree) = worktree {
-            let worktree = worktree.read(cx);
-            let worktree_id = worktree.id();
-            if let Some(root_entry) = worktree.root_entry() {
+        if let Some((worktree_id, visible_worktree_entries, _)) = self.visible_entries.first() {
+            if let Some(entry) = visible_worktree_entries.first() {
                 let selection = SelectedEntry {
-                    worktree_id,
-                    entry_id: root_entry.id,
+                    worktree_id: *worktree_id,
+                    entry_id: entry.id,
                 };
                 self.selection = Some(selection);
                 if window.modifiers().shift {
@@ -2276,7 +2333,7 @@ impl ProjectPanel {
                             project_panel
                                 .project
                                 .update(cx, |project, cx| project.delete_entry(entry_id, true, cx))
-                                .ok_or_else(|| anyhow!("no such entry"))
+                                .context("no such entry")
                         })??
                         .await?;
                 }
@@ -2315,6 +2372,11 @@ impl ProjectPanel {
             })
             .detach_and_log_err(cx);
 
+            if clip_is_cut {
+                // Convert the clipboard cut entry to a copy entry after the first paste.
+                self.clipboard = self.clipboard.take().map(ClipboardEntry::to_copy_entry);
+            }
+
             self.expand_entry(worktree_id, entry.id, cx);
             Some(())
         });
@@ -2728,6 +2790,31 @@ impl ProjectPanel {
         Some(())
     }
 
+    fn create_new_git_entry(
+        parent_entry: &Entry,
+        git_summary: GitSummary,
+        new_entry_kind: EntryKind,
+    ) -> GitEntry {
+        GitEntry {
+            entry: Entry {
+                id: NEW_ENTRY_ID,
+                kind: new_entry_kind,
+                path: parent_entry.path.join("\0").into(),
+                inode: 0,
+                mtime: parent_entry.mtime,
+                size: parent_entry.size,
+                is_ignored: parent_entry.is_ignored,
+                is_external: false,
+                is_private: false,
+                is_always_included: parent_entry.is_always_included,
+                canonical_path: parent_entry.canonical_path.clone(),
+                char_bag: parent_entry.char_bag,
+                is_fifo: parent_entry.is_fifo,
+            },
+            git_summary,
+        }
+    }
+
     fn update_visible_entries(
         &mut self,
         new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
@@ -2747,7 +2834,10 @@ impl ProjectPanel {
         let old_ancestors = std::mem::take(&mut self.ancestors);
         self.visible_entries.clear();
         let mut max_width_item = None;
-        for worktree in project.visible_worktrees(cx) {
+
+        let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect();
+        let hide_root = settings.hide_root && visible_worktrees.len() == 1;
+        for worktree in visible_worktrees {
             let worktree_snapshot = worktree.read(cx).snapshot();
             let worktree_id = worktree_snapshot.id();
 
@@ -2782,6 +2872,18 @@ impl ProjectPanel {
                 GitTraversal::new(&repo_snapshots, worktree_snapshot.entries(true, 0));
             let mut auto_folded_ancestors = vec![];
             while let Some(entry) = entry_iter.entry() {
+                if hide_root && Some(entry.entry) == worktree.read(cx).root_entry() {
+                    if new_entry_parent_id == Some(entry.id) {
+                        visible_worktree_entries.push(Self::create_new_git_entry(
+                            &entry.entry,
+                            entry.git_summary,
+                            new_entry_kind,
+                        ));
+                        new_entry_parent_id = None;
+                    }
+                    entry_iter.advance();
+                    continue;
+                }
                 if auto_collapse_dirs && entry.kind.is_dir() {
                     auto_folded_ancestors.push(entry.id);
                     if !self.unfolded_dir_ids.contains(&entry.id) {
@@ -2827,35 +2929,19 @@ impl ProjectPanel {
                 }
                 let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id {
                     entry.id == new_entry_id || {
-                        self.ancestors.get(&entry.id).map_or(false, |entries| {
-                            entries
-                                .ancestors
-                                .iter()
-                                .any(|entry_id| *entry_id == new_entry_id)
-                        })
+                        self.ancestors
+                            .get(&entry.id)
+                            .map_or(false, |entries| entries.ancestors.contains(&new_entry_id))
                     }
                 } else {
                     false
                 };
                 if precedes_new_entry && (!hide_gitignore || !entry.is_ignored) {
-                    visible_worktree_entries.push(GitEntry {
-                        entry: Entry {
-                            id: NEW_ENTRY_ID,
-                            kind: new_entry_kind,
-                            path: entry.path.join("\0").into(),
-                            inode: 0,
-                            mtime: entry.mtime,
-                            size: entry.size,
-                            is_ignored: entry.is_ignored,
-                            is_external: false,
-                            is_private: false,
-                            is_always_included: entry.is_always_included,
-                            canonical_path: entry.canonical_path.clone(),
-                            char_bag: entry.char_bag,
-                            is_fifo: entry.is_fifo,
-                        },
-                        git_summary: entry.git_summary,
-                    });
+                    visible_worktree_entries.push(Self::create_new_git_entry(
+                        &entry.entry,
+                        entry.git_summary,
+                        new_entry_kind,
+                    ));
                 }
                 let worktree_abs_path = worktree.read(cx).abs_path();
                 let (depth, path) = if Some(entry.entry) == worktree.read(cx).root_entry() {
@@ -3071,6 +3157,29 @@ impl ProjectPanel {
         .detach();
     }
 
+    fn refresh_drag_cursor_style(
+        &self,
+        modifiers: &Modifiers,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(existing_cursor) = cx.active_drag_cursor_style() {
+            let new_cursor = if Self::is_copy_modifier_set(modifiers) {
+                CursorStyle::DragCopy
+            } else {
+                CursorStyle::PointingHand
+            };
+            if existing_cursor != new_cursor {
+                cx.set_active_drag_cursor_style(new_cursor, window);
+            }
+        }
+    }
+
+    fn is_copy_modifier_set(modifiers: &Modifiers) -> bool {
+        cfg!(target_os = "macos") && modifiers.alt
+            || cfg!(not(target_os = "macos")) && modifiers.control
+    }
+
     fn drag_onto(
         &mut self,
         selections: &DraggedSelection,
@@ -3079,8 +3188,7 @@ impl ProjectPanel {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let should_copy = window.modifiers().alt;
-        if should_copy {
+        if Self::is_copy_modifier_set(&window.modifiers()) {
             let _ = maybe!({
                 let project = self.project.read(cx);
                 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
@@ -3165,7 +3273,7 @@ impl ProjectPanel {
         None
     }
 
-    fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef)> {
+    fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef<'_>)> {
         let mut offset = 0;
         for (worktree_id, visible_worktree_entries, _) in &self.visible_entries {
             if visible_worktree_entries.len() > offset + index {
@@ -3342,10 +3450,7 @@ impl ProjectPanel {
                                     .ancestors
                                     .get(&entry.id)
                                     .is_some_and(|auto_folded_dirs| {
-                                        auto_folded_dirs
-                                            .ancestors
-                                            .iter()
-                                            .any(|entry_id| *entry_id == edit_state.entry_id)
+                                        auto_folded_dirs.ancestors.contains(&edit_state.entry_id)
                                     })
                         };
 
@@ -3434,7 +3539,7 @@ impl ProjectPanel {
             .read(cx)
             .repo_snapshots(cx);
         let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
-        worktree.update(cx, |tree, _| {
+        worktree.read_with(cx, |tree, _| {
             utils::ReversibleIterable::new(
                 GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize)),
                 reverse_search,
@@ -3469,16 +3574,17 @@ impl ProjectPanel {
             let worktree = self
                 .project
                 .read(cx)
-                .worktree_for_id(start.worktree_id, cx)?;
+                .worktree_for_id(start.worktree_id, cx)?
+                .read(cx);
 
-            let search = worktree.update(cx, |tree, _| {
-                let entry = tree.entry_for_id(start.entry_id)?;
-                let root_entry = tree.root_entry()?;
-                let tree_id = tree.id();
+            let search = {
+                let entry = worktree.entry_for_id(start.entry_id)?;
+                let root_entry = worktree.root_entry()?;
+                let tree_id = worktree.id();
 
                 let mut first_iter = GitTraversal::new(
                     &repo_snapshots,
-                    tree.traverse_from_path(true, true, true, entry.path.as_ref()),
+                    worktree.traverse_from_path(true, true, true, entry.path.as_ref()),
                 );
 
                 if reverse_search {
@@ -3492,7 +3598,8 @@ impl ProjectPanel {
                     .find(|ele| predicate(*ele, tree_id))
                     .map(|ele| ele.to_owned());
 
-                let second_iter = GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize));
+                let second_iter =
+                    GitTraversal::new(&repo_snapshots, worktree.entries(true, 0usize));
 
                 let second = if reverse_search {
                     second_iter
@@ -3513,7 +3620,7 @@ impl ProjectPanel {
                 } else {
                     Some((first, second))
                 }
-            });
+            };
 
             if let Some((first, second)) = search {
                 let first = first.map(|entry| SelectedEntry {
@@ -3668,11 +3775,72 @@ impl ProjectPanel {
                     None
                 }
             })
-            .unwrap_or((0, 0));
+            .unwrap_or_else(|| (0, entry.path.components().count()));
 
         (depth, difference)
     }
 
+    fn highlight_entry_for_external_drag(
+        &self,
+        target_entry: &Entry,
+        target_worktree: &Worktree,
+    ) -> Option<ProjectEntryId> {
+        // Always highlight directory or parent directory if it's file
+        if target_entry.is_dir() {
+            Some(target_entry.id)
+        } else if let Some(parent_entry) = target_entry
+            .path
+            .parent()
+            .and_then(|parent_path| target_worktree.entry_for_path(parent_path))
+        {
+            Some(parent_entry.id)
+        } else {
+            None
+        }
+    }
+
+    fn highlight_entry_for_selection_drag(
+        &self,
+        target_entry: &Entry,
+        target_worktree: &Worktree,
+        drag_state: &DraggedSelection,
+        cx: &Context<Self>,
+    ) -> Option<ProjectEntryId> {
+        let target_parent_path = target_entry.path.parent();
+
+        // In case of single item drag, we do not highlight existing
+        // directory which item belongs too
+        if drag_state.items().count() == 1 {
+            let active_entry_path = self
+                .project
+                .read(cx)
+                .path_for_entry(drag_state.active_selection.entry_id, cx)?;
+
+            if let Some(active_parent_path) = active_entry_path.path.parent() {
+                // Do not highlight active entry parent
+                if active_parent_path == target_entry.path.as_ref() {
+                    return None;
+                }
+
+                // Do not highlight active entry sibling files
+                if Some(active_parent_path) == target_parent_path && target_entry.is_file() {
+                    return None;
+                }
+            }
+        }
+
+        // Always highlight directory or parent directory if it's file
+        if target_entry.is_dir() {
+            Some(target_entry.id)
+        } else if let Some(parent_entry) =
+            target_parent_path.and_then(|parent_path| target_worktree.entry_for_path(parent_path))
+        {
+            Some(parent_entry.id)
+        } else {
+            None
+        }
+    }
+
     fn render_entry(
         &self,
         entry_id: ProjectEntryId,
@@ -3715,6 +3883,8 @@ impl ProjectPanel {
             .as_ref()
             .map(|f| f.to_string_lossy().to_string());
         let path = details.path.clone();
+        let path_for_external_paths = path.clone();
+        let path_for_dragged_selection = path.clone();
 
         let depth = details.depth;
         let worktree_id = details.worktree_id;
@@ -3772,6 +3942,27 @@ impl ProjectPanel {
             };
 
         let folded_directory_drag_target = self.folded_directory_drag_target;
+        let is_highlighted = {
+            if let Some(highlight_entry_id) = self
+                .drag_target_entry
+                .as_ref()
+                .and_then(|drag_target| drag_target.highlight_entry_id)
+            {
+                // Highlight if same entry or it's children
+                if entry_id == highlight_entry_id {
+                    true
+                } else {
+                    maybe!({
+                        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
+                        let highlight_entry = worktree.read(cx).entry_for_id(highlight_entry_id)?;
+                        Some(path.starts_with(&highlight_entry.path))
+                    })
+                    .unwrap_or(false)
+                }
+            } else {
+                false
+            }
+        };
 
         div()
             .id(entry_id.to_proto() as usize)
@@ -3785,95 +3976,114 @@ impl ProjectPanel {
             .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
             .on_drag_move::<ExternalPaths>(cx.listener(
                 move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
-                    if event.bounds.contains(&event.event.position) {
-                        if this.last_external_paths_drag_over_entry == Some(entry_id) {
-                            return;
+                    let is_current_target = this.drag_target_entry.as_ref()
+                         .map(|entry| entry.entry_id) == Some(entry_id);
+
+                    if !event.bounds.contains(&event.event.position) {
+                        // Entry responsible for setting drag target is also responsible to
+                        // clear it up after drag is out of bounds
+                        if is_current_target {
+                            this.drag_target_entry = None;
                         }
-                        this.last_external_paths_drag_over_entry = Some(entry_id);
-                        this.marked_entries.clear();
-
-                        let Some((worktree, path, entry)) = maybe!({
-                            let worktree = this
-                                .project
-                                .read(cx)
-                                .worktree_for_id(selection.worktree_id, cx)?;
-                            let worktree = worktree.read(cx);
-                            let entry = worktree.entry_for_path(&path)?;
-                            let path = if entry.is_dir() {
-                                path.as_ref()
-                            } else {
-                                path.parent()?
-                            };
-                            Some((worktree, path, entry))
-                        }) else {
-                            return;
-                        };
+                        return;
+                    }
 
-                        this.marked_entries.insert(SelectedEntry {
-                            entry_id: entry.id,
-                            worktree_id: worktree.id(),
-                        });
+                    if is_current_target {
+                        return;
+                    }
 
-                        for entry in worktree.child_entries(path) {
-                            this.marked_entries.insert(SelectedEntry {
-                                entry_id: entry.id,
-                                worktree_id: worktree.id(),
-                            });
-                        }
+                    let Some((entry_id, highlight_entry_id)) = maybe!({
+                        let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
+                        let target_entry = target_worktree.entry_for_path(&path_for_external_paths)?;
+                        let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree);
+                        Some((target_entry.id, highlight_entry_id))
+                    }) else {
+                        return;
+                    };
 
-                        cx.notify();
-                    }
+                    this.drag_target_entry = Some(DragTargetEntry {
+                        entry_id,
+                        highlight_entry_id,
+                    });
+                    this.marked_entries.clear();
                 },
             ))
             .on_drop(cx.listener(
                 move |this, external_paths: &ExternalPaths, window, cx| {
+                    this.drag_target_entry = None;
                     this.hover_scroll_task.take();
-                    this.last_external_paths_drag_over_entry = None;
-                    this.marked_entries.clear();
                     this.drop_external_files(external_paths.paths(), entry_id, window, cx);
                     cx.stop_propagation();
                 },
             ))
             .on_drag_move::<DraggedSelection>(cx.listener(
                 move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
-                    if event.bounds.contains(&event.event.position) {
-                        if this.last_selection_drag_over_entry == Some(entry_id) {
-                            return;
-                        }
-                        this.last_selection_drag_over_entry = Some(entry_id);
-                        this.hover_expand_task.take();
-
-                        if !kind.is_dir()
-                            || this
-                                .expanded_dir_ids
-                                .get(&details.worktree_id)
-                                .map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
-                        {
-                            return;
+                    let is_current_target = this.drag_target_entry.as_ref()
+                         .map(|entry| entry.entry_id) == Some(entry_id);
+
+                    if !event.bounds.contains(&event.event.position) {
+                        // Entry responsible for setting drag target is also responsible to
+                        // clear it up after drag is out of bounds
+                        if is_current_target {
+                            this.drag_target_entry = None;
                         }
+                        return;
+                    }
 
-                        let bounds = event.bounds;
-                        this.hover_expand_task =
-                            Some(cx.spawn_in(window, async move |this, cx| {
-                                cx.background_executor()
-                                    .timer(Duration::from_millis(500))
-                                    .await;
-                                this.update_in(cx, |this, window, cx| {
-                                    this.hover_expand_task.take();
-                                    if this.last_selection_drag_over_entry == Some(entry_id)
-                                        && bounds.contains(&window.mouse_position())
-                                    {
-                                        this.expand_entry(worktree_id, entry_id, cx);
-                                        this.update_visible_entries(
-                                            Some((worktree_id, entry_id)),
-                                            cx,
-                                        );
-                                        cx.notify();
-                                    }
-                                })
-                                .ok();
-                            }));
+                    if is_current_target {
+                        return;
+                    }
+
+                    let drag_state = event.drag(cx);
+                    let Some((entry_id, highlight_entry_id)) = maybe!({
+                        let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
+                        let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?;
+                        let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, drag_state, cx);
+                        Some((target_entry.id, highlight_entry_id))
+                    }) else {
+                        return;
+                    };
+
+                    this.drag_target_entry = Some(DragTargetEntry {
+                        entry_id,
+                        highlight_entry_id,
+                    });
+                    if drag_state.items().count() == 1 {
+                        this.marked_entries.clear();
+                        this.marked_entries.insert(drag_state.active_selection);
+                    }
+                    this.hover_expand_task.take();
+
+                    if !kind.is_dir()
+                        || this
+                            .expanded_dir_ids
+                            .get(&details.worktree_id)
+                            .map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
+                    {
+                        return;
                     }
+
+                    let bounds = event.bounds;
+                    this.hover_expand_task =
+                        Some(cx.spawn_in(window, async move |this, cx| {
+                            cx.background_executor()
+                                .timer(Duration::from_millis(500))
+                                .await;
+                            this.update_in(cx, |this, window, cx| {
+                                this.hover_expand_task.take();
+                                if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id)
+                                    && bounds.contains(&window.mouse_position())
+                                {
+                                    this.expand_entry(worktree_id, entry_id, cx);
+                                    this.update_visible_entries(
+                                        Some((worktree_id, entry_id)),
+                                        cx,
+                                    );
+                                    cx.notify();
+                                }
+                            })
+                            .ok();
+                        }));
                 },
             ))
             .on_drag(
@@ -3887,14 +4097,10 @@ impl ProjectPanel {
                     })
                 },
             )
-            .drag_over::<DraggedSelection>(move |style, _, _, _| {
-                if  folded_directory_drag_target.is_some() {
-                    return style;
-                }
-                style.bg(item_colors.drag_over)
-            })
+            .when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over))
             .on_drop(
                 cx.listener(move |this, selections: &DraggedSelection, window, cx| {
+                    this.drag_target_entry = None;
                     this.hover_scroll_task.take();
                     this.hover_expand_task.take();
                     if  folded_directory_drag_target.is_some() {
@@ -4096,6 +4302,7 @@ impl ProjectPanel {
                                                     div()
                                                     .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
                                                         this.hover_scroll_task.take();
+                                                        this.drag_target_entry = None;
                                                         this.folded_directory_drag_target = None;
                                                         if let Some(target_entry_id) = target_entry_id {
                                                             this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
@@ -4178,6 +4385,7 @@ impl ProjectPanel {
                                                 ))
                                                 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
                                                     this.hover_scroll_task.take();
+                                                    this.drag_target_entry = None;
                                                     this.folded_directory_drag_target = None;
                                                     if let Some(target_entry_id) = target_entry_id {
                                                         this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
@@ -4213,8 +4421,7 @@ impl ProjectPanel {
                                     )
                                 }
                             })
-                        }
-                        .ml_1(),
+                        },
                     )
                     .on_secondary_mouse_down(cx.listener(
                         move |this, event: &MouseDownEvent, window, cx| {
@@ -4318,19 +4525,7 @@ impl ProjectPanel {
         {
             return None;
         }
-
-        let scroll_handle = self.scroll_handle.0.borrow();
-        let longest_item_width = scroll_handle
-            .last_item_size
-            .filter(|size| size.contents.width > size.item.width)?
-            .contents
-            .width
-            .0 as f64;
-        if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
-            return None;
-        }
-
-        Some(
+        Scrollbar::horizontal(self.horizontal_scrollbar_state.clone()).map(|scrollbar| {
             div()
                 .occlude()
                 .id("project-panel-horizontal-scroll")
@@ -4367,12 +4562,8 @@ impl ProjectPanel {
                 .bottom_1()
                 .h(px(12.))
                 .cursor_default()
-                .when(self.width.is_some(), |this| {
-                    this.children(Scrollbar::horizontal(
-                        self.horizontal_scrollbar_state.clone(),
-                    ))
-                }),
-        )
+                .child(scrollbar)
+        })
     }
 
     fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
@@ -4443,34 +4634,30 @@ impl ProjectPanel {
         skip_ignored: bool,
         cx: &mut Context<Self>,
     ) -> Result<()> {
-        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
-            let worktree = worktree.read(cx);
-            if skip_ignored
-                && worktree
-                    .entry_for_id(entry_id)
-                    .map_or(true, |entry| entry.is_ignored && !entry.is_always_included)
-            {
-                return Err(anyhow!(
-                    "can't reveal an ignored entry in the project panel"
-                ));
-            }
-
-            let worktree_id = worktree.id();
-            self.expand_entry(worktree_id, entry_id, cx);
-            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
-            self.marked_entries.clear();
-            self.marked_entries.insert(SelectedEntry {
-                worktree_id,
-                entry_id,
-            });
-            self.autoscroll(cx);
-            cx.notify();
-            Ok(())
-        } else {
-            Err(anyhow!(
-                "can't reveal a non-existent entry in the project panel"
-            ))
+        let worktree = project
+            .read(cx)
+            .worktree_for_entry(entry_id, cx)
+            .context("can't reveal a non-existent entry in the project panel")?;
+        let worktree = worktree.read(cx);
+        if skip_ignored
+            && worktree
+                .entry_for_id(entry_id)
+                .map_or(true, |entry| entry.is_ignored && !entry.is_always_included)
+        {
+            anyhow::bail!("can't reveal an ignored entry in the project panel");
         }
+
+        let worktree_id = worktree.id();
+        self.expand_entry(worktree_id, entry_id, cx);
+        self.update_visible_entries(Some((worktree_id, entry_id)), cx);
+        self.marked_entries.clear();
+        self.marked_entries.insert(SelectedEntry {
+            worktree_id,
+            entry_id,
+        });
+        self.autoscroll(cx);
+        cx.notify();
+        Ok(())
     }
 
     fn find_active_indent_guide(
@@ -4563,13 +4750,23 @@ impl Render for ProjectPanel {
                 .map(|(_, worktree_entries, _)| worktree_entries.len())
                 .sum();
 
-            fn handle_drag_move_scroll<T: 'static>(
+            fn handle_drag_move<T: 'static>(
                 this: &mut ProjectPanel,
                 e: &DragMoveEvent<T>,
                 window: &mut Window,
                 cx: &mut Context<ProjectPanel>,
             ) {
+                if let Some(previous_position) = this.previous_drag_position {
+                    // Refresh cursor only when an actual drag happens,
+                    // because modifiers are not updated when the cursor is not moved.
+                    if e.event.position != previous_position {
+                        this.refresh_drag_cursor_style(&e.event.modifiers, window, cx);
+                    }
+                }
+                this.previous_drag_position = Some(e.event.position);
+
                 if !e.bounds.contains(&e.event.position) {
+                    this.drag_target_entry = None;
                     return;
                 }
                 this.hover_scroll_task.take();
@@ -4623,10 +4820,15 @@ impl Render for ProjectPanel {
             h_flex()
                 .id("project-panel")
                 .group("project-panel")
-                .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
-                .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
+                .on_drag_move(cx.listener(handle_drag_move::<ExternalPaths>))
+                .on_drag_move(cx.listener(handle_drag_move::<DraggedSelection>))
                 .size_full()
                 .relative()
+                .on_modifiers_changed(cx.listener(
+                    |this, event: &ModifiersChangedEvent, window, cx| {
+                        this.refresh_drag_cursor_style(&event.modifiers, window, cx);
+                    },
+                ))
                 .on_hover(cx.listener(|this, hovered, window, cx| {
                     if *hovered {
                         this.show_scrollbar = true;

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -44,6 +44,7 @@ pub struct ProjectPanelSettings {
     pub auto_fold_dirs: bool,
     pub scrollbar: ScrollbarSettings,
     pub show_diagnostics: ShowDiagnostics,
+    pub hide_root: bool,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -145,6 +146,10 @@ pub struct ProjectPanelSettingsContent {
     pub show_diagnostics: Option<ShowDiagnostics>,
     /// Settings related to indent guides in the project panel.
     pub indent_guides: Option<IndentGuidesSettingsContent>,
+    /// Whether to hide the root entry when only one folder is open in the window.
+    ///
+    /// Default: false
+    pub hide_root: Option<bool>,
 }
 
 impl Settings for ProjectPanelSettings {

crates/project_panel/src/project_panel_tests.rs 🔗

@@ -6,7 +6,7 @@ use project::{FakeFs, WorktreeSettings};
 use serde_json::json;
 use settings::SettingsStore;
 use std::path::{Path, PathBuf};
-use util::{path, separator};
+use util::path;
 use workspace::{
     AppState, Pane,
     item::{Item, ProjectItem},
@@ -309,6 +309,7 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
     )
     .await;
 
+    // Test 1: Multiple worktrees with auto_fold_dirs = true
     let project = Project::test(
         fs.clone(),
         [path!("/root1").as_ref(), path!("/root2").as_ref()],
@@ -331,10 +332,10 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&panel, 0..10, cx),
         &[
-            separator!("v root1"),
-            separator!("    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
-            separator!("v root2"),
-            separator!("    > dir_2"),
+            "v root1",
+            "    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
+            "v root2",
+            "    > dir_2",
         ]
     );
 
@@ -346,14 +347,14 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&panel, 0..10, cx),
         &[
-            separator!("v root1"),
-            separator!("    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected"),
-            separator!("        > nested_dir_4/nested_dir_5"),
-            separator!("          file_a.java"),
-            separator!("          file_b.java"),
-            separator!("          file_c.java"),
-            separator!("v root2"),
-            separator!("    > dir_2"),
+            "v root1",
+            "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
+            "        > nested_dir_4/nested_dir_5",
+            "          file_a.java",
+            "          file_b.java",
+            "          file_c.java",
+            "v root2",
+            "    > dir_2",
         ]
     );
 
@@ -365,33 +366,93 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&panel, 0..10, cx),
         &[
-            separator!("v root1"),
-            separator!("    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
-            separator!("        v nested_dir_4/nested_dir_5  <== selected"),
-            separator!("              file_d.java"),
-            separator!("          file_a.java"),
-            separator!("          file_b.java"),
-            separator!("          file_c.java"),
-            separator!("v root2"),
-            separator!("    > dir_2"),
+            "v root1",
+            "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
+            "        v nested_dir_4/nested_dir_5  <== selected",
+            "              file_d.java",
+            "          file_a.java",
+            "          file_b.java",
+            "          file_c.java",
+            "v root2",
+            "    > dir_2",
         ]
     );
     toggle_expand_dir(&panel, "root2/dir_2", cx);
     assert_eq!(
         visible_entries_as_strings(&panel, 0..10, cx),
         &[
-            separator!("v root1"),
-            separator!("    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
-            separator!("        v nested_dir_4/nested_dir_5"),
-            separator!("              file_d.java"),
-            separator!("          file_a.java"),
-            separator!("          file_b.java"),
-            separator!("          file_c.java"),
-            separator!("v root2"),
-            separator!("    v dir_2  <== selected"),
-            separator!("          file_1.java"),
+            "v root1",
+            "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
+            "        v nested_dir_4/nested_dir_5",
+            "              file_d.java",
+            "          file_a.java",
+            "          file_b.java",
+            "          file_c.java",
+            "v root2",
+            "    v dir_2  <== selected",
+            "          file_1.java",
         ]
     );
+
+    // Test 2: Single worktree with auto_fold_dirs = true and hide_root = true
+    {
+        let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
+        let workspace =
+            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+        cx.update(|_, cx| {
+            let settings = *ProjectPanelSettings::get_global(cx);
+            ProjectPanelSettings::override_global(
+                ProjectPanelSettings {
+                    auto_fold_dirs: true,
+                    hide_root: true,
+                    ..settings
+                },
+                cx,
+            );
+        });
+        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &["> dir_1/nested_dir_1/nested_dir_2/nested_dir_3"],
+            "Single worktree with hide_root=true should hide root and show auto-folded paths"
+        );
+
+        toggle_expand_dir(
+            &panel,
+            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
+            cx,
+        );
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
+                "    > nested_dir_4/nested_dir_5",
+                "      file_a.java",
+                "      file_b.java",
+                "      file_c.java",
+            ],
+            "Expanded auto-folded path with hidden root should show contents without root prefix"
+        );
+
+        toggle_expand_dir(
+            &panel,
+            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
+            cx,
+        );
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
+                "    v nested_dir_4/nested_dir_5  <== selected",
+                "          file_d.java",
+                "      file_a.java",
+                "      file_b.java",
+                "      file_c.java",
+            ],
+            "Nested expansion with hidden root should maintain proper indentation"
+        );
+    }
 }
 
 #[gpui::test(iterations = 30)]
@@ -1170,6 +1231,91 @@ async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_cut_paste(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor().clone());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "one.txt": "",
+            "two.txt": "",
+            "a": {},
+            "b": {}
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+    select_path_with_mark(&panel, "root/one.txt", cx);
+    select_path_with_mark(&panel, "root/two.txt", cx);
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..50, cx),
+        &[
+            "v root",
+            "    > a",
+            "    > b",
+            "      one.txt  <== marked",
+            "      two.txt  <== selected  <== marked",
+        ]
+    );
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.cut(&Default::default(), window, cx);
+    });
+
+    select_path(&panel, "root/a", cx);
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.paste(&Default::default(), window, cx);
+    });
+    cx.executor().run_until_parked();
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..50, cx),
+        &[
+            "v root",
+            "    v a",
+            "          one.txt  <== marked",
+            "          two.txt  <== selected  <== marked",
+            "    > b",
+        ],
+        "Cut entries should be moved on first paste."
+    );
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.cancel(&menu::Cancel {}, window, cx)
+    });
+    cx.executor().run_until_parked();
+
+    select_path(&panel, "root/b", cx);
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.paste(&Default::default(), window, cx);
+    });
+    cx.executor().run_until_parked();
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..50, cx),
+        &[
+            "v root",
+            "    v a",
+            "          one.txt",
+            "          two.txt",
+            "    v b",
+            "          one.txt",
+            "          two.txt  <== selected",
+        ],
+        "Cut entries should only be copied for the second paste!"
+    );
+}
+
 #[gpui::test]
 async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
     init_test(cx);
@@ -2390,6 +2536,7 @@ async fn test_select_directory(cx: &mut gpui::TestAppContext) {
         ]
     );
 }
+
 #[gpui::test]
 async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
     init_test_with_editor(cx);
@@ -2458,6 +2605,46 @@ async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
             "      file_2.py  <== selected",
         ]
     );
+
+    cx.update(|_, cx| {
+        let settings = *ProjectPanelSettings::get_global(cx);
+        ProjectPanelSettings::override_global(
+            ProjectPanelSettings {
+                hide_root: true,
+                ..settings
+            },
+            cx,
+        );
+    });
+
+    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+    #[rustfmt::skip]
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..10, cx),
+        &[
+            "> dir_1",
+            "> zdir_2",
+            "  file_1.py",
+            "  file_2.py",
+        ],
+        "With hide_root=true, root should be hidden"
+    );
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.select_first(&SelectFirst, window, cx)
+    });
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..10, cx),
+        &[
+            "> dir_1  <== selected",
+            "> zdir_2",
+            "  file_1.py",
+            "  file_2.py",
+        ],
+        "With hide_root=true, first entry should be dir_1, not the hidden root"
+    );
 }
 
 #[gpui::test]
@@ -2704,6 +2891,101 @@ async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) {
+    init_test_with_editor(cx);
+
+    let fs = FakeFs::new(cx.executor().clone());
+    fs.insert_tree(
+        "/root1",
+        json!({
+            "dir1": { "file1.txt": "content" },
+            "file2.txt": "content",
+        }),
+    )
+    .await;
+    fs.insert_tree("/root2", json!({ "file3.txt": "content" }))
+        .await;
+
+    // Test 1: Single worktree, hide_root=true - rename should be blocked
+    {
+        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
+        let workspace =
+            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+        cx.update(|_, cx| {
+            let settings = *ProjectPanelSettings::get_global(cx);
+            ProjectPanelSettings::override_global(
+                ProjectPanelSettings {
+                    hide_root: true,
+                    ..settings
+                },
+                cx,
+            );
+        });
+
+        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+        panel.update(cx, |panel, cx| {
+            let project = panel.project.read(cx);
+            let worktree = project.visible_worktrees(cx).next().unwrap();
+            let root_entry = worktree.read(cx).root_entry().unwrap();
+            panel.selection = Some(SelectedEntry {
+                worktree_id: worktree.read(cx).id(),
+                entry_id: root_entry.id,
+            });
+        });
+
+        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
+
+        assert!(
+            panel.read_with(cx, |panel, _| panel.edit_state.is_none()),
+            "Rename should be blocked when hide_root=true with single worktree"
+        );
+    }
+
+    // Test 2: Multiple worktrees, hide_root=true - rename should work
+    {
+        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
+        let workspace =
+            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+        cx.update(|_, cx| {
+            let settings = *ProjectPanelSettings::get_global(cx);
+            ProjectPanelSettings::override_global(
+                ProjectPanelSettings {
+                    hide_root: true,
+                    ..settings
+                },
+                cx,
+            );
+        });
+
+        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+        select_path(&panel, "root1", cx);
+        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
+
+        #[cfg(target_os = "windows")]
+        assert!(
+            panel.read_with(cx, |panel, _| panel.edit_state.is_none()),
+            "Rename should be blocked on Windows even with multiple worktrees"
+        );
+
+        #[cfg(not(target_os = "windows"))]
+        {
+            assert!(
+                panel.read_with(cx, |panel, _| panel.edit_state.is_some()),
+                "Rename should work with multiple worktrees on non-Windows when hide_root=true"
+            );
+            panel.update_in(cx, |panel, window, cx| {
+                panel.cancel(&menu::Cancel, window, cx)
+            });
+        }
+    }
+}
+
 #[gpui::test]
 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
     init_test_with_editor(cx);
@@ -4635,12 +4917,12 @@ async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&panel, 0..20, cx),
         &[
-            separator!("v root"),
-            separator!("    v dir1  <== selected"),
-            separator!("        > empty1/empty2/empty3"),
-            separator!("        > ignored_dir"),
-            separator!("        > subdir1"),
-            separator!("      .gitignore"),
+            "v root",
+            "    v dir1  <== selected",
+            "        > empty1/empty2/empty3",
+            "        > ignored_dir",
+            "        > subdir1",
+            "      .gitignore",
         ],
         "Should show first level with auto-folded dirs and ignored dir visible"
     );
@@ -4657,18 +4939,18 @@ async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&panel, 0..20, cx),
         &[
-            separator!("v root"),
-            separator!("    v dir1  <== selected"),
-            separator!("        v empty1"),
-            separator!("            v empty2"),
-            separator!("                v empty3"),
-            separator!("                      file.txt"),
-            separator!("        > ignored_dir"),
-            separator!("        v subdir1"),
-            separator!("            > ignored_nested"),
-            separator!("              file1.txt"),
-            separator!("              file2.txt"),
-            separator!("      .gitignore"),
+            "v root",
+            "    v dir1  <== selected",
+            "        v empty1",
+            "            v empty2",
+            "                v empty3",
+            "                      file.txt",
+            "        > ignored_dir",
+            "        v subdir1",
+            "            > ignored_nested",
+            "              file1.txt",
+            "              file2.txt",
+            "      .gitignore",
         ],
         "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
     );
@@ -4693,12 +4975,12 @@ async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&panel, 0..20, cx),
         &[
-            separator!("v root"),
-            separator!("    v dir1  <== selected"),
-            separator!("        > empty1"),
-            separator!("        > ignored_dir"),
-            separator!("        > subdir1"),
-            separator!("      .gitignore"),
+            "v root",
+            "    v dir1  <== selected",
+            "        > empty1",
+            "        > ignored_dir",
+            "        > subdir1",
+            "      .gitignore",
         ],
         "With auto-fold disabled: should show all directories separately"
     );
@@ -4715,18 +4997,18 @@ async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&panel, 0..20, cx),
         &[
-            separator!("v root"),
-            separator!("    v dir1  <== selected"),
-            separator!("        v empty1"),
-            separator!("            v empty2"),
-            separator!("                v empty3"),
-            separator!("                      file.txt"),
-            separator!("        > ignored_dir"),
-            separator!("        v subdir1"),
-            separator!("            > ignored_nested"),
-            separator!("              file1.txt"),
-            separator!("              file2.txt"),
-            separator!("      .gitignore"),
+            "v root",
+            "    v dir1  <== selected",
+            "        v empty1",
+            "            v empty2",
+            "                v empty3",
+            "                      file.txt",
+            "        > ignored_dir",
+            "        v subdir1",
+            "            > ignored_nested",
+            "              file1.txt",
+            "              file2.txt",
+            "      .gitignore",
         ],
         "After expand_all without auto-fold: should expand all dirs normally, \
          expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
@@ -4745,20 +5027,20 @@ async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&panel, 0..20, cx),
         &[
-            separator!("v root"),
-            separator!("    v dir1  <== selected"),
-            separator!("        v empty1"),
-            separator!("            v empty2"),
-            separator!("                v empty3"),
-            separator!("                      file.txt"),
-            separator!("        v ignored_dir"),
-            separator!("            v subdir"),
-            separator!("                  deep_file.txt"),
-            separator!("        v subdir1"),
-            separator!("            > ignored_nested"),
-            separator!("              file1.txt"),
-            separator!("              file2.txt"),
-            separator!("      .gitignore"),
+            "v root",
+            "    v dir1  <== selected",
+            "        v empty1",
+            "            v empty2",
+            "                v empty3",
+            "                      file.txt",
+            "        v ignored_dir",
+            "            v subdir",
+            "                  deep_file.txt",
+            "        v subdir1",
+            "            > ignored_nested",
+            "              file1.txt",
+            "              file2.txt",
+            "      .gitignore",
         ],
         "After expand_all on ignored_dir: should expand all contents of the ignored directory"
     );
@@ -4808,15 +5090,15 @@ async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
         assert_eq!(
             visible_entries_as_strings(&panel, 0..20, cx),
             &[
-                separator!("v root"),
-                separator!("    v dir1"),
-                separator!("        v subdir1"),
-                separator!("            v nested1"),
-                separator!("                  file1.txt"),
-                separator!("                  file2.txt"),
-                separator!("        v subdir2  <== selected"),
-                separator!("              file4.txt"),
-                separator!("    > dir2"),
+                "v root",
+                "    v dir1",
+                "        v subdir1",
+                "            v nested1",
+                "                  file1.txt",
+                "                  file2.txt",
+                "        v subdir2  <== selected",
+                "              file4.txt",
+                "    > dir2",
             ],
             "Initial state with everything expanded"
         );
@@ -4858,13 +5140,13 @@ async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
         assert_eq!(
             visible_entries_as_strings(&panel, 0..20, cx),
             &[
-                separator!("v root"),
-                separator!("    v dir1"),
-                separator!("        v subdir1/nested1  <== selected"),
-                separator!("              file1.txt"),
-                separator!("              file2.txt"),
-                separator!("        > subdir2"),
-                separator!("    > dir2/single_file"),
+                "v root",
+                "    v dir1",
+                "        v subdir1/nested1  <== selected",
+                "              file1.txt",
+                "              file2.txt",
+                "        > subdir2",
+                "    > dir2/single_file",
             ],
             "Initial state with some dirs expanded"
         );
@@ -4881,11 +5163,11 @@ async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
         assert_eq!(
             visible_entries_as_strings(&panel, 0..20, cx),
             &[
-                separator!("v root"),
-                separator!("    v dir1  <== selected"),
-                separator!("        > subdir1/nested1"),
-                separator!("        > subdir2"),
-                separator!("    > dir2/single_file"),
+                "v root",
+                "    v dir1  <== selected",
+                "        > subdir1/nested1",
+                "        > subdir2",
+                "    > dir2/single_file",
             ],
             "Subdirs should be collapsed and folded with auto-fold enabled"
         );
@@ -4913,14 +5195,14 @@ async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
         assert_eq!(
             visible_entries_as_strings(&panel, 0..20, cx),
             &[
-                separator!("v root"),
-                separator!("    v dir1"),
-                separator!("        v subdir1"),
-                separator!("            v nested1  <== selected"),
-                separator!("                  file1.txt"),
-                separator!("                  file2.txt"),
-                separator!("        > subdir2"),
-                separator!("    > dir2"),
+                "v root",
+                "    v dir1",
+                "        v subdir1",
+                "            v nested1  <== selected",
+                "                  file1.txt",
+                "                  file2.txt",
+                "        > subdir2",
+                "    > dir2",
             ],
             "Initial state with some dirs expanded and auto-fold disabled"
         );
@@ -4937,11 +5219,11 @@ async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
         assert_eq!(
             visible_entries_as_strings(&panel, 0..20, cx),
             &[
-                separator!("v root"),
-                separator!("    v dir1  <== selected"),
-                separator!("        > subdir1"),
-                separator!("        > subdir2"),
-                separator!("    > dir2"),
+                "v root",
+                "    v dir1  <== selected",
+                "        > subdir1",
+                "        > subdir2",
+                "    > dir2",
             ],
             "Subdirs should be collapsed but not folded with auto-fold disabled"
         );
@@ -4979,8 +5261,8 @@ async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&panel, 0..20, cx),
         &[
-            separator!("v root"),
-            separator!("    > dir1"),
+            "v root",
+            "    > dir1",
         ],
         "Initial state with nothing selected"
     );
@@ -5005,14 +5287,540 @@ async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
     assert_eq!(
         visible_entries_as_strings(&panel, 0..20, cx),
         &[
-            separator!("v root"),
-            separator!("    > dir1"),
-            separator!("      hello_from_no_selections  <== selected  <== marked"),
+            "v root",
+            "    > dir1",
+            "      hello_from_no_selections  <== selected  <== marked",
         ],
         "A new file is created under the root directory"
     );
 }
 
+#[gpui::test]
+async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor().clone());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "existing_dir": {
+                "existing_file.txt": "",
+            },
+            "existing_file.txt": "",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
+    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    cx.update(|_, cx| {
+        let settings = *ProjectPanelSettings::get_global(cx);
+        ProjectPanelSettings::override_global(
+            ProjectPanelSettings {
+                hide_root: true,
+                ..settings
+            },
+            cx,
+        );
+    });
+
+    let panel = workspace
+        .update(cx, |workspace, window, cx| {
+            let panel = ProjectPanel::new(workspace, window, cx);
+            workspace.add_panel(panel.clone(), window, cx);
+            panel
+        })
+        .unwrap();
+
+    #[rustfmt::skip]
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..20, cx),
+        &[
+            "> existing_dir",
+            "  existing_file.txt",
+        ],
+        "Initial state with hide_root=true, root should be hidden and nothing selected"
+    );
+
+    panel.update(cx, |panel, _| {
+        assert!(
+            panel.selection.is_none(),
+            "Should have no selection initially"
+        );
+    });
+
+    // Test 1: Create new file when no entry is selected
+    panel.update_in(cx, |panel, window, cx| {
+        panel.new_file(&NewFile, window, cx);
+    });
+    panel.update_in(cx, |panel, window, cx| {
+        assert!(panel.filename_editor.read(cx).is_focused(window));
+    });
+
+    #[rustfmt::skip]
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..20, cx),
+        &[
+            "> existing_dir",
+            "  [EDITOR: '']  <== selected",
+            "  existing_file.txt",
+        ],
+        "Editor should appear at root level when hide_root=true and no selection"
+    );
+
+    let confirm = panel.update_in(cx, |panel, window, cx| {
+        panel.filename_editor.update(cx, |editor, cx| {
+            editor.set_text("new_file_at_root.txt", window, cx)
+        });
+        panel.confirm_edit(window, cx).unwrap()
+    });
+    confirm.await.unwrap();
+
+    #[rustfmt::skip]
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..20, cx),
+        &[
+            "> existing_dir",
+            "  existing_file.txt",
+            "  new_file_at_root.txt  <== selected  <== marked",
+        ],
+        "New file should be created at root level and visible without root prefix"
+    );
+
+    assert!(
+        fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
+        "File should be created in the actual root directory"
+    );
+
+    // Test 2: Create new directory when no entry is selected
+    panel.update(cx, |panel, _| {
+        panel.selection = None;
+    });
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.new_directory(&NewDirectory, window, cx);
+    });
+    panel.update_in(cx, |panel, window, cx| {
+        assert!(panel.filename_editor.read(cx).is_focused(window));
+    });
+
+    #[rustfmt::skip]
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..20, cx),
+        &[
+            "> [EDITOR: '']  <== selected",
+            "> existing_dir",
+            "  existing_file.txt",
+            "  new_file_at_root.txt",
+        ],
+        "Directory editor should appear at root level when hide_root=true and no selection"
+    );
+
+    let confirm = panel.update_in(cx, |panel, window, cx| {
+        panel.filename_editor.update(cx, |editor, cx| {
+            editor.set_text("new_dir_at_root", window, cx)
+        });
+        panel.confirm_edit(window, cx).unwrap()
+    });
+    confirm.await.unwrap();
+
+    #[rustfmt::skip]
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..20, cx),
+        &[
+            "> existing_dir",
+            "v new_dir_at_root  <== selected",
+            "  existing_file.txt",
+            "  new_file_at_root.txt",
+        ],
+        "New directory should be created at root level and visible without root prefix"
+    );
+
+    assert!(
+        fs.is_dir(Path::new("/root/new_dir_at_root")).await,
+        "Directory should be created in the actual root directory"
+    );
+}
+
+#[gpui::test]
+async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor().clone());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "dir1": {
+                "file1.txt": "",
+                "dir2": {
+                    "file2.txt": ""
+                }
+            },
+            "file3.txt": ""
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+    panel.update(cx, |panel, cx| {
+        let project = panel.project.read(cx);
+        let worktree = project.visible_worktrees(cx).next().unwrap();
+        let worktree = worktree.read(cx);
+
+        // Test 1: Target is a directory, should highlight the directory itself
+        let dir_entry = worktree.entry_for_path("dir1").unwrap();
+        let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
+        assert_eq!(
+            result,
+            Some(dir_entry.id),
+            "Should highlight directory itself"
+        );
+
+        // Test 2: Target is nested file, should highlight immediate parent
+        let nested_file = worktree.entry_for_path("dir1/dir2/file2.txt").unwrap();
+        let nested_parent = worktree.entry_for_path("dir1/dir2").unwrap();
+        let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
+        assert_eq!(
+            result,
+            Some(nested_parent.id),
+            "Should highlight immediate parent"
+        );
+
+        // Test 3: Target is root level file, should highlight root
+        let root_file = worktree.entry_for_path("file3.txt").unwrap();
+        let result = panel.highlight_entry_for_external_drag(root_file, worktree);
+        assert_eq!(
+            result,
+            Some(worktree.root_entry().unwrap().id),
+            "Root level file should return None"
+        );
+
+        // Test 4: Target is root itself, should highlight root
+        let root_entry = worktree.root_entry().unwrap();
+        let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
+        assert_eq!(
+            result,
+            Some(root_entry.id),
+            "Root level file should return None"
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor().clone());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "parent_dir": {
+                "child_file.txt": "",
+                "sibling_file.txt": "",
+                "child_dir": {
+                    "nested_file.txt": ""
+                }
+            },
+            "other_dir": {
+                "other_file.txt": ""
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+    panel.update(cx, |panel, cx| {
+        let project = panel.project.read(cx);
+        let worktree = project.visible_worktrees(cx).next().unwrap();
+        let worktree_id = worktree.read(cx).id();
+        let worktree = worktree.read(cx);
+
+        let parent_dir = worktree.entry_for_path("parent_dir").unwrap();
+        let child_file = worktree
+            .entry_for_path("parent_dir/child_file.txt")
+            .unwrap();
+        let sibling_file = worktree
+            .entry_for_path("parent_dir/sibling_file.txt")
+            .unwrap();
+        let child_dir = worktree.entry_for_path("parent_dir/child_dir").unwrap();
+        let other_dir = worktree.entry_for_path("other_dir").unwrap();
+        let other_file = worktree.entry_for_path("other_dir/other_file.txt").unwrap();
+
+        // Test 1: Single item drag, don't highlight parent directory
+        let dragged_selection = DraggedSelection {
+            active_selection: SelectedEntry {
+                worktree_id,
+                entry_id: child_file.id,
+            },
+            marked_selections: Arc::new(BTreeSet::from([SelectedEntry {
+                worktree_id,
+                entry_id: child_file.id,
+            }])),
+        };
+        let result =
+            panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
+        assert_eq!(result, None, "Should not highlight parent of dragged item");
+
+        // Test 2: Single item drag, don't highlight sibling files
+        let result = panel.highlight_entry_for_selection_drag(
+            sibling_file,
+            worktree,
+            &dragged_selection,
+            cx,
+        );
+        assert_eq!(result, None, "Should not highlight sibling files");
+
+        // Test 3: Single item drag, highlight unrelated directory
+        let result =
+            panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
+        assert_eq!(
+            result,
+            Some(other_dir.id),
+            "Should highlight unrelated directory"
+        );
+
+        // Test 4: Single item drag, highlight sibling directory
+        let result =
+            panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
+        assert_eq!(
+            result,
+            Some(child_dir.id),
+            "Should highlight sibling directory"
+        );
+
+        // Test 5: Multiple items drag, highlight parent directory
+        let dragged_selection = DraggedSelection {
+            active_selection: SelectedEntry {
+                worktree_id,
+                entry_id: child_file.id,
+            },
+            marked_selections: Arc::new(BTreeSet::from([
+                SelectedEntry {
+                    worktree_id,
+                    entry_id: child_file.id,
+                },
+                SelectedEntry {
+                    worktree_id,
+                    entry_id: sibling_file.id,
+                },
+            ])),
+        };
+        let result =
+            panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
+        assert_eq!(
+            result,
+            Some(parent_dir.id),
+            "Should highlight parent with multiple items"
+        );
+
+        // Test 6: Target is file in different directory, highlight parent
+        let result =
+            panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
+        assert_eq!(
+            result,
+            Some(other_dir.id),
+            "Should highlight parent of target file"
+        );
+
+        // Test 7: Target is directory, always highlight
+        let result =
+            panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
+        assert_eq!(
+            result,
+            Some(child_dir.id),
+            "Should always highlight directories"
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_hide_root(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor().clone());
+    fs.insert_tree(
+        "/root1",
+        json!({
+            "dir1": {
+                "file1.txt": "content",
+                "file2.txt": "content",
+            },
+            "dir2": {
+                "file3.txt": "content",
+            },
+            "file4.txt": "content",
+        }),
+    )
+    .await;
+
+    fs.insert_tree(
+        "/root2",
+        json!({
+            "dir3": {
+                "file5.txt": "content",
+            },
+            "file6.txt": "content",
+        }),
+    )
+    .await;
+
+    // Test 1: Single worktree with hide_root = false
+    {
+        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
+        let workspace =
+            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+        cx.update(|_, cx| {
+            let settings = *ProjectPanelSettings::get_global(cx);
+            ProjectPanelSettings::override_global(
+                ProjectPanelSettings {
+                    hide_root: false,
+                    ..settings
+                },
+                cx,
+            );
+        });
+
+        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+        #[rustfmt::skip]
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1",
+                "    > dir1",
+                "    > dir2",
+                "      file4.txt",
+            ],
+            "With hide_root=false and single worktree, root should be visible"
+        );
+    }
+
+    // Test 2: Single worktree with hide_root = true
+    {
+        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
+        let workspace =
+            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+        // Set hide_root to true
+        cx.update(|_, cx| {
+            let settings = *ProjectPanelSettings::get_global(cx);
+            ProjectPanelSettings::override_global(
+                ProjectPanelSettings {
+                    hide_root: true,
+                    ..settings
+                },
+                cx,
+            );
+        });
+
+        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &["> dir1", "> dir2", "  file4.txt",],
+            "With hide_root=true and single worktree, root should be hidden"
+        );
+
+        // Test expanding directories still works without root
+        toggle_expand_dir(&panel, "root1/dir1", cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v dir1  <== selected",
+                "      file1.txt",
+                "      file2.txt",
+                "> dir2",
+                "  file4.txt",
+            ],
+            "Should be able to expand directories even when root is hidden"
+        );
+    }
+
+    // Test 3: Multiple worktrees with hide_root = true
+    {
+        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
+        let workspace =
+            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+        // Set hide_root to true
+        cx.update(|_, cx| {
+            let settings = *ProjectPanelSettings::get_global(cx);
+            ProjectPanelSettings::override_global(
+                ProjectPanelSettings {
+                    hide_root: true,
+                    ..settings
+                },
+                cx,
+            );
+        });
+
+        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1",
+                "    > dir1",
+                "    > dir2",
+                "      file4.txt",
+                "v root2",
+                "    > dir3",
+                "      file6.txt",
+            ],
+            "With hide_root=true and multiple worktrees, roots should still be visible"
+        );
+    }
+
+    // Test 4: Multiple worktrees with hide_root = false
+    {
+        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
+        let workspace =
+            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+        cx.update(|_, cx| {
+            let settings = *ProjectPanelSettings::get_global(cx);
+            ProjectPanelSettings::override_global(
+                ProjectPanelSettings {
+                    hide_root: false,
+                    ..settings
+                },
+                cx,
+            );
+        });
+
+        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1",
+                "    > dir1",
+                "    > dir2",
+                "      file4.txt",
+                "v root2",
+                "    > dir3",
+                "      file6.txt",
+            ],
+            "With hide_root=false and multiple worktrees, roots should be visible"
+        );
+    }
+}
+
 fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
     let path = path.as_ref();
     panel.update(cx, |panel, cx| {

crates/project_symbols/src/project_symbols.rs 🔗

@@ -1,4 +1,4 @@
-use editor::{Bias, Editor, scroll::Autoscroll, styled_runs_for_code_label};
+use editor::{Bias, Editor, SelectionEffects, scroll::Autoscroll, styled_runs_for_code_label};
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
     App, Context, DismissEvent, Entity, FontWeight, ParentElement, StyledText, Task, WeakEntity,
@@ -66,6 +66,7 @@ impl ProjectSymbolsDelegate {
             &self.visible_match_candidates,
             query,
             false,
+            true,
             MAX_MATCHES,
             &Default::default(),
             cx.background_executor().clone(),
@@ -74,6 +75,7 @@ impl ProjectSymbolsDelegate {
             &self.external_match_candidates,
             query,
             false,
+            true,
             MAX_MATCHES - visible_matches.len().min(MAX_MATCHES),
             &Default::default(),
             cx.background_executor().clone(),
@@ -134,12 +136,15 @@ impl PickerDelegate for ProjectSymbolsDelegate {
                         workspace.open_project_item::<Editor>(pane, buffer, true, true, window, cx);
 
                     editor.update(cx, |editor, cx| {
-                        editor.change_selections(Some(Autoscroll::center()), window, cx, |s| {
-                            s.select_ranges([position..position])
-                        });
+                        editor.change_selections(
+                            SelectionEffects::scroll(Autoscroll::center()),
+                            window,
+                            cx,
+                            |s| s.select_ranges([position..position]),
+                        );
                     });
                 })?;
-                Ok::<_, anyhow::Error>(())
+                anyhow::Ok(())
             })
             .detach_and_log_err(cx);
             cx.emit(DismissEvent);
@@ -342,6 +347,7 @@ mod tests {
                             &candidates,
                             &params.query,
                             true,
+                            true,
                             100,
                             &Default::default(),
                             executor.clone(),
@@ -381,7 +387,7 @@ mod tests {
         });
 
         cx.run_until_parked();
-        symbols.update(cx, |symbols, _| {
+        symbols.read_with(cx, |symbols, _| {
             assert_eq!(symbols.delegate.matches.len(), 0);
         });
 
@@ -392,7 +398,7 @@ mod tests {
         });
 
         cx.run_until_parked();
-        symbols.update(cx, |symbols, _| {
+        symbols.read_with(cx, |symbols, _| {
             let delegate = &symbols.delegate;
             assert_eq!(delegate.matches.len(), 2);
             assert_eq!(delegate.matches[0].string, "ton");
@@ -406,7 +412,7 @@ mod tests {
         });
 
         cx.run_until_parked();
-        symbols.update(cx, |symbols, _| {
+        symbols.read_with(cx, |symbols, _| {
             assert_eq!(symbols.delegate.matches.len(), 0);
         });
     }

crates/prompt_store/src/prompt_store.rs 🔗

@@ -1,6 +1,6 @@
 mod prompts;
 
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 use chrono::{DateTime, Utc};
 use collections::HashMap;
 use futures::FutureExt as _;
@@ -266,10 +266,7 @@ impl PromptStore {
         let bodies = self.bodies;
         cx.background_spawn(async move {
             let txn = env.read_txn()?;
-            let mut prompt = bodies
-                .get(&txn, &id)?
-                .ok_or_else(|| anyhow!("prompt not found"))?
-                .into();
+            let mut prompt = bodies.get(&txn, &id)?.context("prompt not found")?.into();
             LineEnding::normalize(&mut prompt);
             Ok(prompt)
         })
@@ -359,6 +356,7 @@ impl PromptStore {
                     &candidates,
                     &query,
                     false,
+                    true,
                     100,
                     &cancellation_flag,
                     executor,

crates/prompt_store/src/prompts.rs 🔗

@@ -74,6 +74,7 @@ pub struct UserRulesContext {
 #[derive(Debug, Clone, Serialize)]
 pub struct WorktreeContext {
     pub root_name: String,
+    pub abs_path: Arc<Path>,
     pub rules_file: Option<RulesFileContext>,
 }
 
@@ -455,6 +456,7 @@ mod test {
     fn test_assistant_system_prompt_renders() {
         let worktrees = vec![WorktreeContext {
             root_name: "path".into(),
+            abs_path: Path::new("/path/to/root").into(),
             rules_file: Some(RulesFileContext {
                 path_in_worktree: Path::new(".rules").into(),
                 text: "".into(),
@@ -484,6 +486,7 @@ mod test {
     fn test_assistant_system_prompt_depends_on_enabled_tools() {
         let worktrees = vec![WorktreeContext {
             root_name: "path".into(),
+            abs_path: Path::new("/path/to/root").into(),
             rules_file: None,
         }];
         let default_user_rules = vec![];

crates/proto/build.rs 🔗

@@ -3,7 +3,6 @@ fn main() {
     build
         .type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")
         .type_attribute("ProjectPath", "#[derive(Hash, Eq)]")
-        .type_attribute("Breakpoint", "#[derive(Hash, Eq)]")
         .type_attribute("Anchor", "#[derive(Hash, Eq)]")
         .compile_protos(&["proto/zed.proto"], &["proto"])
         .unwrap();

crates/proto/proto/app.proto 🔗

@@ -27,6 +27,8 @@ message UpdateUserPlan {
     optional bool is_usage_based_billing_enabled = 3;
     optional SubscriptionUsage usage = 4;
     optional SubscriptionPeriod subscription_period = 5;
+    optional bool account_too_young = 6;
+    optional bool has_overdue_invoices = 7;
 }
 
 message SubscriptionPeriod {

crates/proto/proto/buffer.proto 🔗

@@ -251,6 +251,14 @@ message Diagnostic {
     Anchor start = 1;
     Anchor end = 2;
     optional string source = 3;
+
+    enum SourceKind {
+        Pulled = 0;
+        Pushed = 1;
+        Other = 2;
+    }
+
+    SourceKind source_kind = 16;
     Severity severity = 4;
     string message = 5;
     optional string code = 6;
@@ -261,6 +269,7 @@ message Diagnostic {
 
     bool is_disk_based = 10;
     bool is_unnecessary = 11;
+    bool underline = 15;
 
     enum Severity {
         None = 0;

crates/proto/proto/call.proto 🔗

@@ -189,6 +189,8 @@ message UpdateProject {
 
 message JoinProject {
     uint64 project_id = 1;
+    optional string committer_email = 2;
+    optional string committer_name = 3;
 }
 
 message JoinProjectResponse {

crates/proto/proto/channel.proto 🔗

@@ -8,6 +8,7 @@ message Channel {
     uint64 id = 1;
     string name = 2;
     ChannelVisibility visibility = 3;
+    int32 channel_order = 4;
     repeated uint64 parent_path = 5;
 }
 
@@ -207,6 +208,15 @@ message MoveChannel {
     uint64 to = 2;
 }
 
+message ReorderChannel {
+    uint64 channel_id = 1;
+    enum Direction {
+        Up = 0;
+        Down = 1;
+    }
+    Direction direction = 2;
+}
+
 message JoinChannelBuffer {
     uint64 channel_id = 1;
 }

crates/proto/proto/core.proto 🔗

@@ -7,10 +7,10 @@ message PeerId {
 }
 
 message User {
+    reserved 4;
     uint64 id = 1;
     string github_login = 2;
     string avatar_url = 3;
-    optional string email = 4;
     optional string name = 5;
 }
 
@@ -24,4 +24,6 @@ message Collaborator {
     uint32 replica_id = 2;
     uint64 user_id = 3;
     bool is_host = 4;
+    optional string committer_name = 5;
+    optional string committer_email = 6;
 }

crates/proto/proto/debugger.proto 🔗

@@ -16,6 +16,12 @@ message Breakpoint {
     optional string message = 4;
     optional string condition = 5;
     optional string hit_condition = 6;
+    map<uint64, BreakpointSessionState> session_state = 7;
+}
+
+message BreakpointSessionState {
+    uint64 id = 1;
+    bool verified = 2;
 }
 
 message BreakpointsForFile {
@@ -30,63 +36,6 @@ message ToggleBreakpoint {
     Breakpoint breakpoint = 3;
 }
 
-enum DebuggerThreadItem {
-    Console = 0;
-    LoadedSource = 1;
-    Modules = 2;
-    Variables = 3;
-}
-
-message DebuggerSetVariableState {
-    string name = 1;
-    DapScope scope = 2;
-    string value = 3;
-    uint64 stack_frame_id = 4;
-    optional string evaluate_name = 5;
-    uint64 parent_variables_reference = 6;
-}
-
-message VariableListOpenEntry {
-    oneof entry {
-        DebuggerOpenEntryScope scope = 1;
-        DebuggerOpenEntryVariable variable = 2;
-    }
-}
-
-message DebuggerOpenEntryScope {
-    string name = 1;
-}
-
-message DebuggerOpenEntryVariable {
-    string scope_name = 1;
-    string name = 2;
-    uint64 depth = 3;
-}
-
-message VariableListEntrySetState {
-    uint64 depth = 1;
-    DebuggerSetVariableState state = 2;
-}
-
-message VariableListEntryVariable {
-    uint64 depth = 1;
-    DapScope scope = 2;
-    DapVariable variable = 3;
-    bool has_children = 4;
-    uint64 container_reference = 5;
-}
-
-message DebuggerScopeVariableIndex {
-    repeated uint64 fetched_ids = 1;
-    repeated DebuggerVariableContainer variables = 2;
-}
-
-message DebuggerVariableContainer {
-    uint64 container_reference = 1;
-    DapVariable variable = 2;
-    uint64 depth = 3;
-}
-
 enum DapThreadStatus {
     Running = 0;
     Stopped = 1;
@@ -94,18 +43,6 @@ enum DapThreadStatus {
     Ended = 3;
 }
 
-message VariableListScopes {
-    uint64 stack_frame_id = 1;
-    repeated DapScope scopes = 2;
-}
-
-message VariableListVariables {
-    uint64 stack_frame_id = 1;
-    uint64 scope_id = 2;
-    DebuggerScopeVariableIndex variables = 3;
-}
-
-
 enum VariablesArgumentsFilter {
     Indexed = 0;
     Named = 1;
@@ -524,13 +461,8 @@ message DapModule {
 message DebugTaskDefinition {
     string adapter = 1;
     string label = 2;
-    oneof request {
-        DebugLaunchRequest debug_launch_request = 3;
-        DebugAttachRequest debug_attach_request = 4;
-    }
-    optional string initialize_args = 5;
-    optional TcpHost tcp_connection = 6;
-    optional bool stop_on_entry = 7;
+    string config = 3;
+    optional TcpHost tcp_connection = 4;
 }
 
 message TcpHost {
@@ -561,10 +493,11 @@ message GetDebugAdapterBinary {
     uint64 project_id = 1;
     uint64 session_id = 3;
     DebugTaskDefinition definition = 2;
+    uint64 worktree_id = 4;
 }
 
 message DebugAdapterBinary {
-    string command = 1;
+    optional string command = 1;
     repeated string arguments = 2;
     map<string, string> envs = 3;
     optional string cwd = 4;

crates/proto/proto/git.proto 🔗

@@ -326,6 +326,7 @@ message Fetch {
     reserved 2;
     uint64 repository_id = 3;
     uint64 askpass_id = 4;
+    optional string remote = 5;
 }
 
 message GetRemotes {

crates/proto/proto/lsp.proto 🔗

@@ -195,6 +195,8 @@ message LspExtGoToParentModuleResponse {
 message GetCompletionsResponse {
     repeated Completion completions = 1;
     repeated VectorClockEntry version = 2;
+    // `!is_complete`, inverted for a default of `is_complete = true`
+    bool can_reuse = 3;
 }
 
 message ApplyCompletionAdditionalEdits {
@@ -532,12 +534,15 @@ message DiagnosticSummary {
 message UpdateLanguageServer {
     uint64 project_id = 1;
     uint64 language_server_id = 2;
+    optional string server_name = 8;
     oneof variant {
         LspWorkStart work_start = 3;
         LspWorkProgress work_progress = 4;
         LspWorkEnd work_end = 5;
         LspDiskBasedDiagnosticsUpdating disk_based_diagnostics_updating = 6;
         LspDiskBasedDiagnosticsUpdated disk_based_diagnostics_updated = 7;
+        StatusUpdate status_update = 9;
+        RegisteredForBuffer registered_for_buffer = 10;
     }
 }
 
@@ -564,6 +569,34 @@ message LspDiskBasedDiagnosticsUpdating {}
 
 message LspDiskBasedDiagnosticsUpdated {}
 
+message StatusUpdate {
+    optional string message = 1;
+    oneof status {
+        ServerBinaryStatus binary = 2;
+        ServerHealth health = 3;
+    }
+}
+
+enum ServerHealth {
+    OK = 0;
+    WARNING = 1;
+    ERROR = 2;
+}
+
+enum ServerBinaryStatus {
+    NONE = 0;
+    CHECKING_FOR_UPDATE = 1;
+    DOWNLOADING = 2;
+    STARTING = 3;
+    STOPPING = 4;
+    STOPPED = 5;
+    FAILED = 6;
+}
+
+message RegisteredForBuffer {
+    string buffer_abs_path = 1;
+}
+
 message LanguageServerLog {
     uint64 project_id = 1;
     uint64 language_server_id = 2;
@@ -591,6 +624,7 @@ message ApplyCodeActionKindResponse {
 message RegisterBufferWithLanguageServers {
     uint64 project_id = 1;
     uint64 buffer_id = 2;
+    repeated LanguageServerSelector only_servers = 3;
 }
 
 enum FormatTrigger {
@@ -664,6 +698,51 @@ message LanguageServerPromptResponse {
     optional uint64 action_response = 1;
 }
 
+message GetDocumentColor {
+    uint64 project_id = 1;
+    uint64 buffer_id = 2;
+    repeated VectorClockEntry version = 3;
+
+}
+
+message GetDocumentColorResponse {
+    repeated ColorInformation colors = 1;
+    repeated VectorClockEntry version = 2;
+
+}
+
+message ColorInformation {
+    PointUtf16 lsp_range_start = 1;
+    PointUtf16 lsp_range_end = 2;
+    float red = 3;
+    float green = 4;
+    float blue = 5;
+    float alpha = 6;
+}
+
+message GetColorPresentation {
+    uint64 project_id = 1;
+    uint64 buffer_id = 2;
+    ColorInformation color = 3;
+    uint64 server_id = 4;
+}
+
+message GetColorPresentationResponse {
+    repeated ColorPresentation presentations = 1;
+}
+
+message ColorPresentation {
+    string label = 1;
+    optional TextEdit text_edit = 2;
+    repeated TextEdit additional_text_edits = 3;
+}
+
+message TextEdit {
+    string new_text = 1;
+    PointUtf16 lsp_range_start = 2;
+    PointUtf16 lsp_range_end = 3;
+}
+
 message MultiLspQuery {
     uint64 project_id = 1;
     uint64 buffer_id = 2;
@@ -676,19 +755,32 @@ message MultiLspQuery {
         GetCodeActions get_code_actions = 6;
         GetSignatureHelp get_signature_help = 7;
         GetCodeLens get_code_lens = 8;
+        GetDocumentDiagnostics get_document_diagnostics = 9;
+        GetDocumentColor get_document_color = 10;
     }
 }
 
 message AllLanguageServers {}
 
+message LanguageServerSelector {
+    oneof selector {
+        uint64 server_id = 1;
+        string name = 2;
+    }
+}
+
 message RestartLanguageServers {
     uint64 project_id = 1;
     repeated uint64 buffer_ids = 2;
+    repeated LanguageServerSelector only_servers = 3;
+    bool all = 4;
 }
 
 message StopLanguageServers {
     uint64 project_id = 1;
     repeated uint64 buffer_ids = 2;
+    repeated LanguageServerSelector also_servers = 3;
+    bool all = 4;
 }
 
 message MultiLspQueryResponse {
@@ -701,7 +793,10 @@ message LspResponse {
         GetCodeActionsResponse get_code_actions_response = 2;
         GetSignatureHelpResponse get_signature_help_response = 3;
         GetCodeLensResponse get_code_lens_response = 4;
+        GetDocumentDiagnosticsResponse get_document_diagnostics_response = 5;
+        GetDocumentColorResponse get_document_color_response = 6;
     }
+    uint64 server_id = 7;
 }
 
 message LanguageServerIdForName {
@@ -747,3 +842,60 @@ message LspExtClearFlycheck {
     uint64 buffer_id = 2;
     uint64 language_server_id = 3;
 }
+
+message LspDiagnosticRelatedInformation {
+    optional string location_url = 1;
+    PointUtf16 location_range_start = 2;
+    PointUtf16 location_range_end = 3;
+    string message = 4;
+}
+
+enum LspDiagnosticTag {
+    None = 0;
+    Unnecessary = 1;
+    Deprecated = 2;
+}
+
+message LspDiagnostic {
+    PointUtf16 start = 1;
+    PointUtf16 end = 2;
+    Severity severity = 3;
+    optional string code = 4;
+    optional string code_description = 5;
+    optional string source = 6;
+    string message = 7;
+    repeated LspDiagnosticRelatedInformation related_information = 8;
+    repeated LspDiagnosticTag tags = 9;
+    optional string data = 10;
+
+    enum Severity {
+        None = 0;
+        Error = 1;
+        Warning = 2;
+        Information = 3;
+        Hint = 4;
+    }
+}
+
+message GetDocumentDiagnostics {
+    uint64 project_id = 1;
+    uint64 buffer_id = 2;
+    repeated VectorClockEntry version = 3;
+}
+
+message GetDocumentDiagnosticsResponse {
+    repeated PulledDiagnostics pulled_diagnostics = 1;
+}
+
+message PulledDiagnostics {
+    uint64 server_id = 1;
+    string uri = 2;
+    optional string result_id = 3;
+    bool changed = 4;
+    repeated LspDiagnostic diagnostics = 5;
+}
+
+message PullWorkspaceDiagnostics {
+    uint64 project_id = 1;
+    uint64 server_id = 2;
+}

crates/proto/proto/toolchain.proto 🔗

@@ -23,6 +23,7 @@ message ListToolchainsResponse {
     repeated Toolchain toolchains = 1;
     bool has_values = 2;
     repeated ToolchainGroup groups = 3;
+    optional string relative_worktree_path = 4;
 }
 
 message ActivateToolchain {

crates/proto/proto/zed.proto 🔗

@@ -190,6 +190,7 @@ message Envelope {
         GetChannelMessagesById get_channel_messages_by_id = 144;
 
         MoveChannel move_channel = 147;
+        ReorderChannel reorder_channel = 349;
         SetChannelVisibility set_channel_visibility = 148;
 
         AddNotification add_notification = 149;
@@ -386,7 +387,17 @@ message Envelope {
         LspExtRunFlycheck lsp_ext_run_flycheck = 346;
         LspExtClearFlycheck lsp_ext_clear_flycheck = 347;
 
-        LogToDebugConsole log_to_debug_console = 348; // current max
+        LogToDebugConsole log_to_debug_console = 348;
+
+        GetDocumentDiagnostics get_document_diagnostics = 350;
+        GetDocumentDiagnosticsResponse get_document_diagnostics_response = 351;
+        PullWorkspaceDiagnostics pull_workspace_diagnostics = 352;
+
+        GetDocumentColor get_document_color = 353;
+        GetDocumentColorResponse get_document_color_response = 354;
+        GetColorPresentation get_color_presentation = 355;
+        GetColorPresentationResponse get_color_presentation_response = 356; // current max
+
     }
 
     reserved 87 to 88;

crates/proto/src/error.rs 🔗

@@ -124,7 +124,7 @@ impl ErrorExt for anyhow::Error {
         if let Some(rpc_error) = self.downcast_ref::<RpcError>() {
             rpc_error.cloned()
         } else {
-            anyhow::anyhow!("{}", self)
+            anyhow::anyhow!("{self}")
         }
     }
 }
@@ -134,7 +134,7 @@ impl From<ErrorCode> for anyhow::Error {
         RpcError {
             request: None,
             code: value,
-            msg: format!("{:?}", value).to_string(),
+            msg: format!("{:?}", value),
             tags: Default::default(),
         }
         .into()
@@ -241,7 +241,7 @@ impl From<ErrorCode> for RpcError {
         RpcError {
             request: None,
             code,
-            msg: format!("{:?}", code).to_string(),
+            msg: format!("{:?}", code),
             tags: Default::default(),
         }
     }

crates/proto/src/proto.rs 🔗

@@ -176,6 +176,7 @@ messages!(
     (LspExtClearFlycheck, Background),
     (MarkNotificationRead, Foreground),
     (MoveChannel, Foreground),
+    (ReorderChannel, Foreground),
     (MultiLspQuery, Background),
     (MultiLspQueryResponse, Background),
     (OnTypeFormatting, Background),
@@ -220,6 +221,10 @@ messages!(
     (ResolveCompletionDocumentationResponse, Background),
     (ResolveInlayHint, Background),
     (ResolveInlayHintResponse, Background),
+    (GetDocumentColor, Background),
+    (GetDocumentColorResponse, Background),
+    (GetColorPresentation, Background),
+    (GetColorPresentationResponse, Background),
     (RefreshCodeLens, Background),
     (GetCodeLens, Background),
     (GetCodeLensResponse, Background),
@@ -306,6 +311,9 @@ messages!(
     (RunDebugLocators, Background),
     (DebugRequest, Background),
     (LogToDebugConsole, Background),
+    (GetDocumentDiagnostics, Background),
+    (GetDocumentDiagnosticsResponse, Background),
+    (PullWorkspaceDiagnostics, Background)
 );
 
 request_messages!(
@@ -389,12 +397,15 @@ request_messages!(
     (RemoveContact, Ack),
     (RenameChannel, RenameChannelResponse),
     (RenameProjectEntry, ProjectEntryResponse),
+    (ReorderChannel, Ack),
     (RequestContact, Ack),
     (
         ResolveCompletionDocumentation,
         ResolveCompletionDocumentationResponse
     ),
     (ResolveInlayHint, ResolveInlayHintResponse),
+    (GetDocumentColor, GetDocumentColorResponse),
+    (GetColorPresentation, GetColorPresentationResponse),
     (RespondToChannelInvite, Ack),
     (RespondToContactRequest, Ack),
     (SaveBuffer, BufferSaved),
@@ -467,6 +478,8 @@ request_messages!(
     (ToggleBreakpoint, Ack),
     (GetDebugAdapterBinary, DebugAdapterBinary),
     (RunDebugLocators, DebugRequest),
+    (GetDocumentDiagnostics, GetDocumentDiagnosticsResponse),
+    (PullWorkspaceDiagnostics, Ack)
 );
 
 entity_messages!(
@@ -480,9 +493,11 @@ entity_messages!(
     BufferSaved,
     CloseBuffer,
     Commit,
+    GetColorPresentation,
     CopyProjectEntry,
     CreateBufferForPeer,
     CreateProjectEntry,
+    GetDocumentColor,
     DeleteProjectEntry,
     ExpandProjectEntry,
     ExpandAllForProjectEntry,
@@ -593,6 +608,8 @@ entity_messages!(
     RunDebugLocators,
     GetDebugAdapterBinary,
     LogToDebugConsole,
+    GetDocumentDiagnostics,
+    PullWorkspaceDiagnostics
 );
 
 entity_messages!(
@@ -615,7 +632,7 @@ impl From<Timestamp> for SystemTime {
 
 impl From<SystemTime> for Timestamp {
     fn from(time: SystemTime) -> Self {
-        let duration = time.duration_since(UNIX_EPOCH).unwrap();
+        let duration = time.duration_since(UNIX_EPOCH).unwrap_or_default();
         Self {
             seconds: duration.as_secs(),
             nanos: duration.subsec_nanos(),

crates/proto/src/typed_envelope.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{Envelope, PeerId};
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use serde::Serialize;
 use std::{
     any::{Any, TypeId},
@@ -201,7 +201,7 @@ pub struct TypedEnvelope<T> {
 impl<T> TypedEnvelope<T> {
     pub fn original_sender_id(&self) -> Result<PeerId> {
         self.original_sender_id
-            .ok_or_else(|| anyhow!("missing original_sender_id"))
+            .context("missing original_sender_id")
     }
 }
 

crates/recent_projects/src/recent_projects.rs 🔗

@@ -1,6 +1,8 @@
 pub mod disconnected_overlay;
 mod remote_servers;
+mod ssh_config;
 mod ssh_connections;
+
 pub use ssh_connections::{is_connecting_over_ssh, open_ssh_project};
 
 use disconnected_overlay::DisconnectedOverlay;
@@ -25,14 +27,43 @@ use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_con
 use util::{ResultExt, paths::PathExt};
 use workspace::{
     CloseIntent, HistoryManager, ModalView, OpenOptions, SerializedWorkspaceLocation, WORKSPACE_DB,
-    Workspace, WorkspaceId,
+    Workspace, WorkspaceId, with_active_or_new_workspace,
 };
 use zed_actions::{OpenRecent, OpenRemote};
 
 pub fn init(cx: &mut App) {
     SshSettings::register(cx);
-    cx.observe_new(RecentProjects::register).detach();
-    cx.observe_new(RemoteServerProjects::register).detach();
+    cx.on_action(|open_recent: &OpenRecent, cx| {
+        let create_new_window = open_recent.create_new_window;
+        with_active_or_new_workspace(cx, move |workspace, window, cx| {
+            let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
+                RecentProjects::open(workspace, create_new_window, window, cx);
+                return;
+            };
+
+            recent_projects.update(cx, |recent_projects, cx| {
+                recent_projects
+                    .picker
+                    .update(cx, |picker, cx| picker.cycle_selection(window, cx))
+            });
+        });
+    });
+    cx.on_action(|open_remote: &OpenRemote, cx| {
+        let from_existing_connection = open_remote.from_existing_connection;
+        let create_new_window = open_remote.create_new_window;
+        with_active_or_new_workspace(cx, move |workspace, window, cx| {
+            if from_existing_connection {
+                cx.propagate();
+                return;
+            }
+            let handle = cx.entity().downgrade();
+            let fs = workspace.project().read(cx).fs().clone();
+            workspace.toggle_modal(window, cx, |window, cx| {
+                RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
+            })
+        });
+    });
+
     cx.observe_new(DisconnectedOverlay::register).detach();
 }
 
@@ -84,25 +115,6 @@ impl RecentProjects {
         }
     }
 
-    fn register(
-        workspace: &mut Workspace,
-        _window: Option<&mut Window>,
-        _cx: &mut Context<Workspace>,
-    ) {
-        workspace.register_action(|workspace, open_recent: &OpenRecent, window, cx| {
-            let Some(recent_projects) = workspace.active_modal::<Self>(cx) else {
-                Self::open(workspace, open_recent.create_new_window, window, cx);
-                return;
-            };
-
-            recent_projects.update(cx, |recent_projects, cx| {
-                recent_projects
-                    .picker
-                    .update(cx, |picker, cx| picker.cycle_selection(window, cx))
-            });
-        });
-    }
-
     pub fn open(
         workspace: &mut Workspace,
         create_new_window: bool,
@@ -239,6 +251,7 @@ impl PickerDelegate for RecentProjectsDelegate {
             candidates.as_slice(),
             query,
             smart_case,
+            true,
             100,
             &Default::default(),
             cx.background_executor().clone(),
@@ -466,9 +479,23 @@ impl PickerDelegate for RecentProjectsDelegate {
                 .border_color(cx.theme().colors().border_variant)
                 .child(
                     Button::new("remote", "Open Remote Folder")
-                        .key_binding(KeyBinding::for_action(&OpenRemote, window, cx))
+                        .key_binding(KeyBinding::for_action(
+                            &OpenRemote {
+                                from_existing_connection: false,
+                                create_new_window: false,
+                            },
+                            window,
+                            cx,
+                        ))
                         .on_click(|_, window, cx| {
-                            window.dispatch_action(OpenRemote.boxed_clone(), cx)
+                            window.dispatch_action(
+                                OpenRemote {
+                                    from_existing_connection: false,
+                                    create_new_window: false,
+                                }
+                                .boxed_clone(),
+                                cx,
+                            )
                         }),
                 )
                 .child(

crates/recent_projects/src/remote_servers.rs 🔗

@@ -1,14 +1,21 @@
 use std::any::Any;
+use std::borrow::Cow;
 use std::collections::BTreeSet;
 use std::path::PathBuf;
+use std::rc::Rc;
 use std::sync::Arc;
+use std::sync::atomic;
+use std::sync::atomic::AtomicUsize;
 
 use editor::Editor;
 use file_finder::OpenPathDelegate;
 use futures::FutureExt;
 use futures::channel::oneshot;
 use futures::future::Shared;
+use futures::select;
+use gpui::ClickEvent;
 use gpui::ClipboardItem;
+use gpui::Subscription;
 use gpui::Task;
 use gpui::WeakEntity;
 use gpui::canvas;
@@ -16,13 +23,19 @@ use gpui::{
     AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
     PromptLevel, ScrollHandle, Window,
 };
+use paths::global_ssh_config_file;
+use paths::user_ssh_config_file;
 use picker::Picker;
+use project::Fs;
 use project::Project;
 use remote::SshConnectionOptions;
 use remote::SshRemoteClient;
 use remote::ssh_session::ConnectionIdentifier;
 use settings::Settings;
+use settings::SettingsStore;
 use settings::update_settings_file;
+use settings::watch_config_file;
+use smol::stream::StreamExt as _;
 use ui::Navigable;
 use ui::NavigableEntry;
 use ui::{
@@ -38,7 +51,7 @@ use workspace::{
     open_ssh_project_with_existing_connection,
 };
 
-use crate::OpenRemote;
+use crate::ssh_config::parse_ssh_config_hosts;
 use crate::ssh_connections::RemoteSettingsContent;
 use crate::ssh_connections::SshConnection;
 use crate::ssh_connections::SshConnectionHeader;
@@ -55,6 +68,10 @@ pub struct RemoteServerProjects {
     focus_handle: FocusHandle,
     workspace: WeakEntity<Workspace>,
     retained_connections: Vec<Entity<SshRemoteClient>>,
+    ssh_config_updates: Task<()>,
+    ssh_config_servers: BTreeSet<SharedString>,
+    create_new_window: bool,
+    _subscription: Subscription,
 }
 
 struct CreateRemoteServer {
@@ -121,6 +138,7 @@ impl Focusable for ProjectPicker {
 
 impl ProjectPicker {
     fn new(
+        create_new_window: bool,
         ix: usize,
         connection: SshConnectionOptions,
         project: Entity<Project>,
@@ -131,7 +149,7 @@ impl ProjectPicker {
     ) -> Entity<Self> {
         let (tx, rx) = oneshot::channel();
         let lister = project::DirectoryLister::Project(project.clone());
-        let delegate = file_finder::OpenPathDelegate::new(tx, lister);
+        let delegate = file_finder::OpenPathDelegate::new(tx, lister, false);
 
         let picker = cx.new(|cx| {
             let picker = Picker::uniform_list(delegate, window, cx)
@@ -149,9 +167,16 @@ impl ProjectPicker {
                     let Ok(Some(paths)) = rx.await else {
                         workspace
                             .update_in(cx, |workspace, window, cx| {
+                                let fs = workspace.project().read(cx).fs().clone();
                                 let weak = cx.entity().downgrade();
                                 workspace.toggle_modal(window, cx, |window, cx| {
-                                    RemoteServerProjects::new(window, cx, weak)
+                                    RemoteServerProjects::new(
+                                        create_new_window,
+                                        fs,
+                                        window,
+                                        weak,
+                                        cx,
+                                    )
                                 });
                             })
                             .log_err()?;
@@ -159,7 +184,7 @@ impl ProjectPicker {
                     };
 
                     let app_state = workspace
-                        .update(cx, |workspace, _| workspace.app_state().clone())
+                        .read_with(cx, |workspace, _| workspace.app_state().clone())
                         .ok()?;
 
                     cx.update(|_, cx| {
@@ -238,25 +263,52 @@ impl gpui::Render for ProjectPicker {
 }
 
 #[derive(Clone)]
-struct ProjectEntry {
-    open_folder: NavigableEntry,
-    projects: Vec<(NavigableEntry, SshProject)>,
-    configure: NavigableEntry,
-    connection: SshConnection,
+enum RemoteEntry {
+    Project {
+        open_folder: NavigableEntry,
+        projects: Vec<(NavigableEntry, SshProject)>,
+        configure: NavigableEntry,
+        connection: SshConnection,
+    },
+    SshConfig {
+        open_folder: NavigableEntry,
+        host: SharedString,
+    },
+}
+
+impl RemoteEntry {
+    fn is_from_zed(&self) -> bool {
+        matches!(self, Self::Project { .. })
+    }
+
+    fn connection(&self) -> Cow<'_, SshConnection> {
+        match self {
+            Self::Project { connection, .. } => Cow::Borrowed(connection),
+            Self::SshConfig { host, .. } => Cow::Owned(SshConnection {
+                host: host.clone(),
+                ..SshConnection::default()
+            }),
+        }
+    }
 }
 
 #[derive(Clone)]
 struct DefaultState {
     scrollbar: ScrollbarState,
     add_new_server: NavigableEntry,
-    servers: Vec<ProjectEntry>,
+    servers: Vec<RemoteEntry>,
 }
+
 impl DefaultState {
-    fn new(cx: &mut App) -> Self {
+    fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
         let handle = ScrollHandle::new();
         let scrollbar = ScrollbarState::new(handle.clone());
         let add_new_server = NavigableEntry::new(&handle, cx);
-        let servers = SshSettings::get_global(cx)
+
+        let ssh_settings = SshSettings::get_global(cx);
+        let read_ssh_config = ssh_settings.read_ssh_config;
+
+        let mut servers: Vec<RemoteEntry> = ssh_settings
             .ssh_connections()
             .map(|connection| {
                 let open_folder = NavigableEntry::new(&handle, cx);
@@ -266,7 +318,7 @@ impl DefaultState {
                     .iter()
                     .map(|project| (NavigableEntry::new(&handle, cx), project.clone()))
                     .collect();
-                ProjectEntry {
+                RemoteEntry::Project {
                     open_folder,
                     configure,
                     projects,
@@ -274,6 +326,22 @@ impl DefaultState {
                 }
             })
             .collect();
+
+        if read_ssh_config {
+            let mut extra_servers_from_config = ssh_config_servers.clone();
+            for server in &servers {
+                if let RemoteEntry::Project { connection, .. } = server {
+                    extra_servers_from_config.remove(&connection.host);
+                }
+            }
+            servers.extend(extra_servers_from_config.into_iter().map(|host| {
+                RemoteEntry::SshConfig {
+                    open_folder: NavigableEntry::new(&handle, cx),
+                    host,
+                }
+            }));
+        }
+
         Self {
             scrollbar,
             add_new_server,
@@ -297,35 +365,25 @@ enum Mode {
 }
 
 impl Mode {
-    fn default_mode(cx: &mut App) -> Self {
-        Self::Default(DefaultState::new(cx))
+    fn default_mode(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
+        Self::Default(DefaultState::new(ssh_config_servers, cx))
     }
 }
 impl RemoteServerProjects {
-    pub fn register(
-        workspace: &mut Workspace,
-        _window: Option<&mut Window>,
-        _: &mut Context<Workspace>,
-    ) {
-        workspace.register_action(|workspace, _: &OpenRemote, window, cx| {
-            let handle = cx.entity().downgrade();
-            workspace.toggle_modal(window, cx, |window, cx| Self::new(window, cx, handle))
-        });
-    }
-
-    pub fn open(workspace: Entity<Workspace>, window: &mut Window, cx: &mut App) {
-        workspace.update(cx, |workspace, cx| {
-            let handle = cx.entity().downgrade();
-            workspace.toggle_modal(window, cx, |window, cx| Self::new(window, cx, handle))
-        })
-    }
-
     pub fn new(
+        create_new_window: bool,
+        fs: Arc<dyn Fs>,
         window: &mut Window,
-        cx: &mut Context<Self>,
         workspace: WeakEntity<Workspace>,
+        cx: &mut Context<Self>,
     ) -> Self {
         let focus_handle = cx.focus_handle();
+        let mut read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
+        let ssh_config_updates = if read_ssh_config {
+            spawn_ssh_config_watch(fs.clone(), cx)
+        } else {
+            Task::ready(())
+        };
 
         let mut base_style = window.text_style();
         base_style.refine(&gpui::TextStyleRefinement {
@@ -333,15 +391,34 @@ impl RemoteServerProjects {
             ..Default::default()
         });
 
+        let _subscription =
+            cx.observe_global_in::<SettingsStore>(window, move |recent_projects, _, cx| {
+                let new_read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
+                if read_ssh_config != new_read_ssh_config {
+                    read_ssh_config = new_read_ssh_config;
+                    if read_ssh_config {
+                        recent_projects.ssh_config_updates = spawn_ssh_config_watch(fs.clone(), cx);
+                    } else {
+                        recent_projects.ssh_config_servers.clear();
+                        recent_projects.ssh_config_updates = Task::ready(());
+                    }
+                }
+            });
+
         Self {
-            mode: Mode::default_mode(cx),
+            mode: Mode::default_mode(&BTreeSet::new(), cx),
             focus_handle,
             workspace,
             retained_connections: Vec::new(),
+            ssh_config_updates,
+            ssh_config_servers: BTreeSet::new(),
+            create_new_window,
+            _subscription,
         }
     }
 
     pub fn project_picker(
+        create_new_window: bool,
         ix: usize,
         connection_options: remote::SshConnectionOptions,
         project: Entity<Project>,
@@ -350,8 +427,10 @@ impl RemoteServerProjects {
         cx: &mut Context<Self>,
         workspace: WeakEntity<Workspace>,
     ) -> Self {
-        let mut this = Self::new(window, cx, workspace.clone());
+        let fs = project.read(cx).fs().clone();
+        let mut this = Self::new(create_new_window, fs, window, workspace.clone(), cx);
         this.mode = Mode::ProjectPicker(ProjectPicker::new(
+            create_new_window,
             ix,
             connection_options,
             project,
@@ -400,14 +479,15 @@ impl RemoteServerProjects {
         .prompt_err("Failed to connect", window, cx, |_, _, _| None);
 
         let address_editor = editor.clone();
-        let creating = cx.spawn(async move |this, cx| {
+        let creating = cx.spawn_in(window, async move |this, cx| {
             match connection.await {
                 Some(Some(client)) => this
-                    .update(cx, |this, cx| {
+                    .update_in(cx, |this, window, cx| {
                         telemetry::event!("SSH Server Created");
                         this.retained_connections.push(client);
                         this.add_ssh_server(connection_options, cx);
-                        this.mode = Mode::default_mode(cx);
+                        this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
+                        this.focus_handle(cx).focus(window);
                         cx.notify()
                     })
                     .log_err(),
@@ -466,6 +546,7 @@ impl RemoteServerProjects {
             return;
         };
 
+        let create_new_window = self.create_new_window;
         let connection_options = ssh_connection.into();
         workspace.update(cx, |_, cx| {
             cx.defer_in(window, move |workspace, window, cx| {
@@ -501,8 +582,9 @@ impl RemoteServerProjects {
                     let Some(Some(session)) = session else {
                         return workspace.update_in(cx, |workspace, window, cx| {
                             let weak = cx.entity().downgrade();
+                            let fs = workspace.project().read(cx).fs().clone();
                             workspace.toggle_modal(window, cx, |window, cx| {
-                                RemoteServerProjects::new(window, cx, weak)
+                                RemoteServerProjects::new(create_new_window, fs, window, weak, cx)
                             });
                         });
                     };
@@ -530,6 +612,7 @@ impl RemoteServerProjects {
                             let weak = cx.entity().downgrade();
                             workspace.toggle_modal(window, cx, |window, cx| {
                                 RemoteServerProjects::project_picker(
+                                    create_new_window,
                                     ix,
                                     connection_options,
                                     project,
@@ -572,7 +655,7 @@ impl RemoteServerProjects {
                         }
                     }
                 });
-                self.mode = Mode::default_mode(cx);
+                self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
                 self.focus_handle.focus(window);
             }
         }
@@ -592,7 +675,7 @@ impl RemoteServerProjects {
                 cx.notify();
             }
             _ => {
-                self.mode = Mode::default_mode(cx);
+                self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
                 self.focus_handle(cx).focus(window);
                 cx.notify();
             }
@@ -602,16 +685,16 @@ impl RemoteServerProjects {
     fn render_ssh_connection(
         &mut self,
         ix: usize,
-        ssh_server: ProjectEntry,
+        ssh_server: RemoteEntry,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
-        let (main_label, aux_label) = if let Some(nickname) = ssh_server.connection.nickname.clone()
-        {
-            let aux_label = SharedString::from(format!("({})", ssh_server.connection.host));
+        let connection = ssh_server.connection().into_owned();
+        let (main_label, aux_label) = if let Some(nickname) = connection.nickname.clone() {
+            let aux_label = SharedString::from(format!("({})", connection.host));
             (nickname.into(), Some(aux_label))
         } else {
-            (ssh_server.connection.host.clone(), None)
+            (connection.host.clone(), None)
         };
         v_flex()
             .w_full()
@@ -637,13 +720,18 @@ impl RemoteServerProjects {
                         }),
                     ),
             )
-            .child(
-                List::new()
+            .child(match &ssh_server {
+                RemoteEntry::Project {
+                    open_folder,
+                    projects,
+                    configure,
+                    connection,
+                } => List::new()
                     .empty_message("No projects.")
-                    .children(ssh_server.projects.iter().enumerate().map(|(pix, p)| {
+                    .children(projects.iter().enumerate().map(|(pix, p)| {
                         v_flex().gap_0p5().child(self.render_ssh_project(
                             ix,
-                            &ssh_server,
+                            ssh_server.clone(),
                             pix,
                             p,
                             window,
@@ -653,37 +741,29 @@ impl RemoteServerProjects {
                     .child(
                         h_flex()
                             .id(("new-remote-project-container", ix))
-                            .track_focus(&ssh_server.open_folder.focus_handle)
-                            .anchor_scroll(ssh_server.open_folder.scroll_anchor.clone())
+                            .track_focus(&open_folder.focus_handle)
+                            .anchor_scroll(open_folder.scroll_anchor.clone())
                             .on_action(cx.listener({
-                                let ssh_connection = ssh_server.clone();
+                                let ssh_connection = connection.clone();
                                 move |this, _: &menu::Confirm, window, cx| {
-                                    this.create_ssh_project(
-                                        ix,
-                                        ssh_connection.connection.clone(),
-                                        window,
-                                        cx,
-                                    );
+                                    this.create_ssh_project(ix, ssh_connection.clone(), window, cx);
                                 }
                             }))
                             .child(
                                 ListItem::new(("new-remote-project", ix))
                                     .toggle_state(
-                                        ssh_server
-                                            .open_folder
-                                            .focus_handle
-                                            .contains_focused(window, cx),
+                                        open_folder.focus_handle.contains_focused(window, cx),
                                     )
                                     .inset(true)
                                     .spacing(ui::ListItemSpacing::Sparse)
                                     .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
                                     .child(Label::new("Open Folder"))
                                     .on_click(cx.listener({
-                                        let ssh_connection = ssh_server.clone();
+                                        let ssh_connection = connection.clone();
                                         move |this, _, window, cx| {
                                             this.create_ssh_project(
                                                 ix,
-                                                ssh_connection.connection.clone(),
+                                                ssh_connection.clone(),
                                                 window,
                                                 cx,
                                             );
@@ -694,13 +774,13 @@ impl RemoteServerProjects {
                     .child(
                         h_flex()
                             .id(("server-options-container", ix))
-                            .track_focus(&ssh_server.configure.focus_handle)
-                            .anchor_scroll(ssh_server.configure.scroll_anchor.clone())
+                            .track_focus(&configure.focus_handle)
+                            .anchor_scroll(configure.scroll_anchor.clone())
                             .on_action(cx.listener({
-                                let ssh_connection = ssh_server.clone();
+                                let ssh_connection = connection.clone();
                                 move |this, _: &menu::Confirm, window, cx| {
                                     this.view_server_options(
-                                        (ix, ssh_connection.connection.clone()),
+                                        (ix, ssh_connection.clone()),
                                         window,
                                         cx,
                                     );
@@ -709,20 +789,17 @@ impl RemoteServerProjects {
                             .child(
                                 ListItem::new(("server-options", ix))
                                     .toggle_state(
-                                        ssh_server
-                                            .configure
-                                            .focus_handle
-                                            .contains_focused(window, cx),
+                                        configure.focus_handle.contains_focused(window, cx),
                                     )
                                     .inset(true)
                                     .spacing(ui::ListItemSpacing::Sparse)
                                     .start_slot(Icon::new(IconName::Settings).color(Color::Muted))
                                     .child(Label::new("View Server Options"))
                                     .on_click(cx.listener({
-                                        let ssh_connection = ssh_server.clone();
+                                        let ssh_connection = connection.clone();
                                         move |this, _, window, cx| {
                                             this.view_server_options(
-                                                (ix, ssh_connection.connection.clone()),
+                                                (ix, ssh_connection.clone()),
                                                 window,
                                                 cx,
                                             );
@@ -730,47 +807,95 @@ impl RemoteServerProjects {
                                     })),
                             ),
                     ),
-            )
+                RemoteEntry::SshConfig { open_folder, host } => List::new().child(
+                    h_flex()
+                        .id(("new-remote-project-container", ix))
+                        .track_focus(&open_folder.focus_handle)
+                        .anchor_scroll(open_folder.scroll_anchor.clone())
+                        .on_action(cx.listener({
+                            let ssh_connection = connection.clone();
+                            let host = host.clone();
+                            move |this, _: &menu::Confirm, window, cx| {
+                                let new_ix = this.create_host_from_ssh_config(&host, cx);
+                                this.create_ssh_project(new_ix, ssh_connection.clone(), window, cx);
+                            }
+                        }))
+                        .child(
+                            ListItem::new(("new-remote-project", ix))
+                                .toggle_state(open_folder.focus_handle.contains_focused(window, cx))
+                                .inset(true)
+                                .spacing(ui::ListItemSpacing::Sparse)
+                                .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
+                                .child(Label::new("Open Folder"))
+                                .on_click(cx.listener({
+                                    let ssh_connection = connection.clone();
+                                    let host = host.clone();
+                                    move |this, _, window, cx| {
+                                        let new_ix = this.create_host_from_ssh_config(&host, cx);
+                                        this.create_ssh_project(
+                                            new_ix,
+                                            ssh_connection.clone(),
+                                            window,
+                                            cx,
+                                        );
+                                    }
+                                })),
+                        ),
+                ),
+            })
     }
 
     fn render_ssh_project(
         &mut self,
         server_ix: usize,
-        server: &ProjectEntry,
+        server: RemoteEntry,
         ix: usize,
         (navigation, project): &(NavigableEntry, SshProject),
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
-        let server = server.clone();
+        let create_new_window = self.create_new_window;
+        let is_from_zed = server.is_from_zed();
         let element_id_base = SharedString::from(format!("remote-project-{server_ix}"));
         let container_element_id_base =
             SharedString::from(format!("remote-project-container-{element_id_base}"));
 
-        let callback = Arc::new({
+        let callback = Rc::new({
             let project = project.clone();
-            move |this: &mut Self, window: &mut Window, cx: &mut Context<Self>| {
-                let Some(app_state) = this
+            move |remote_server_projects: &mut Self,
+                  secondary_confirm: bool,
+                  window: &mut Window,
+                  cx: &mut Context<Self>| {
+                let Some(app_state) = remote_server_projects
                     .workspace
-                    .update(cx, |workspace, _| workspace.app_state().clone())
+                    .read_with(cx, |workspace, _| workspace.app_state().clone())
                     .log_err()
                 else {
                     return;
                 };
                 let project = project.clone();
-                let server = server.connection.clone();
+                let server = server.connection().into_owned();
                 cx.emit(DismissEvent);
+
+                let replace_window = match (create_new_window, secondary_confirm) {
+                    (true, false) | (false, true) => None,
+                    (true, true) | (false, false) => window.window_handle().downcast::<Workspace>(),
+                };
+
                 cx.spawn_in(window, async move |_, cx| {
                     let result = open_ssh_project(
                         server.into(),
                         project.paths.into_iter().map(PathBuf::from).collect(),
                         app_state,
-                        OpenOptions::default(),
+                        OpenOptions {
+                            replace_window,
+                            ..OpenOptions::default()
+                        },
                         cx,
                     )
                     .await;
                     if let Err(e) = result {
-                        log::error!("Failed to connect: {:?}", e);
+                        log::error!("Failed to connect: {e:#}");
                         cx.prompt(
                             gpui::PromptLevel::Critical,
                             "Failed to connect",
@@ -792,7 +917,13 @@ impl RemoteServerProjects {
             .on_action(cx.listener({
                 let callback = callback.clone();
                 move |this, _: &menu::Confirm, window, cx| {
-                    callback(this, window, cx);
+                    callback(this, false, window, cx);
+                }
+            }))
+            .on_action(cx.listener({
+                let callback = callback.clone();
+                move |this, _: &menu::SecondaryConfirm, window, cx| {
+                    callback(this, true, window, cx);
                 }
             }))
             .child(
@@ -806,24 +937,29 @@ impl RemoteServerProjects {
                             .size(IconSize::Small),
                     )
                     .child(Label::new(project.paths.join(", ")))
-                    .on_click(cx.listener(move |this, _, window, cx| callback(this, window, cx)))
-                    .end_hover_slot::<AnyElement>(Some(
-                        div()
-                            .mr_2()
-                            .child({
-                                let project = project.clone();
-                                // Right-margin to offset it from the Scrollbar
-                                IconButton::new("remove-remote-project", IconName::TrashAlt)
-                                    .icon_size(IconSize::Small)
-                                    .shape(IconButtonShape::Square)
-                                    .size(ButtonSize::Large)
-                                    .tooltip(Tooltip::text("Delete Remote Project"))
-                                    .on_click(cx.listener(move |this, _, _, cx| {
-                                        this.delete_ssh_project(server_ix, &project, cx)
-                                    }))
-                            })
-                            .into_any_element(),
-                    )),
+                    .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
+                        let secondary_confirm = e.down.modifiers.platform;
+                        callback(this, secondary_confirm, window, cx)
+                    }))
+                    .when(is_from_zed, |server_list_item| {
+                        server_list_item.end_hover_slot::<AnyElement>(Some(
+                            div()
+                                .mr_2()
+                                .child({
+                                    let project = project.clone();
+                                    // Right-margin to offset it from the Scrollbar
+                                    IconButton::new("remove-remote-project", IconName::TrashAlt)
+                                        .icon_size(IconSize::Small)
+                                        .shape(IconButtonShape::Square)
+                                        .size(ButtonSize::Large)
+                                        .tooltip(Tooltip::text("Delete Remote Project"))
+                                        .on_click(cx.listener(move |this, _, _, cx| {
+                                            this.delete_ssh_project(server_ix, &project, cx)
+                                        }))
+                                })
+                                .into_any_element(),
+                        ))
+                    }),
             )
     }
 
@@ -834,7 +970,7 @@ impl RemoteServerProjects {
     ) {
         let Some(fs) = self
             .workspace
-            .update(cx, |workspace, _| workspace.app_state().fs.clone())
+            .read_with(cx, |workspace, _| workspace.app_state().fs.clone())
             .log_err()
         else {
             return;
@@ -876,7 +1012,7 @@ impl RemoteServerProjects {
                     host: SharedString::from(connection_options.host),
                     username: connection_options.username,
                     port: connection_options.port,
-                    projects: BTreeSet::<SshProject>::new(),
+                    projects: BTreeSet::new(),
                     nickname: None,
                     args: connection_options.args.unwrap_or_default(),
                     upload_binary_over_ssh: None,
@@ -1122,7 +1258,10 @@ impl RemoteServerProjects {
                                             .ok();
                                         remote_servers
                                             .update(cx, |this, cx| {
-                                                this.mode = Mode::default_mode(cx);
+                                                this.mode = Mode::default_mode(
+                                                    &this.ssh_config_servers,
+                                                    cx,
+                                                );
                                                 cx.notify();
                                             })
                                             .ok();
@@ -1174,7 +1313,7 @@ impl RemoteServerProjects {
                                 .id("ssh-options-copy-server-address")
                                 .track_focus(&entries[3].focus_handle)
                                 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
-                                    this.mode = Mode::default_mode(cx);
+                                    this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
                                     cx.focus_self(window);
                                     cx.notify();
                                 }))
@@ -1190,7 +1329,8 @@ impl RemoteServerProjects {
                                         )
                                         .child(Label::new("Go Back"))
                                         .on_click(cx.listener(|this, _, window, cx| {
-                                            this.mode = Mode::default_mode(cx);
+                                            this.mode =
+                                                Mode::default_mode(&this.ssh_config_servers, cx);
                                             cx.focus_self(window);
                                             cx.notify()
                                         })),
@@ -1250,22 +1390,51 @@ impl RemoteServerProjects {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
-        if SshSettings::get_global(cx)
+        let ssh_settings = SshSettings::get_global(cx);
+        let mut should_rebuild = false;
+
+        if ssh_settings
             .ssh_connections
             .as_ref()
             .map_or(false, |connections| {
                 state
                     .servers
                     .iter()
-                    .map(|server| &server.connection)
+                    .filter_map(|server| match server {
+                        RemoteEntry::Project { connection, .. } => Some(connection),
+                        RemoteEntry::SshConfig { .. } => None,
+                    })
                     .ne(connections.iter())
             })
         {
-            self.mode = Mode::default_mode(cx);
+            should_rebuild = true;
+        };
+
+        if !should_rebuild && ssh_settings.read_ssh_config {
+            let current_ssh_hosts: BTreeSet<SharedString> = state
+                .servers
+                .iter()
+                .filter_map(|server| match server {
+                    RemoteEntry::SshConfig { host, .. } => Some(host.clone()),
+                    _ => None,
+                })
+                .collect();
+            let mut expected_ssh_hosts = self.ssh_config_servers.clone();
+            for server in &state.servers {
+                if let RemoteEntry::Project { connection, .. } = server {
+                    expected_ssh_hosts.remove(&connection.host);
+                }
+            }
+            should_rebuild = current_ssh_hosts != expected_ssh_hosts;
+        }
+
+        if should_rebuild {
+            self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
             if let Mode::Default(new_state) = &self.mode {
                 state = new_state.clone();
             }
         }
+
         let scroll_state = state.scrollbar.parent_entity(&cx.entity());
         let connect_button = div()
             .id("ssh-connect-new-server-container")
@@ -1332,19 +1501,51 @@ impl RemoteServerProjects {
         .entry(state.add_new_server.clone());
 
         for server in &state.servers {
-            for (navigation_state, _) in &server.projects {
-                modal_section = modal_section.entry(navigation_state.clone());
+            match server {
+                RemoteEntry::Project {
+                    open_folder,
+                    projects,
+                    configure,
+                    ..
+                } => {
+                    for (navigation_state, _) in projects {
+                        modal_section = modal_section.entry(navigation_state.clone());
+                    }
+                    modal_section = modal_section
+                        .entry(open_folder.clone())
+                        .entry(configure.clone());
+                }
+                RemoteEntry::SshConfig { open_folder, .. } => {
+                    modal_section = modal_section.entry(open_folder.clone());
+                }
             }
-            modal_section = modal_section
-                .entry(server.open_folder.clone())
-                .entry(server.configure.clone());
         }
         let mut modal_section = modal_section.render(window, cx).into_any_element();
 
+        let (create_window, reuse_window) = if self.create_new_window {
+            (
+                window.keystroke_text_for(&menu::Confirm),
+                window.keystroke_text_for(&menu::SecondaryConfirm),
+            )
+        } else {
+            (
+                window.keystroke_text_for(&menu::SecondaryConfirm),
+                window.keystroke_text_for(&menu::Confirm),
+            )
+        };
+        let placeholder_text = Arc::from(format!(
+            "{reuse_window} reuses this window, {create_window} opens a new one",
+        ));
+
         Modal::new("remote-projects", None)
             .header(
                 ModalHeader::new()
-                    .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall)),
+                    .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall))
+                    .child(
+                        Label::new(placeholder_text)
+                            .color(Color::Muted)
+                            .size(LabelSize::XSmall),
+                    ),
             )
             .section(
                 Section::new().padded(false).child(
@@ -1385,6 +1586,94 @@ impl RemoteServerProjects {
             )
             .into_any_element()
     }
+
+    fn create_host_from_ssh_config(
+        &mut self,
+        ssh_config_host: &SharedString,
+        cx: &mut Context<'_, Self>,
+    ) -> usize {
+        let new_ix = Arc::new(AtomicUsize::new(0));
+
+        let update_new_ix = new_ix.clone();
+        self.update_settings_file(cx, move |settings, _| {
+            update_new_ix.store(
+                settings
+                    .ssh_connections
+                    .as_ref()
+                    .map_or(0, |connections| connections.len()),
+                atomic::Ordering::Release,
+            );
+        });
+
+        self.add_ssh_server(
+            SshConnectionOptions {
+                host: ssh_config_host.to_string(),
+                ..SshConnectionOptions::default()
+            },
+            cx,
+        );
+        self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
+        new_ix.load(atomic::Ordering::Acquire)
+    }
+}
+
+fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
+    let mut user_ssh_config_watcher =
+        watch_config_file(cx.background_executor(), fs.clone(), user_ssh_config_file());
+    let mut global_ssh_config_watcher = watch_config_file(
+        cx.background_executor(),
+        fs,
+        global_ssh_config_file().to_owned(),
+    );
+
+    cx.spawn(async move |remote_server_projects, cx| {
+        let mut global_hosts = BTreeSet::default();
+        let mut user_hosts = BTreeSet::default();
+        let mut running_receivers = 2;
+
+        loop {
+            select! {
+                new_global_file_contents = global_ssh_config_watcher.next().fuse() => {
+                    match new_global_file_contents {
+                        Some(new_global_file_contents) => {
+                            global_hosts = parse_ssh_config_hosts(&new_global_file_contents);
+                            if remote_server_projects.update(cx, |remote_server_projects, cx| {
+                                remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
+                                cx.notify();
+                            }).is_err() {
+                                return;
+                            }
+                        },
+                        None => {
+                            running_receivers -= 1;
+                            if running_receivers == 0 {
+                                return;
+                            }
+                        }
+                    }
+                },
+                new_user_file_contents = user_ssh_config_watcher.next().fuse() => {
+                    match new_user_file_contents {
+                        Some(new_user_file_contents) => {
+                            user_hosts = parse_ssh_config_hosts(&new_user_file_contents);
+                            if remote_server_projects.update(cx, |remote_server_projects, cx| {
+                                remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
+                                cx.notify();
+                            }).is_err() {
+                                return;
+                            }
+                        },
+                        None => {
+                            running_receivers -= 1;
+                            if running_receivers == 0 {
+                                return;
+                            }
+                        }
+                    }
+                },
+            }
+        }
+    })
 }
 
 fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {

crates/recent_projects/src/ssh_config.rs 🔗

@@ -0,0 +1,96 @@
+use std::collections::BTreeSet;
+
+pub fn parse_ssh_config_hosts(config: &str) -> BTreeSet<String> {
+    let mut hosts = BTreeSet::new();
+    let mut needs_another_line = false;
+    for line in config.lines() {
+        let line = line.trim_start();
+        if let Some(line) = line.strip_prefix("Host") {
+            match line.chars().next() {
+                Some('\\') => {
+                    needs_another_line = true;
+                }
+                Some('\n' | '\r') => {
+                    needs_another_line = false;
+                }
+                Some(c) if c.is_whitespace() => {
+                    parse_hosts_from(line, &mut hosts);
+                }
+                Some(_) | None => {
+                    needs_another_line = false;
+                }
+            };
+
+            if needs_another_line {
+                parse_hosts_from(line, &mut hosts);
+                needs_another_line = line.trim_end().ends_with('\\');
+            } else {
+                needs_another_line = false;
+            }
+        } else if needs_another_line {
+            needs_another_line = line.trim_end().ends_with('\\');
+            parse_hosts_from(line, &mut hosts);
+        } else {
+            needs_another_line = false;
+        }
+    }
+
+    hosts
+}
+
+fn parse_hosts_from(line: &str, hosts: &mut BTreeSet<String>) {
+    hosts.extend(
+        line.split_whitespace()
+            .filter(|field| !field.starts_with("!"))
+            .filter(|field| !field.contains("*"))
+            .filter(|field| !field.is_empty())
+            .map(|field| field.to_owned()),
+    );
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_thank_you_bjorn3() {
+        let hosts = "
+            Host *
+              AddKeysToAgent yes
+              UseKeychain yes
+              IdentityFile ~/.ssh/id_ed25519
+
+            Host whatever.*
+            User another
+
+            Host !not_this
+            User not_me
+
+            Host something
+        HostName whatever.tld
+
+        Host linux bsd host3
+          User bjorn
+
+        Host rpi
+          user rpi
+          hostname rpi.local
+
+        Host \
+               somehost \
+        anotherhost
+        Hostname 192.168.3.3";
+
+        let expected_hosts = BTreeSet::from_iter([
+            "something".to_owned(),
+            "linux".to_owned(),
+            "host3".to_owned(),
+            "bsd".to_owned(),
+            "rpi".to_owned(),
+            "somehost".to_owned(),
+            "anotherhost".to_owned(),
+        ]);
+
+        assert_eq!(expected_hosts, parse_ssh_config_hosts(hosts));
+    }
+}

crates/recent_projects/src/ssh_connections.rs 🔗

@@ -1,7 +1,7 @@
 use std::collections::BTreeSet;
 use std::{path::PathBuf, sync::Arc, time::Duration};
 
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use auto_update::AutoUpdater;
 use editor::Editor;
 use extension_host::ExtensionStore;
@@ -25,11 +25,15 @@ use ui::{
     ActiveTheme, Color, Context, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label,
     LabelCommon, Styled, Window, prelude::*,
 };
+use util::serde::default_true;
 use workspace::{AppState, ModalView, Workspace};
 
 #[derive(Deserialize)]
 pub struct SshSettings {
     pub ssh_connections: Option<Vec<SshConnection>>,
+    /// Whether to read ~/.ssh/config for ssh connection sources.
+    #[serde(default = "default_true")]
+    pub read_ssh_config: bool,
 }
 
 impl SshSettings {
@@ -115,6 +119,7 @@ pub struct SshProject {
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 pub struct RemoteSettingsContent {
     pub ssh_connections: Option<Vec<SshConnection>>,
+    pub read_ssh_config: Option<bool>,
 }
 
 impl Settings for SshSettings {
@@ -243,7 +248,7 @@ impl Render for SshPrompt {
         text_style.refine(&refinement);
         let markdown_style = MarkdownStyle {
             base_text_style: text_style,
-            selection_background_color: cx.theme().players().local().selection,
+            selection_background_color: cx.theme().colors().element_selection_background,
             ..Default::default()
         };
 
@@ -284,6 +289,9 @@ impl Render for SshPrompt {
                         .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
                         .child(self.editor.clone()),
                 )
+                .when(window.capslock().on, |el| {
+                    el.child(Label::new("⚠️ ⇪ is on"))
+                })
             })
     }
 }
@@ -479,15 +487,14 @@ impl remote::SshClientDelegate for SshClientDelegate {
                 cx,
             )
             .await
-            .map_err(|e| {
-                anyhow!(
-                    "Failed to download remote server binary (version: {}, os: {}, arch: {}): {}",
+            .with_context(|| {
+                format!(
+                    "Downloading remote server binary (version: {}, os: {}, arch: {})",
                     version
                         .map(|v| format!("{}", v))
                         .unwrap_or("unknown".to_string()),
                     platform.os,
                     platform.arch,
-                    e
                 )
             })?;
             Ok(binary_path)

crates/refineable/derive_refineable/src/derive_refineable.rs 🔗

@@ -16,25 +16,24 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream {
         ..
     } = parse_macro_input!(input);
 
-    let refineable_attr = attrs.iter().find(|attr| attr.path.is_ident("refineable"));
+    let refineable_attr = attrs.iter().find(|attr| attr.path().is_ident("refineable"));
 
     let mut impl_debug_on_refinement = false;
+    let mut derives_serialize = false;
     let mut refinement_traits_to_derive = vec![];
 
     if let Some(refineable_attr) = refineable_attr {
-        if let Ok(syn::Meta::List(meta_list)) = refineable_attr.parse_meta() {
-            for nested in meta_list.nested {
-                let syn::NestedMeta::Meta(syn::Meta::Path(path)) = nested else {
-                    continue;
-                };
-
-                if path.is_ident("Debug") {
-                    impl_debug_on_refinement = true;
-                } else {
-                    refinement_traits_to_derive.push(path);
+        let _ = refineable_attr.parse_nested_meta(|meta| {
+            if meta.path.is_ident("Debug") {
+                impl_debug_on_refinement = true;
+            } else {
+                if meta.path.is_ident("Serialize") {
+                    derives_serialize = true;
                 }
+                refinement_traits_to_derive.push(meta.path);
             }
-        }
+            Ok(())
+        });
     }
 
     let refinement_ident = format_ident!("{}Refinement", ident);
@@ -52,7 +51,22 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream {
     let field_visibilities: Vec<_> = fields.iter().map(|f| &f.vis).collect();
     let wrapped_types: Vec<_> = fields.iter().map(|f| get_wrapper_type(f, &f.ty)).collect();
 
-    // Create trait bound that each wrapped type must implement Clone // & Default
+    let field_attributes: Vec<TokenStream2> = fields
+        .iter()
+        .map(|f| {
+            if derives_serialize {
+                if is_refineable_field(f) {
+                    quote! { #[serde(default, skip_serializing_if = "::refineable::IsEmpty::is_empty")] }
+                } else {
+                    quote! { #[serde(skip_serializing_if = "::std::option::Option::is_none")] }
+                }
+            } else {
+                quote! {}
+            }
+        })
+        .collect();
+
+    // Create trait bound that each wrapped type must implement Clone
     let type_param_bounds: Vec<_> = wrapped_types
         .iter()
         .map(|ty| {
@@ -239,6 +253,136 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream {
         quote! {}
     };
 
+    let refinement_is_empty_conditions: Vec<TokenStream2> = fields
+        .iter()
+        .enumerate()
+        .map(|(i, field)| {
+            let name = &field.ident;
+
+            let condition = if is_refineable_field(field) {
+                quote! { self.#name.is_empty() }
+            } else {
+                quote! { self.#name.is_none() }
+            };
+
+            if i < fields.len() - 1 {
+                quote! { #condition && }
+            } else {
+                condition
+            }
+        })
+        .collect();
+
+    let refineable_is_superset_conditions: Vec<TokenStream2> = fields
+        .iter()
+        .map(|field| {
+            let name = &field.ident;
+            let is_refineable = is_refineable_field(field);
+            let is_optional = is_optional_field(field);
+
+            if is_refineable {
+                quote! {
+                    if !self.#name.is_superset_of(&refinement.#name) {
+                        return false;
+                    }
+                }
+            } else if is_optional {
+                quote! {
+                    if refinement.#name.is_some() && &self.#name != &refinement.#name {
+                        return false;
+                    }
+                }
+            } else {
+                quote! {
+                    if let Some(refinement_value) = &refinement.#name {
+                        if &self.#name != refinement_value {
+                            return false;
+                        }
+                    }
+                }
+            }
+        })
+        .collect();
+
+    let refinement_is_superset_conditions: Vec<TokenStream2> = fields
+        .iter()
+        .map(|field| {
+            let name = &field.ident;
+            let is_refineable = is_refineable_field(field);
+
+            if is_refineable {
+                quote! {
+                    if !self.#name.is_superset_of(&refinement.#name) {
+                        return false;
+                    }
+                }
+            } else {
+                quote! {
+                    if refinement.#name.is_some() && &self.#name != &refinement.#name {
+                        return false;
+                    }
+                }
+            }
+        })
+        .collect();
+
+    let refineable_subtract_assignments: Vec<TokenStream2> = fields
+        .iter()
+        .map(|field| {
+            let name = &field.ident;
+            let is_refineable = is_refineable_field(field);
+            let is_optional = is_optional_field(field);
+
+            if is_refineable {
+                quote! {
+                    #name: self.#name.subtract(&refinement.#name),
+                }
+            } else if is_optional {
+                quote! {
+                    #name: if &self.#name == &refinement.#name {
+                        None
+                    } else {
+                        self.#name.clone()
+                    },
+                }
+            } else {
+                quote! {
+                    #name: if let Some(refinement_value) = &refinement.#name {
+                        if &self.#name == refinement_value {
+                            None
+                        } else {
+                            Some(self.#name.clone())
+                        }
+                    } else {
+                        Some(self.#name.clone())
+                    },
+                }
+            }
+        })
+        .collect();
+
+    let refinement_subtract_assignments: Vec<TokenStream2> = fields
+        .iter()
+        .map(|field| {
+            let name = &field.ident;
+            let is_refineable = is_refineable_field(field);
+
+            if is_refineable {
+                quote! {
+                    #name: self.#name.subtract(&refinement.#name),
+                }
+            } else {
+                quote! {
+                    #name: if &self.#name == &refinement.#name {
+                        None
+                    } else {
+                        self.#name.clone()
+                    },
+                }
+            }
+        })
+        .collect();
+
     let mut derive_stream = quote! {};
     for trait_to_derive in refinement_traits_to_derive {
         derive_stream.extend(quote! { #[derive(#trait_to_derive)] })
@@ -251,6 +395,7 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream {
         pub struct #refinement_ident #impl_generics {
             #(
                 #[allow(missing_docs)]
+                #field_attributes
                 #field_visibilities #field_names: #wrapped_types
             ),*
         }
@@ -268,6 +413,19 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream {
                 #( #refineable_refined_assignments )*
                 self
             }
+
+            fn is_superset_of(&self, refinement: &Self::Refinement) -> bool
+            {
+                #( #refineable_is_superset_conditions )*
+                true
+            }
+
+            fn subtract(&self, refinement: &Self::Refinement) -> Self::Refinement
+            {
+                #refinement_ident {
+                    #( #refineable_subtract_assignments )*
+                }
+            }
         }
 
         impl #impl_generics Refineable for #refinement_ident #ty_generics
@@ -283,6 +441,27 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream {
                 #( #refinement_refined_assignments )*
                 self
             }
+
+            fn is_superset_of(&self, refinement: &Self::Refinement) -> bool
+            {
+                #( #refinement_is_superset_conditions )*
+                true
+            }
+
+            fn subtract(&self, refinement: &Self::Refinement) -> Self::Refinement
+            {
+                #refinement_ident {
+                    #( #refinement_subtract_assignments )*
+                }
+            }
+        }
+
+        impl #impl_generics ::refineable::IsEmpty for #refinement_ident #ty_generics
+            #where_clause
+        {
+            fn is_empty(&self) -> bool {
+                #( #refinement_is_empty_conditions )*
+            }
         }
 
         impl #impl_generics From<#refinement_ident #ty_generics> for #ident #ty_generics
@@ -325,7 +504,9 @@ pub fn derive_refineable(input: TokenStream) -> TokenStream {
 }
 
 fn is_refineable_field(f: &Field) -> bool {
-    f.attrs.iter().any(|attr| attr.path.is_ident("refineable"))
+    f.attrs
+        .iter()
+        .any(|attr| attr.path().is_ident("refineable"))
 }
 
 fn is_optional_field(f: &Field) -> bool {

crates/refineable/src/refineable.rs 🔗

@@ -1,18 +1,120 @@
 pub use derive_refineable::Refineable;
 
+/// A trait for types that can be refined with partial updates.
+///
+/// The `Refineable` trait enables hierarchical configuration patterns where a base configuration
+/// can be selectively overridden by refinements. This is particularly useful for styling and
+/// settings, and theme hierarchies.
+///
+/// # Derive Macro
+///
+/// The `#[derive(Refineable)]` macro automatically generates a companion refinement type and
+/// implements this trait. For a struct `Style`, it creates `StyleRefinement` where each field is
+/// wrapped appropriately:
+///
+/// - **Refineable fields** (marked with `#[refineable]`): Become the corresponding refinement type
+///   (e.g., `Bar` becomes `BarRefinement`)
+/// - **Optional fields** (`Option<T>`): Remain as `Option<T>`
+/// - **Regular fields**: Become `Option<T>`
+///
+/// ## Example
+///
+/// ```rust
+/// #[derive(Refineable, Clone, Default)]
+/// struct Example {
+///     color: String,
+///     font_size: Option<u32>,
+///     #[refineable]
+///     margin: Margin,
+/// }
+///
+/// #[derive(Refineable, Clone, Default)]
+/// struct Margin {
+///     top: u32,
+///     left: u32,
+/// }
+///
+///
+/// fn example() {
+///     let mut example = Example::default();
+///     let refinement = ExampleRefinement {
+///         color: Some("red".to_string()),
+///         font_size: None,
+///         margin: MarginRefinement {
+///             top: Some(10),
+///             left: None,
+///         },
+///     };
+///
+///     base_style.refine(&refinement);
+/// }
+/// ```
+///
+/// This generates `ExampleRefinement` with:
+/// - `color: Option<String>`
+/// - `font_size: Option<u32>` (unchanged)
+/// - `margin: MarginRefinement`
+///
+/// ## Attributes
+///
+/// The derive macro supports these attributes on the struct:
+/// - `#[refineable(Debug)]`: Implements `Debug` for the refinement type
+/// - `#[refineable(Serialize)]`: Derives `Serialize` which skips serializing `None`
+/// - `#[refineable(OtherTrait)]`: Derives additional traits on the refinement type
+///
+/// Fields can be marked with:
+/// - `#[refineable]`: Field is itself refineable (uses nested refinement type)
 pub trait Refineable: Clone {
-    type Refinement: Refineable<Refinement = Self::Refinement> + Default;
+    type Refinement: Refineable<Refinement = Self::Refinement> + IsEmpty + Default;
 
+    /// Applies the given refinement to this instance, modifying it in place.
+    ///
+    /// Only non-empty values in the refinement are applied.
+    ///
+    /// * For refineable fields, this recursively calls `refine`.
+    /// * For other fields, the value is replaced if present in the refinement.
     fn refine(&mut self, refinement: &Self::Refinement);
+
+    /// Returns a new instance with the refinement applied, equivalent to cloning `self` and calling
+    /// `refine` on it.
     fn refined(self, refinement: Self::Refinement) -> Self;
+
+    /// Creates an instance from a cascade by merging all refinements atop the default value.
     fn from_cascade(cascade: &Cascade<Self>) -> Self
     where
         Self: Default + Sized,
     {
         Self::default().refined(cascade.merged())
     }
+
+    /// Returns `true` if this instance would contain all values from the refinement.
+    ///
+    /// For refineable fields, this recursively checks `is_superset_of`. For other fields, this
+    /// checks if the refinement's `Some` values match this instance's values.
+    fn is_superset_of(&self, refinement: &Self::Refinement) -> bool;
+
+    /// Returns a refinement that represents the difference between this instance and the given
+    /// refinement.
+    ///
+    /// For refineable fields, this recursively calls `subtract`. For other fields, the field is
+    /// `None` if the field's value is equal to the refinement.
+    fn subtract(&self, refinement: &Self::Refinement) -> Self::Refinement;
+}
+
+pub trait IsEmpty {
+    /// Returns `true` if applying this refinement would have no effect.
+    fn is_empty(&self) -> bool;
 }
 
+/// A cascade of refinements that can be merged in priority order.
+///
+/// A cascade maintains a sequence of optional refinements where later entries
+/// take precedence over earlier ones. The first slot (index 0) is always the
+/// base refinement and is guaranteed to be present.
+///
+/// This is useful for implementing configuration hierarchies like CSS cascading,
+/// where styles from different sources (user agent, user, author) are combined
+/// with specific precedence rules.
 pub struct Cascade<S: Refineable>(Vec<Option<S::Refinement>>);
 
 impl<S: Refineable + Default> Default for Cascade<S> {
@@ -21,23 +123,43 @@ impl<S: Refineable + Default> Default for Cascade<S> {
     }
 }
 
+/// A handle to a specific slot in a cascade.
+///
+/// Slots are used to identify specific positions in the cascade where
+/// refinements can be set or updated.
 #[derive(Copy, Clone)]
 pub struct CascadeSlot(usize);
 
 impl<S: Refineable + Default> Cascade<S> {
+    /// Reserves a new slot in the cascade and returns a handle to it.
+    ///
+    /// The new slot is initially empty (`None`) and can be populated later
+    /// using `set()`.
     pub fn reserve(&mut self) -> CascadeSlot {
         self.0.push(None);
         CascadeSlot(self.0.len() - 1)
     }
 
+    /// Returns a mutable reference to the base refinement (slot 0).
+    ///
+    /// The base refinement is always present and serves as the foundation
+    /// for the cascade.
     pub fn base(&mut self) -> &mut S::Refinement {
         self.0[0].as_mut().unwrap()
     }
 
+    /// Sets the refinement for a specific slot in the cascade.
+    ///
+    /// Setting a slot to `None` effectively removes it from consideration
+    /// during merging.
     pub fn set(&mut self, slot: CascadeSlot, refinement: Option<S::Refinement>) {
         self.0[slot.0] = refinement
     }
 
+    /// Merges all refinements in the cascade into a single refinement.
+    ///
+    /// Refinements are applied in order, with later slots taking precedence.
+    /// Empty slots (`None`) are skipped during merging.
     pub fn merged(&self) -> S::Refinement {
         let mut merged = self.0[0].clone().unwrap();
         for refinement in self.0.iter().skip(1).flatten() {

crates/release_channel/src/lib.rs 🔗

@@ -35,14 +35,19 @@ pub fn app_identifier() -> &'static str {
 }
 
 /// The Git commit SHA that Zed was built at.
-#[derive(Clone)]
-pub struct AppCommitSha(pub String);
+#[derive(Clone, Eq, Debug, PartialEq)]
+pub struct AppCommitSha(String);
 
 struct GlobalAppCommitSha(AppCommitSha);
 
 impl Global for GlobalAppCommitSha {}
 
 impl AppCommitSha {
+    /// Creates a new [`AppCommitSha`].
+    pub fn new(sha: String) -> Self {
+        AppCommitSha(sha)
+    }
+
     /// Returns the global [`AppCommitSha`], if one is set.
     pub fn try_global(cx: &App) -> Option<AppCommitSha> {
         cx.try_global::<GlobalAppCommitSha>()
@@ -53,6 +58,16 @@ impl AppCommitSha {
     pub fn set_global(sha: AppCommitSha, cx: &mut App) {
         cx.set_global(GlobalAppCommitSha(sha))
     }
+
+    /// Returns the full commit SHA.
+    pub fn full(&self) -> String {
+        self.0.to_string()
+    }
+
+    /// Returns the short (7 character) commit SHA.
+    pub fn short(&self) -> String {
+        self.0.chars().take(7).collect()
+    }
 }
 
 struct GlobalAppVersion(SemanticVersion);

crates/remote/Cargo.toml 🔗

@@ -41,6 +41,7 @@ tempfile.workspace = true
 thiserror.workspace = true
 urlencoding.workspace = true
 util.workspace = true
+which.workspace = true
 workspace-hack.workspace = true
 
 [dev-dependencies]

crates/remote/src/ssh_session.rs 🔗

@@ -23,7 +23,7 @@ use gpui::{
 };
 use itertools::Itertools;
 use parking_lot::Mutex;
-use paths;
+
 use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
 use rpc::{
     AnyProtoClient, EntityMessageSubscriber, ErrorExt, ProtoClient, ProtoMessageHandlerSet,
@@ -100,7 +100,7 @@ macro_rules! shell_script {
 fn parse_port_number(port_str: &str) -> Result<u16> {
     port_str
         .parse()
-        .map_err(|e| anyhow!("Invalid port number: {}: {}", port_str, e))
+        .with_context(|| format!("parsing port number: {port_str}"))
 }
 
 fn parse_port_forward_spec(spec: &str) -> Result<SshPortForwardOption> {
@@ -151,9 +151,7 @@ impl SshConnectionOptions {
             "-w",
         ];
 
-        let mut tokens = shlex::split(input)
-            .ok_or_else(|| anyhow!("invalid input"))?
-            .into_iter();
+        let mut tokens = shlex::split(input).context("invalid input")?.into_iter();
 
         'outer: while let Some(arg) = tokens.next() {
             if ALLOWED_OPTS.contains(&(&arg as &str)) {
@@ -369,14 +367,12 @@ impl SshSocket {
 
     async fn run_command(&self, program: &str, args: &[&str]) -> Result<String> {
         let output = self.ssh_command(program, args).output().await?;
-        if output.status.success() {
-            Ok(String::from_utf8_lossy(&output.stdout).to_string())
-        } else {
-            Err(anyhow!(
-                "failed to run command: {}",
-                String::from_utf8_lossy(&output.stderr)
-            ))
-        }
+        anyhow::ensure!(
+            output.status.success(),
+            "failed to run command: {}",
+            String::from_utf8_lossy(&output.stderr)
+        );
+        Ok(String::from_utf8_lossy(&output.stdout).to_string())
     }
 
     fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command {
@@ -727,13 +723,13 @@ impl SshRemoteClient {
             .map(|state| state.can_reconnect())
             .unwrap_or(false);
         if !can_reconnect {
+            log::info!("aborting reconnect, because not in state that allows reconnecting");
             let error = if let Some(state) = lock.as_ref() {
                 format!("invalid state, cannot reconnect while in state {state}")
             } else {
                 "no state set".to_string()
             };
-            log::info!("aborting reconnect, because not in state that allows reconnecting");
-            return Err(anyhow!(error));
+            anyhow::bail!(error);
         }
 
         let state = lock.take().unwrap();
@@ -905,7 +901,7 @@ impl SshRemoteClient {
         mut connection_activity_rx: mpsc::Receiver<()>,
         cx: &mut AsyncApp,
     ) -> Task<Result<()>> {
-        let Ok(client) = this.update(cx, |this, _| this.client.clone()) else {
+        let Ok(client) = this.read_with(cx, |this, _| this.client.clone()) else {
             return Task::ready(Err(anyhow!("SshRemoteClient lost")));
         };
 
@@ -925,7 +921,7 @@ impl SshRemoteClient {
 
                             if missed_heartbeats != 0 {
                                 missed_heartbeats = 0;
-                                this.update(cx, |this, mut cx| {
+                                let _ =this.update(cx, |this, mut cx| {
                                     this.handle_heartbeat_result(missed_heartbeats, &mut cx)
                                 })?;
                             }
@@ -1363,14 +1359,13 @@ impl RemoteConnection for SshRemoteConnection {
         cx.background_spawn(async move {
             let output = output.await?;
 
-            if !output.status.success() {
-                return Err(anyhow!(
-                    "failed to upload directory {} -> {}: {}",
-                    src_path.display(),
-                    dest_path.display(),
-                    String::from_utf8_lossy(&output.stderr)
-                ));
-            }
+            anyhow::ensure!(
+                output.status.success(),
+                "failed to upload directory {} -> {}: {}",
+                src_path.display(),
+                dest_path.display(),
+                String::from_utf8_lossy(&output.stderr)
+            );
 
             Ok(())
         })
@@ -1446,7 +1441,7 @@ impl SshRemoteConnection {
         _delegate: Arc<dyn SshClientDelegate>,
         _cx: &mut AsyncApp,
     ) -> Result<Self> {
-        Err(anyhow!("ssh is not supported on this platform"))
+        anyhow::bail!("ssh is not supported on this platform");
     }
 
     #[cfg(unix)]
@@ -1506,10 +1501,10 @@ impl SshRemoteConnection {
                 match result {
                     AskPassResult::CancelledByUser => {
                         master_process.kill().ok();
-                        Err(anyhow!("SSH connection canceled"))?
+                        anyhow::bail!("SSH connection canceled")
                     }
                     AskPassResult::Timedout => {
-                        Err(anyhow!("connecting to host timed out"))?
+                        anyhow::bail!("connecting to host timed out")
                     }
                 }
             }
@@ -1531,7 +1526,7 @@ impl SshRemoteConnection {
                 "failed to connect: {}",
                 String::from_utf8_lossy(&output).trim()
             );
-            Err(anyhow!(error_message))?;
+            anyhow::bail!(error_message);
         }
 
         drop(askpass);
@@ -1566,15 +1561,15 @@ impl SshRemoteConnection {
     async fn platform(&self) -> Result<SshPlatform> {
         let uname = self.socket.run_command("sh", &["-c", "uname -sm"]).await?;
         let Some((os, arch)) = uname.split_once(" ") else {
-            Err(anyhow!("unknown uname: {uname:?}"))?
+            anyhow::bail!("unknown uname: {uname:?}")
         };
 
         let os = match os.trim() {
             "Darwin" => "macos",
             "Linux" => "linux",
-            _ => Err(anyhow!(
+            _ => anyhow::bail!(
                 "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
-            ))?,
+            ),
         };
         // exclude armv5,6,7 as they are 32-bit.
         let arch = if arch.starts_with("armv8")
@@ -1586,9 +1581,9 @@ impl SshRemoteConnection {
         } else if arch.starts_with("x86") {
             "x86_64"
         } else {
-            Err(anyhow!(
+            anyhow::bail!(
                 "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
-            ))?
+            )
         };
 
         Ok(SshPlatform { os, arch })
@@ -1707,7 +1702,7 @@ impl SshRemoteConnection {
     ) -> Result<PathBuf> {
         let version_str = match release_channel {
             ReleaseChannel::Nightly => {
-                let commit = commit.map(|s| s.0.to_string()).unwrap_or_default();
+                let commit = commit.map(|s| s.full()).unwrap_or_default();
 
                 format!("{}-{}", version, commit)
             }
@@ -1720,20 +1715,21 @@ impl SshRemoteConnection {
             version_str
         );
         let dst_path = paths::remote_server_dir_relative().join(binary_name);
-        let tmp_path_gz = PathBuf::from(format!(
-            "{}-download-{}.gz",
-            dst_path.to_string_lossy(),
-            std::process::id()
-        ));
 
+        let build_remote_server = std::env::var("ZED_BUILD_REMOTE_SERVER").ok();
         #[cfg(debug_assertions)]
-        if std::env::var("ZED_BUILD_REMOTE_SERVER").is_ok() {
+        if let Some(build_remote_server) = build_remote_server {
             let src_path = self
-                .build_local(self.platform().await?, delegate, cx)
+                .build_local(build_remote_server, self.platform().await?, delegate, cx)
                 .await?;
-            self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx)
+            let tmp_path = paths::remote_server_dir_relative().join(format!(
+                "download-{}-{}",
+                std::process::id(),
+                src_path.file_name().unwrap().to_string_lossy()
+            ));
+            self.upload_local_server_binary(&src_path, &tmp_path, delegate, cx)
                 .await?;
-            self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
+            self.extract_server_binary(&dst_path, &tmp_path, delegate, cx)
                 .await?;
             return Ok(dst_path);
         }
@@ -1760,6 +1756,11 @@ impl SshRemoteConnection {
 
         let platform = self.platform().await?;
 
+        let tmp_path_gz = PathBuf::from(format!(
+            "{}-download-{}.gz",
+            dst_path.to_string_lossy(),
+            std::process::id()
+        ));
         if !self.socket.connection_options.upload_binary_over_ssh {
             if let Some((url, body)) = delegate
                 .get_download_params(platform, release_channel, wanted_version, cx)
@@ -1804,7 +1805,16 @@ impl SshRemoteConnection {
     ) -> Result<()> {
         if let Some(parent) = tmp_path_gz.parent() {
             self.socket
-                .run_command("mkdir", &["-p", &parent.to_string_lossy()])
+                .run_command(
+                    "sh",
+                    &[
+                        "-c",
+                        &shell_script!(
+                            "mkdir -p {parent}",
+                            parent = parent.to_string_lossy().as_ref()
+                        ),
+                    ],
+                )
                 .await?;
         }
 
@@ -1876,7 +1886,16 @@ impl SshRemoteConnection {
     ) -> Result<()> {
         if let Some(parent) = tmp_path_gz.parent() {
             self.socket
-                .run_command("mkdir", &["-p", &parent.to_string_lossy()])
+                .run_command(
+                    "sh",
+                    &[
+                        "-c",
+                        &shell_script!(
+                            "mkdir -p {parent}",
+                            parent = parent.to_string_lossy().as_ref()
+                        ),
+                    ],
+                )
                 .await?;
         }
 
@@ -1900,20 +1919,27 @@ impl SshRemoteConnection {
     async fn extract_server_binary(
         &self,
         dst_path: &Path,
-        tmp_path_gz: &Path,
+        tmp_path: &Path,
         delegate: &Arc<dyn SshClientDelegate>,
         cx: &mut AsyncApp,
     ) -> Result<()> {
         delegate.set_status(Some("Extracting remote development server"), cx);
         let server_mode = 0o755;
 
-        let script = shell_script!(
-            "gunzip -f {tmp_path_gz} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
-            tmp_path_gz = &tmp_path_gz.to_string_lossy(),
-            tmp_path = &tmp_path_gz.to_string_lossy().strip_suffix(".gz").unwrap(),
-            server_mode = &format!("{:o}", server_mode),
-            dst_path = &dst_path.to_string_lossy()
-        );
+        let orig_tmp_path = tmp_path.to_string_lossy();
+        let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
+            shell_script!(
+                "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
+                server_mode = &format!("{:o}", server_mode),
+                dst_path = &dst_path.to_string_lossy()
+            )
+        } else {
+            shell_script!(
+                "chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",
+                server_mode = &format!("{:o}", server_mode),
+                dst_path = &dst_path.to_string_lossy()
+            )
+        };
         self.socket.run_command("sh", &["-c", &script]).await?;
         Ok(())
     }
@@ -1940,21 +1966,20 @@ impl SshRemoteConnection {
             .output()
             .await?;
 
-        if output.status.success() {
-            Ok(())
-        } else {
-            Err(anyhow!(
-                "failed to upload file {} -> {}: {}",
-                src_path.display(),
-                dest_path.display(),
-                String::from_utf8_lossy(&output.stderr)
-            ))
-        }
+        anyhow::ensure!(
+            output.status.success(),
+            "failed to upload file {} -> {}: {}",
+            src_path.display(),
+            dest_path.display(),
+            String::from_utf8_lossy(&output.stderr)
+        );
+        Ok(())
     }
 
     #[cfg(debug_assertions)]
     async fn build_local(
         &self,
+        build_remote_server: String,
         platform: SshPlatform,
         delegate: &Arc<dyn SshClientDelegate>,
         cx: &mut AsyncApp,
@@ -1967,9 +1992,10 @@ impl SshRemoteConnection {
                 .stderr(Stdio::inherit())
                 .output()
                 .await?;
-            if !output.status.success() {
-                Err(anyhow!("Failed to run command: {:?}", command))?;
-            }
+            anyhow::ensure!(
+                output.status.success(),
+                "Failed to run command: {command:?}"
+            );
             Ok(())
         }
 
@@ -2004,57 +2030,99 @@ impl SshRemoteConnection {
         };
         smol::fs::create_dir_all("target/remote_server").await?;
 
-        delegate.set_status(Some("Installing cross.rs for cross-compilation"), cx);
-        log::info!("installing cross");
-        run_cmd(Command::new("cargo").args([
-            "install",
-            "cross",
-            "--git",
-            "https://github.com/cross-rs/cross",
-        ]))
-        .await?;
-
-        delegate.set_status(
-            Some(&format!(
-                "Building remote server binary from source for {} with Docker",
-                &triple
-            )),
-            cx,
-        );
-        log::info!("building remote server binary from source for {}", &triple);
-        run_cmd(
-            Command::new("cross")
-                .args([
-                    "build",
-                    "--package",
-                    "remote_server",
-                    "--features",
-                    "debug-embed",
-                    "--target-dir",
-                    "target/remote_server",
-                    "--target",
-                    &triple,
-                ])
-                .env(
-                    "CROSS_CONTAINER_OPTS",
-                    "--mount type=bind,src=./target,dst=/app/target",
-                ),
-        )
-        .await?;
+        if build_remote_server.contains("cross") {
+            delegate.set_status(Some("Installing cross.rs for cross-compilation"), cx);
+            log::info!("installing cross");
+            run_cmd(Command::new("cargo").args([
+                "install",
+                "cross",
+                "--git",
+                "https://github.com/cross-rs/cross",
+            ]))
+            .await?;
+
+            delegate.set_status(
+                Some(&format!(
+                    "Building remote server binary from source for {} with Docker",
+                    &triple
+                )),
+                cx,
+            );
+            log::info!("building remote server binary from source for {}", &triple);
+            run_cmd(
+                Command::new("cross")
+                    .args([
+                        "build",
+                        "--package",
+                        "remote_server",
+                        "--features",
+                        "debug-embed",
+                        "--target-dir",
+                        "target/remote_server",
+                        "--target",
+                        &triple,
+                    ])
+                    .env(
+                        "CROSS_CONTAINER_OPTS",
+                        "--mount type=bind,src=./target,dst=/app/target",
+                    ),
+            )
+            .await?;
+        } else {
+            let which = cx
+                .background_spawn(async move { which::which("zig") })
+                .await;
+
+            if which.is_err() {
+                anyhow::bail!(
+                    "zig not found on $PATH, install zig (see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross"
+                )
+            }
 
-        delegate.set_status(Some("Compressing binary"), cx);
+            delegate.set_status(Some("Adding rustup target for cross-compilation"), cx);
+            log::info!("adding rustup target");
+            run_cmd(Command::new("rustup").args(["target", "add"]).arg(&triple)).await?;
 
-        run_cmd(Command::new("gzip").args([
-            "-9",
-            "-f",
-            &format!("target/remote_server/{}/debug/remote_server", triple),
-        ]))
-        .await?;
+            delegate.set_status(Some("Installing cargo-zigbuild for cross-compilation"), cx);
+            log::info!("installing cargo-zigbuild");
+            run_cmd(Command::new("cargo").args(["install", "--locked", "cargo-zigbuild"])).await?;
 
-        let path = std::env::current_dir()?.join(format!(
-            "target/remote_server/{}/debug/remote_server.gz",
-            triple
-        ));
+            delegate.set_status(
+                Some(&format!(
+                    "Building remote binary from source for {triple} with Zig"
+                )),
+                cx,
+            );
+            log::info!("building remote binary from source for {triple} with Zig");
+            run_cmd(Command::new("cargo").args([
+                "zigbuild",
+                "--package",
+                "remote_server",
+                "--features",
+                "debug-embed",
+                "--target-dir",
+                "target/remote_server",
+                "--target",
+                &triple,
+            ]))
+            .await?;
+        };
+
+        let mut path = format!("target/remote_server/{triple}/debug/remote_server").into();
+        if !build_remote_server.contains("nocompress") {
+            delegate.set_status(Some("Compressing binary"), cx);
+
+            run_cmd(Command::new("gzip").args([
+                "-9",
+                "-f",
+                &format!("target/remote_server/{}/debug/remote_server", triple),
+            ]))
+            .await?;
+
+            path = std::env::current_dir()?.join(format!(
+                "target/remote_server/{triple}/debug/remote_server.gz"
+            ));
+        }
 
         return Ok(path);
     }
@@ -2242,8 +2310,7 @@ impl ChannelClient {
         async move {
             let response = response.await?;
             log::debug!("ssh request finish. name:{}", T::NAME);
-            T::Response::from_envelope(response)
-                .ok_or_else(|| anyhow!("received a response of the wrong type"))
+            T::Response::from_envelope(response).context("received a response of the wrong type")
         }
     }
 
@@ -2263,7 +2330,7 @@ impl ChannelClient {
             },
             async {
                 smol::Timer::after(timeout).await;
-                Err(anyhow!("Timeout detected"))
+                anyhow::bail!("Timeout detected")
             },
         )
         .await
@@ -2277,7 +2344,7 @@ impl ChannelClient {
             },
             async {
                 smol::Timer::after(timeout).await;
-                Err(anyhow!("Timeout detected"))
+                anyhow::bail!("Timeout detected")
             },
         )
         .await
@@ -2307,8 +2374,8 @@ impl ChannelClient {
         };
         async move {
             if let Err(error) = &result {
-                log::error!("failed to send message: {}", error);
-                return Err(anyhow!("failed to send message: {}", error));
+                log::error!("failed to send message: {error}");
+                anyhow::bail!("failed to send message: {error}");
             }
 
             let response = rx.await.context("connection lost")?.0;

crates/remote_server/Cargo.toml 🔗

@@ -24,12 +24,12 @@ test-support = ["fs/test-support"]
 [dependencies]
 anyhow.workspace = true
 askpass.workspace = true
-async-watch.workspace = true
 backtrace = "0.3"
 chrono.workspace = true
 clap.workspace = true
 client.workspace = true
 dap_adapters.workspace = true
+debug_adapter_extension.workspace = true
 env_logger.workspace = true
 extension.workspace = true
 extension_host.workspace = true
@@ -62,6 +62,7 @@ smol.workspace = true
 sysinfo.workspace = true
 telemetry_events.workspace = true
 util.workspace = true
+watch.workspace = true
 worktree.workspace = true
 
 [target.'cfg(not(windows))'.dependencies]
@@ -74,6 +75,8 @@ assistant_tools.workspace = true
 client = { workspace = true, features = ["test-support"] }
 clock = { workspace = true, features = ["test-support"] }
 dap = { workspace = true, features = ["test-support"] }
+editor = { workspace = true, features = ["test-support"] }
+workspace = { workspace = true, features = ["test-support"] }
 fs = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 http_client = { workspace = true, features = ["test-support"] }
@@ -85,6 +88,7 @@ language_model = { workspace = true, features = ["test-support"] }
 lsp = { workspace = true, features=["test-support"] }
 unindent.workspace = true
 serde_json.workspace = true
+zlog.workspace = true
 
 [build-dependencies]
 cargo_toml.workspace = true

crates/remote_server/src/headless_project.rs 🔗

@@ -1,5 +1,5 @@
 use ::proto::{FromProto, ToProto};
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result, anyhow};
 
 use extension::ExtensionHostProxy;
 use extension_host::headless_host::HeadlessExtensionStore;
@@ -9,8 +9,8 @@ use http_client::HttpClient;
 use language::{Buffer, BufferEvent, LanguageRegistry, proto::serialize_operation};
 use node_runtime::NodeRuntime;
 use project::{
-    LspStore, LspStoreEvent, PrettierStore, ProjectEnvironment, ProjectPath, ToolchainStore,
-    WorktreeId,
+    LspStore, LspStoreEvent, ManifestTree, PrettierStore, ProjectEnvironment, ProjectPath,
+    ToolchainStore, WorktreeId,
     buffer_store::{BufferStore, BufferStoreEvent},
     debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore},
     git_store::GitStore,
@@ -76,6 +76,7 @@ impl HeadlessProject {
         }: HeadlessAppState,
         cx: &mut Context<Self>,
     ) -> Self {
+        debug_adapter_extension::init(proxy.clone(), cx);
         language_extension::init(proxy.clone(), languages.clone());
         languages::init(languages.clone(), node_runtime.clone(), cx);
 
@@ -86,12 +87,13 @@ impl HeadlessProject {
         });
 
         let environment = cx.new(|_| ProjectEnvironment::new(None));
-
+        let manifest_tree = ManifestTree::new(worktree_store.clone(), cx);
         let toolchain_store = cx.new(|cx| {
             ToolchainStore::local(
                 languages.clone(),
                 worktree_store.clone(),
                 environment.clone(),
+                manifest_tree.clone(),
                 cx,
             )
         });
@@ -144,6 +146,7 @@ impl HeadlessProject {
 
         let task_store = cx.new(|cx| {
             let mut task_store = TaskStore::local(
+                fs.clone(),
                 buffer_store.downgrade(),
                 worktree_store.clone(),
                 toolchain_store.read(cx).as_language_toolchain_store(),
@@ -171,6 +174,7 @@ impl HeadlessProject {
                 prettier_store.clone(),
                 toolchain_store.clone(),
                 environment,
+                manifest_tree,
                 languages.clone(),
                 http_client.clone(),
                 fs.clone(),
@@ -297,11 +301,13 @@ impl HeadlessProject {
         match event {
             LspStoreEvent::LanguageServerUpdate {
                 language_server_id,
+                name,
                 message,
             } => {
                 self.session
                     .send(proto::UpdateLanguageServer {
                         project_id: SSH_PROJECT_ID,
+                        server_name: name.as_ref().map(|name| name.to_string()),
                         language_server_id: language_server_id.to_proto(),
                         variant: Some(message.clone()),
                     })
@@ -367,7 +373,7 @@ impl HeadlessProject {
                 let mut parent = path
                     .parent()
                     .ok_or(e)
-                    .map_err(|_| anyhow!("{:?} does not exist", path))?;
+                    .with_context(|| format!("{path:?} does not exist"))?;
                 if parent == Path::new("") {
                     parent = util::paths::home_dir();
                 }
@@ -382,7 +388,7 @@ impl HeadlessProject {
         };
 
         let worktree = this
-            .update(&mut cx.clone(), |this, _| {
+            .read_with(&mut cx.clone(), |this, _| {
                 Worktree::local(
                     Arc::from(canonicalized.as_path()),
                     message.payload.visible,
@@ -393,11 +399,12 @@ impl HeadlessProject {
             })?
             .await?;
 
-        let response = this.update(&mut cx, |_, cx| {
-            worktree.update(cx, |worktree, _| proto::AddWorktreeResponse {
+        let response = this.read_with(&mut cx, |_, cx| {
+            let worktree = worktree.read(cx);
+            proto::AddWorktreeResponse {
                 worktree_id: worktree.id().to_proto(),
                 canonicalized_path: canonicalized.to_proto(),
-            })
+            }
         })?;
 
         // We spawn this asynchronously, so that we can send the response back
@@ -535,7 +542,7 @@ impl HeadlessProject {
                 });
             }
 
-            let buffer_id = buffer.read_with(cx, |b, _| b.remote_id());
+            let buffer_id = buffer.read(cx).remote_id();
 
             buffer_store.update(cx, |buffer_store, cx| {
                 buffer_store
@@ -557,11 +564,7 @@ impl HeadlessProject {
         mut cx: AsyncApp,
     ) -> Result<proto::FindSearchCandidatesResponse> {
         let message = envelope.payload;
-        let query = SearchQuery::from_proto(
-            message
-                .query
-                .ok_or_else(|| anyhow!("missing query field"))?,
-        )?;
+        let query = SearchQuery::from_proto(message.query.context("missing query field")?)?;
         let results = this.update(&mut cx, |this, cx| {
             this.buffer_store.update(cx, |buffer_store, cx| {
                 buffer_store.find_search_candidates(&query, message.limit as _, this.fs.clone(), cx)
@@ -575,7 +578,7 @@ impl HeadlessProject {
         let buffer_store = this.read_with(&cx, |this, _| this.buffer_store.clone())?;
 
         while let Ok(buffer) = results.recv().await {
-            let buffer_id = buffer.update(&mut cx, |this, _| this.remote_id())?;
+            let buffer_id = buffer.read_with(&mut cx, |this, _| this.remote_id())?;
             response.buffer_ids.push(buffer_id.to_proto());
             buffer_store
                 .update(&mut cx, |buffer_store, cx| {

crates/remote_server/src/main.rs 🔗

@@ -12,6 +12,9 @@ struct Cli {
     /// by having Zed act like netcat communicating over a Unix socket.
     #[arg(long, hide = true)]
     askpass: Option<String>,
+    /// Used for loading the environment from the project.
+    #[arg(long, hide = true)]
+    printenv: bool,
 }
 
 #[derive(Subcommand)]
@@ -55,6 +58,11 @@ fn main() {
         return;
     }
 
+    if cli.printenv {
+        util::shell_env::print_env();
+        return;
+    }
+
     let result = match cli.command {
         Some(Commands::Run {
             log_file,

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -2,7 +2,7 @@
 /// The tests in this file assume that server_cx is running on Windows too.
 /// We neead to find a way to test Windows-Non-Windows interactions.
 use crate::headless_project::HeadlessProject;
-use assistant_tool::Tool as _;
+use assistant_tool::{Tool as _, ToolResultContent};
 use assistant_tools::{ReadFileTool, ReadFileToolInput};
 use client::{Client, UserStore};
 use clock::FakeSystemClock;
@@ -33,7 +33,7 @@ use std::{
 };
 #[cfg(not(windows))]
 use unindent::Unindent as _;
-use util::{path, separator};
+use util::path;
 
 #[gpui::test]
 async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
@@ -218,7 +218,7 @@ async fn test_remote_project_search(cx: &mut TestAppContext, server_cx: &mut Tes
         buffer.update(&mut cx, |buffer, cx| {
             assert_eq!(
                 buffer.file().unwrap().full_path(cx).to_string_lossy(),
-                separator!("project1/README.md")
+                path!("project1/README.md")
             )
         });
 
@@ -422,7 +422,12 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
             "Rust",
             FakeLspAdapter {
                 name: "rust-analyzer",
-                ..Default::default()
+                capabilities: lsp::ServerCapabilities {
+                    completion_provider: Some(lsp::CompletionOptions::default()),
+                    rename_provider: Some(lsp::OneOf::Left(true)),
+                    ..lsp::ServerCapabilities::default()
+                },
+                ..FakeLspAdapter::default()
             },
         )
     });
@@ -430,7 +435,11 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
     let mut fake_lsp = server_cx.update(|cx| {
         headless.read(cx).languages.register_fake_language_server(
             LanguageServerName("rust-analyzer".into()),
-            Default::default(),
+            lsp::ServerCapabilities {
+                completion_provider: Some(lsp::CompletionOptions::default()),
+                rename_provider: Some(lsp::OneOf::Left(true)),
+                ..lsp::ServerCapabilities::default()
+            },
             None,
         )
     });
@@ -513,8 +522,8 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
 
     assert_eq!(
         result
-            .unwrap()
             .into_iter()
+            .flat_map(|response| response.completions)
             .map(|c| c.label.text)
             .collect::<Vec<_>>(),
         vec!["boop".to_string()]
@@ -1356,6 +1365,7 @@ async fn test_remote_git_diffs(cx: &mut TestAppContext, server_cx: &mut TestAppC
     fs.set_head_for_repo(
         Path::new("/code/project1/.git"),
         &[("src/lib.rs".into(), text_1.clone())],
+        "deadbeef",
     );
 
     let (project, _headless) = init_test(&fs, cx, server_cx).await;
@@ -1416,6 +1426,149 @@ async fn test_remote_git_diffs(cx: &mut TestAppContext, server_cx: &mut TestAppC
     fs.set_head_for_repo(
         Path::new("/code/project1/.git"),
         &[("src/lib.rs".into(), text_2.clone())],
+        "deadbeef",
+    );
+
+    cx.executor().run_until_parked();
+    diff.read_with(cx, |diff, cx| {
+        assert_eq!(diff.base_text_string().unwrap(), text_2);
+        assert_eq!(
+            diff.secondary_diff()
+                .unwrap()
+                .read(cx)
+                .base_text_string()
+                .unwrap(),
+            text_2
+        );
+    });
+}
+
+// TODO: this test fails on Windows.
+#[cfg(not(windows))]
+#[gpui::test]
+async fn test_remote_git_diffs_when_recv_update_repository_delay(
+    cx: &mut TestAppContext,
+    server_cx: &mut TestAppContext,
+) {
+    use editor::Editor;
+    use gpui::VisualContext;
+    let text_2 = "
+        fn one() -> usize {
+            1
+        }
+    "
+    .unindent();
+    let text_1 = "
+        fn one() -> usize {
+            0
+        }
+    "
+    .unindent();
+
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        "/code",
+        json!({
+            "project1": {
+                "src": {
+                    "lib.rs": text_2
+                },
+                "README.md": "# project 1",
+            },
+        }),
+    )
+    .await;
+
+    let (project, _headless) = init_test(&fs, cx, server_cx).await;
+    let (worktree, _) = project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree("/code/project1", true, cx)
+        })
+        .await
+        .unwrap();
+    let worktree_id = cx.update(|cx| worktree.read(cx).id());
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
+        })
+        .await
+        .unwrap();
+    let buffer_id = cx.update(|cx| buffer.read(cx).remote_id());
+    cx.update(|cx| {
+        workspace::init_settings(cx);
+        editor::init_settings(cx);
+    });
+    let cx = cx.add_empty_window();
+    let editor = cx.new_window_entity(|window, cx| {
+        Editor::for_buffer(buffer, Some(project.clone()), window, cx)
+    });
+
+    // Remote server will send proto::UpdateRepository after the instance of Editor create.
+    fs.insert_tree(
+        "/code",
+        json!({
+            "project1": {
+                ".git": {},
+            },
+        }),
+    )
+    .await;
+
+    fs.set_index_for_repo(
+        Path::new("/code/project1/.git"),
+        &[("src/lib.rs".into(), text_1.clone())],
+    );
+    fs.set_head_for_repo(
+        Path::new("/code/project1/.git"),
+        &[("src/lib.rs".into(), text_1.clone())],
+        "sha",
+    );
+
+    cx.executor().run_until_parked();
+    let diff = editor
+        .read_with(cx, |editor, cx| {
+            editor
+                .buffer()
+                .read_with(cx, |buffer, _| buffer.diff_for(buffer_id))
+        })
+        .unwrap();
+
+    diff.read_with(cx, |diff, cx| {
+        assert_eq!(diff.base_text_string().unwrap(), text_1);
+        assert_eq!(
+            diff.secondary_diff()
+                .unwrap()
+                .read(cx)
+                .base_text_string()
+                .unwrap(),
+            text_1
+        );
+    });
+
+    // stage the current buffer's contents
+    fs.set_index_for_repo(
+        Path::new("/code/project1/.git"),
+        &[("src/lib.rs".into(), text_2.clone())],
+    );
+
+    cx.executor().run_until_parked();
+    diff.read_with(cx, |diff, cx| {
+        assert_eq!(diff.base_text_string().unwrap(), text_1);
+        assert_eq!(
+            diff.secondary_diff()
+                .unwrap()
+                .read(cx)
+                .base_text_string()
+                .unwrap(),
+            text_2
+        );
+    });
+
+    // commit the current buffer's contents
+    fs.set_head_for_repo(
+        Path::new("/code/project1/.git"),
+        &[("src/lib.rs".into(), text_2.clone())],
+        "sha",
     );
 
     cx.executor().run_until_parked();
@@ -1593,7 +1746,7 @@ async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mu
         )
     });
     let output = exists_result.output.await.unwrap().content;
-    assert_eq!(output, "B");
+    assert_eq!(output, ToolResultContent::Text("B".to_string()));
 
     let input = ReadFileToolInput {
         path: "project/c.txt".into(),
@@ -1663,9 +1816,7 @@ pub async fn init_test(
 }
 
 fn init_logger() {
-    if std::env::var("RUST_LOG").is_ok() {
-        env_logger::try_init().ok();
-    }
+    zlog::init_test();
 }
 
 fn build_project(ssh: Entity<SshRemoteClient>, cx: &mut TestAppContext) -> Entity<Project> {

crates/remote_server/src/unix.rs 🔗

@@ -257,7 +257,7 @@ fn start_server(
     log_rx: Receiver<Vec<u8>>,
     cx: &mut App,
 ) -> Arc<ChannelClient> {
-    // This is the server idle timeout. If no connection comes in in this timeout, the server will shut down.
+    // This is the server idle timeout. If no connection comes in this timeout, the server will shut down.
     const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10 * 60);
 
     let (incoming_tx, incoming_rx) = mpsc::unbounded::<Envelope>();
@@ -333,7 +333,7 @@ fn start_server(
                             break;
                         };
                         if let Err(error) = incoming_tx.unbounded_send(message) {
-                            log::error!("failed to send message to application: {:?}. exiting.", error);
+                            log::error!("failed to send message to application: {error:?}. exiting.");
                             return Err(anyhow!(error));
                         }
                     }
@@ -390,8 +390,7 @@ fn init_paths() -> anyhow::Result<()> {
     ]
     .iter()
     {
-        std::fs::create_dir_all(path)
-            .map_err(|e| anyhow!("Could not create directory {:?}: {}", path, e))?;
+        std::fs::create_dir_all(path).with_context(|| format!("creating directory {path:?}"))?;
     }
     Ok(())
 }
@@ -542,7 +541,7 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
     if is_reconnecting {
         if !server_running {
             log::error!("attempted to reconnect, but no server running");
-            return Err(anyhow!(ProxyLaunchError::ServerNotRunning));
+            anyhow::bail!(ProxyLaunchError::ServerNotRunning);
         }
     } else {
         if let Some(pid) = server_pid {
@@ -573,19 +572,20 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
         let mut stream = smol::net::unix::UnixStream::connect(&server_paths.stderr_socket).await?;
         let mut stderr_buffer = vec![0; 2048];
         loop {
-            match stream.read(&mut stderr_buffer).await {
-                Ok(0) => {
+            match stream
+                .read(&mut stderr_buffer)
+                .await
+                .context("reading stderr")?
+            {
+                0 => {
                     let error =
                         std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "stderr closed");
                     Err(anyhow!(error))?;
                 }
-                Ok(n) => {
+                n => {
                     stderr.write_all(&mut stderr_buffer[..n]).await?;
                     stderr.flush().await?;
                 }
-                Err(error) => {
-                    Err(anyhow!("error reading stderr: {error:?}"))?;
-                }
             }
         }
     });
@@ -756,7 +756,7 @@ fn initialize_settings(
     session: Arc<ChannelClient>,
     fs: Arc<dyn Fs>,
     cx: &mut App,
-) -> async_watch::Receiver<Option<NodeBinaryOptions>> {
+) -> watch::Receiver<Option<NodeBinaryOptions>> {
     let user_settings_file_rx = watch_config_file(
         &cx.background_executor(),
         fs,
@@ -791,7 +791,7 @@ fn initialize_settings(
         }
     });
 
-    let (tx, rx) = async_watch::channel(None);
+    let (mut tx, rx) = watch::channel(None);
     cx.observe_global::<SettingsStore>(move |cx| {
         let settings = &ProjectSettings::get_global(cx).node;
         log::info!("Got new node settings: {:?}", settings);
@@ -868,7 +868,7 @@ fn read_proxy_settings(cx: &mut Context<HeadlessProject>) -> Option<Url> {
 }
 
 fn daemonize() -> Result<ControlFlow<()>> {
-    match fork::fork().map_err(|e| anyhow::anyhow!("failed to call fork with error code {}", e))? {
+    match fork::fork().map_err(|e| anyhow!("failed to call fork with error code {e}"))? {
         fork::Fork::Parent(_) => {
             return Ok(ControlFlow::Break(()));
         }

crates/repl/src/kernels/mod.rs 🔗

@@ -92,7 +92,7 @@ pub fn python_env_kernel_specifications(
     let background_executor = cx.background_executor().clone();
 
     async move {
-        let toolchains = if let Some(toolchains) = toolchains.await {
+        let toolchains = if let Some((toolchains, _)) = toolchains.await {
             toolchains
         } else {
             return Ok(Vec::new());

crates/repl/src/kernels/native_kernel.rs 🔗

@@ -351,14 +351,7 @@ impl RunningKernel for NativeRunningKernel {
     fn force_shutdown(&mut self, _window: &mut Window, _cx: &mut App) -> Task<anyhow::Result<()>> {
         self._process_status_task.take();
         self.request_tx.close_channel();
-
-        Task::ready(match self.process.kill() {
-            Ok(_) => Ok(()),
-            Err(error) => Err(anyhow::anyhow!(
-                "Failed to kill the kernel process: {}",
-                error
-            )),
-        })
+        Task::ready(self.process.kill().context("killing the kernel process"))
     }
 }
 

crates/repl/src/kernels/remote_kernels.rs 🔗

@@ -54,7 +54,7 @@ pub async fn launch_remote_kernel(
     if !response.status().is_success() {
         let mut body = String::new();
         response.into_body().read_to_string(&mut body).await?;
-        return Err(anyhow::anyhow!("Failed to launch kernel: {}", body));
+        anyhow::bail!("Failed to launch kernel: {body}");
     }
 
     let mut body = String::new();
@@ -79,36 +79,31 @@ pub async fn list_remote_kernelspecs(
 
     let response = http_client.send(request).await?;
 
-    if response.status().is_success() {
-        let mut body = response.into_body();
-
-        let mut body_bytes = Vec::new();
-        body.read_to_end(&mut body_bytes).await?;
-
-        let kernel_specs: KernelSpecsResponse = serde_json::from_slice(&body_bytes)?;
-
-        let remote_kernelspecs = kernel_specs
-            .kernelspecs
-            .into_iter()
-            .map(|(name, spec)| RemoteKernelSpecification {
-                name: name.clone(),
-                url: remote_server.base_url.clone(),
-                token: remote_server.token.clone(),
-                kernelspec: spec.spec,
-            })
-            .collect::<Vec<RemoteKernelSpecification>>();
-
-        if remote_kernelspecs.is_empty() {
-            Err(anyhow::anyhow!("No kernel specs found"))
-        } else {
-            Ok(remote_kernelspecs.clone())
-        }
-    } else {
-        Err(anyhow::anyhow!(
-            "Failed to fetch kernel specs: {}",
-            response.status()
-        ))
-    }
+    anyhow::ensure!(
+        response.status().is_success(),
+        "Failed to fetch kernel specs: {}",
+        response.status()
+    );
+    let mut body = response.into_body();
+
+    let mut body_bytes = Vec::new();
+    body.read_to_end(&mut body_bytes).await?;
+
+    let kernel_specs: KernelSpecsResponse = serde_json::from_slice(&body_bytes)?;
+
+    let remote_kernelspecs = kernel_specs
+        .kernelspecs
+        .into_iter()
+        .map(|(name, spec)| RemoteKernelSpecification {
+            name: name.clone(),
+            url: remote_server.base_url.clone(),
+            token: remote_server.token.clone(),
+            kernelspec: spec.spec,
+        })
+        .collect::<Vec<RemoteKernelSpecification>>();
+
+    anyhow::ensure!(!remote_kernelspecs.is_empty(), "No kernel specs found");
+    Ok(remote_kernelspecs.clone())
 }
 
 impl PartialEq for RemoteKernelSpecification {
@@ -288,14 +283,12 @@ impl RunningKernel for RemoteRunningKernel {
 
             let response = http_client.send(request).await?;
 
-            if response.status().is_success() {
-                Ok(())
-            } else {
-                Err(anyhow::anyhow!(
-                    "Failed to shutdown kernel: {}",
-                    response.status()
-                ))
-            }
+            anyhow::ensure!(
+                response.status().is_success(),
+                "Failed to shutdown kernel: {}",
+                response.status()
+            );
+            Ok(())
         })
     }
 }

crates/repl/src/notebook/cell.rs 🔗

@@ -177,7 +177,10 @@ impl Cell {
 
                 let editor_view = cx.new(|cx| {
                     let mut editor = Editor::new(
-                        EditorMode::AutoHeight { max_lines: 1024 },
+                        EditorMode::AutoHeight {
+                            min_lines: 1,
+                            max_lines: Some(1024),
+                        },
                         multi_buffer,
                         None,
                         window,

crates/repl/src/notebook/notebook_ui.rs 🔗

@@ -15,7 +15,7 @@ use gpui::{
 use language::{Language, LanguageRegistry};
 use project::{Project, ProjectEntryId, ProjectPath};
 use ui::{Tooltip, prelude::*};
-use workspace::item::{ItemEvent, TabContentParams};
+use workspace::item::{ItemEvent, SaveOptions, TabContentParams};
 use workspace::searchable::SearchableItemHandle;
 use workspace::{Item, ItemHandle, Pane, ProjectItem, ToolbarItemLocation};
 use workspace::{ToolbarItemEvent, ToolbarItemView};
@@ -565,7 +565,7 @@ impl project::ProjectItem for NotebookItem {
         project: &Entity<Project>,
         path: &ProjectPath,
         cx: &mut App,
-    ) -> Option<Task<gpui::Result<Entity<Self>>>> {
+    ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
         let path = path.clone();
         let project = project.clone();
         let fs = project.read(cx).fs().clone();
@@ -575,7 +575,7 @@ impl project::ProjectItem for NotebookItem {
             Some(cx.spawn(async move |cx| {
                 let abs_path = project
                     .read_with(cx, |project, cx| project.absolute_path(&path, cx))?
-                    .ok_or_else(|| anyhow::anyhow!("Failed to find the absolute path"))?;
+                    .with_context(|| format!("finding the absolute path of {path:?}"))?;
 
                 // todo: watch for changes to the file
                 let file_content = fs.load(&abs_path.as_path()).await?;
@@ -782,7 +782,7 @@ impl Item for NotebookEditor {
     // TODO
     fn save(
         &mut self,
-        _format: bool,
+        _options: SaveOptions,
         _project: Entity<Project>,
         _window: &mut Window,
         _cx: &mut Context<Self>,

crates/repl/src/outputs.rs 🔗

@@ -221,7 +221,9 @@ impl Output {
         };
 
         h_flex()
+            .id("output-content")
             .w_full()
+            .overflow_x_scroll()
             .items_start()
             .child(div().flex_1().children(content))
             .children(match self {

crates/repl/src/outputs/image.rs 🔗

@@ -51,8 +51,8 @@ impl ImageView {
             image::ImageFormat::WebP => ImageFormat::Webp,
             image::ImageFormat::Tiff => ImageFormat::Tiff,
             image::ImageFormat::Bmp => ImageFormat::Bmp,
-            _ => {
-                return Err(anyhow::anyhow!("unsupported image format"));
+            format => {
+                anyhow::bail!("unsupported image format {format:?}");
             }
         };
 

crates/repl/src/outputs/plain.rs 🔗

@@ -258,7 +258,7 @@ impl Render for TerminalOutput {
                 cell: ic.cell.clone(),
             });
         let (cells, rects) =
-            TerminalElement::layout_grid(grid, &text_style, text_system, None, window, cx);
+            TerminalElement::layout_grid(grid, 0, &text_style, text_system, None, window, cx);
 
         // lines are 0-indexed, so we must add 1 to get the number of lines
         let text_line_height = text_style.line_height_in_pixels(window.rem_size());

crates/repl/src/outputs/table.rs 🔗

@@ -106,10 +106,7 @@ impl TableView {
 
         for field in table.schema.fields.iter() {
             runs[0].len = field.name.len();
-            let mut width = text_system
-                .layout_line(&field.name, font_size, &runs)
-                .map(|layout| layout.width)
-                .unwrap_or(px(0.));
+            let mut width = text_system.layout_line(&field.name, font_size, &runs).width;
 
             let Some(data) = table.data.as_ref() else {
                 widths.push(width);
@@ -122,8 +119,7 @@ impl TableView {
                 let cell_width = window
                     .text_system()
                     .layout_line(&content, font_size, &runs)
-                    .map(|layout| layout.width)
-                    .unwrap_or(px(0.));
+                    .width;
 
                 width = width.max(cell_width)
             }

crates/repl/src/repl_editor.rs 🔗

@@ -97,7 +97,7 @@ pub fn run(
     };
 
     let (runnable_ranges, next_cell_point) =
-        runnable_ranges(&buffer.read(cx).snapshot(), selected_range);
+        runnable_ranges(&buffer.read(cx).snapshot(), selected_range, cx);
 
     for runnable_range in runnable_ranges {
         let Some(language) = multibuffer.read(cx).language_at(runnable_range.start, cx) else {
@@ -107,7 +107,7 @@ pub fn run(
         let kernel_specification = store
             .read(cx)
             .active_kernelspec(project_path.worktree_id, Some(language.clone()), cx)
-            .ok_or_else(|| anyhow::anyhow!("No kernel found for language: {}", language.name()))?;
+            .with_context(|| format!("No kernel found for language: {}", language.name()))?;
 
         let fs = store.read(cx).fs().clone();
 
@@ -170,7 +170,6 @@ pub fn run(
     anyhow::Ok(())
 }
 
-#[allow(clippy::large_enum_variant)]
 pub enum SessionSupport {
     ActiveSession(Entity<Session>),
     Inactive(KernelSpecification),
@@ -216,7 +215,8 @@ pub fn session(editor: WeakEntity<Editor>, cx: &mut App) -> SessionSupport {
     match kernelspec {
         Some(kernelspec) => SessionSupport::Inactive(kernelspec),
         None => {
-            if language_supported(&language.clone()) {
+            // For language_supported, need to check available kernels for language
+            if language_supported(&language.clone(), cx) {
                 SessionSupport::RequiresSetup(language.name())
             } else {
                 SessionSupport::Unsupported
@@ -415,10 +415,11 @@ fn jupytext_cells(
 fn runnable_ranges(
     buffer: &BufferSnapshot,
     range: Range<Point>,
+    cx: &mut App,
 ) -> (Vec<Range<Point>>, Option<Point>) {
     if let Some(language) = buffer.language() {
         if language.name() == "Markdown".into() {
-            return (markdown_code_blocks(buffer, range.clone()), None);
+            return (markdown_code_blocks(buffer, range.clone(), cx), None);
         }
     }
 
@@ -443,21 +444,30 @@ fn runnable_ranges(
 
 // We allow markdown code blocks to end in a trailing newline in order to render the output
 // below the final code fence. This is different than our behavior for selections and Jupytext cells.
-fn markdown_code_blocks(buffer: &BufferSnapshot, range: Range<Point>) -> Vec<Range<Point>> {
+fn markdown_code_blocks(
+    buffer: &BufferSnapshot,
+    range: Range<Point>,
+    cx: &mut App,
+) -> Vec<Range<Point>> {
     buffer
         .injections_intersecting_range(range)
-        .filter(|(_, language)| language_supported(language))
+        .filter(|(_, language)| language_supported(language, cx))
         .map(|(content_range, _)| {
             buffer.offset_to_point(content_range.start)..buffer.offset_to_point(content_range.end)
         })
         .collect()
 }
 
-fn language_supported(language: &Arc<Language>) -> bool {
-    match language.name().as_ref() {
-        "TypeScript" | "Python" => true,
-        _ => false,
-    }
+fn language_supported(language: &Arc<Language>, cx: &mut App) -> bool {
+    let store = ReplStore::global(cx);
+    let store_read = store.read(cx);
+
+    // Since we're just checking for general language support, we only need to look at
+    // the pure Jupyter kernels - these are all the globally available ones
+    store_read.pure_jupyter_kernel_specifications().any(|spec| {
+        // Convert to lowercase for case-insensitive comparison since kernels might report "python" while our language is "Python"
+        spec.language().as_ref().to_lowercase() == language.name().as_ref().to_lowercase()
+    })
 }
 
 fn get_language(editor: WeakEntity<Editor>, cx: &mut App) -> Option<Arc<Language>> {
@@ -507,7 +517,7 @@ mod tests {
         let snapshot = buffer.read(cx).snapshot();
 
         // Single-point selection
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 4)..Point::new(0, 4));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 4)..Point::new(0, 4), cx);
         let snippets = snippets
             .into_iter()
             .map(|range| snapshot.text_for_range(range).collect::<String>())
@@ -515,7 +525,7 @@ mod tests {
         assert_eq!(snippets, vec!["print(1 + 1)"]);
 
         // Multi-line selection
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 5)..Point::new(2, 0));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 5)..Point::new(2, 0), cx);
         let snippets = snippets
             .into_iter()
             .map(|range| snapshot.text_for_range(range).collect::<String>())
@@ -528,7 +538,7 @@ mod tests {
         );
 
         // Trimming multiple trailing blank lines
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 5)..Point::new(5, 0));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 5)..Point::new(5, 0), cx);
 
         let snippets = snippets
             .into_iter()
@@ -581,7 +591,7 @@ mod tests {
         let snapshot = buffer.read(cx).snapshot();
 
         // Jupytext snippet surrounding an empty selection
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 5)..Point::new(2, 5));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 5)..Point::new(2, 5), cx);
 
         let snippets = snippets
             .into_iter()
@@ -597,7 +607,7 @@ mod tests {
         );
 
         // Jupytext snippets intersecting a non-empty selection
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 5)..Point::new(6, 2));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 5)..Point::new(6, 2), cx);
         let snippets = snippets
             .into_iter()
             .map(|range| snapshot.text_for_range(range).collect::<String>())
@@ -624,6 +634,49 @@ mod tests {
 
     #[gpui::test]
     fn test_markdown_code_blocks(cx: &mut App) {
+        use crate::kernels::LocalKernelSpecification;
+        use jupyter_protocol::JupyterKernelspec;
+
+        // Initialize settings
+        settings::init(cx);
+        editor::init(cx);
+
+        // Initialize the ReplStore with a fake filesystem
+        let fs = Arc::new(project::RealFs::new(None, cx.background_executor().clone()));
+        ReplStore::init(fs, cx);
+
+        // Add mock kernel specifications for TypeScript and Python
+        let store = ReplStore::global(cx);
+        store.update(cx, |store, cx| {
+            let typescript_spec = KernelSpecification::Jupyter(LocalKernelSpecification {
+                name: "typescript".into(),
+                kernelspec: JupyterKernelspec {
+                    argv: vec![],
+                    display_name: "TypeScript".into(),
+                    language: "typescript".into(),
+                    interrupt_mode: None,
+                    metadata: None,
+                    env: None,
+                },
+                path: std::path::PathBuf::new(),
+            });
+
+            let python_spec = KernelSpecification::Jupyter(LocalKernelSpecification {
+                name: "python".into(),
+                kernelspec: JupyterKernelspec {
+                    argv: vec![],
+                    display_name: "Python".into(),
+                    language: "python".into(),
+                    interrupt_mode: None,
+                    metadata: None,
+                    env: None,
+                },
+                path: std::path::PathBuf::new(),
+            });
+
+            store.set_kernel_specs_for_testing(vec![typescript_spec, python_spec], cx);
+        });
+
         let markdown = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
         let typescript = languages::language(
             "typescript",
@@ -659,7 +712,7 @@ mod tests {
         });
         let snapshot = buffer.read(cx).snapshot();
 
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(3, 5)..Point::new(8, 5));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(3, 5)..Point::new(8, 5), cx);
         let snippets = snippets
             .into_iter()
             .map(|range| snapshot.text_for_range(range).collect::<String>())
@@ -704,7 +757,7 @@ mod tests {
         });
         let snapshot = buffer.read(cx).snapshot();
 
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(3, 5)..Point::new(12, 5));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(3, 5)..Point::new(12, 5), cx);
         let snippets = snippets
             .into_iter()
             .map(|range| snapshot.text_for_range(range).collect::<String>())
@@ -743,7 +796,7 @@ mod tests {
         });
         let snapshot = buffer.read(cx).snapshot();
 
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(4, 5)..Point::new(5, 5));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(4, 5)..Point::new(5, 5), cx);
         let snippets = snippets
             .into_iter()
             .map(|range| snapshot.text_for_range(range).collect::<String>())

crates/repl/src/repl_store.rs 🔗

@@ -1,6 +1,6 @@
 use std::sync::Arc;
 
-use anyhow::Result;
+use anyhow::{Context as _, Result};
 use collections::HashMap;
 use command_palette_hooks::CommandPaletteFilter;
 use gpui::{App, Context, Entity, EntityId, Global, Subscription, Task, prelude::*};
@@ -125,7 +125,7 @@ impl ReplStore {
         cx.spawn(async move |this, cx| {
             let kernel_specifications = kernel_specifications
                 .await
-                .map_err(|e| anyhow::anyhow!("Failed to get python kernelspecs: {:?}", e))?;
+                .context("getting python kernelspecs")?;
 
             this.update(cx, |this, cx| {
                 this.kernel_specifications_for_worktree
@@ -279,4 +279,14 @@ impl ReplStore {
     pub fn remove_session(&mut self, entity_id: EntityId) {
         self.sessions.remove(&entity_id);
     }
+
+    #[cfg(test)]
+    pub fn set_kernel_specs_for_testing(
+        &mut self,
+        specs: Vec<KernelSpecification>,
+        cx: &mut Context<Self>,
+    ) {
+        self.kernel_specifications = specs;
+        cx.notify();
+    }
 }

crates/repl/src/session.rs 🔗

@@ -6,7 +6,9 @@ use crate::{
     kernels::{Kernel, KernelSpecification, NativeRunningKernel},
     outputs::{ExecutionStatus, ExecutionView},
 };
+use anyhow::Context as _;
 use collections::{HashMap, HashSet};
+use editor::SelectionEffects;
 use editor::{
     Anchor, AnchorRangeExt as _, Editor, MultiBuffer, ToPoint,
     display_map::{
@@ -57,13 +59,8 @@ impl EditorBlock {
         on_close: CloseBlockFn,
         cx: &mut Context<Session>,
     ) -> anyhow::Result<Self> {
-        let editor = editor
-            .upgrade()
-            .ok_or_else(|| anyhow::anyhow!("editor is not open"))?;
-        let workspace = editor
-            .read(cx)
-            .workspace()
-            .ok_or_else(|| anyhow::anyhow!("workspace dropped"))?;
+        let editor = editor.upgrade().context("editor is not open")?;
+        let workspace = editor.read(cx).workspace().context("workspace dropped")?;
 
         let execution_view = cx.new(|cx| ExecutionView::new(status, workspace.downgrade(), cx));
 
@@ -166,7 +163,7 @@ impl EditorBlock {
 
             div()
                 .id(cx.block_id)
-                .block_mouse_down()
+                .block_mouse_except_scroll()
                 .flex()
                 .items_start()
                 .min_h(text_line_height)
@@ -481,7 +478,7 @@ impl Session {
         if move_down {
             editor.update(cx, move |editor, cx| {
                 editor.change_selections(
-                    Some(Autoscroll::top_relative(8)),
+                    SelectionEffects::scroll(Autoscroll::top_relative(8)),
                     window,
                     cx,
                     |selections| {

crates/reqwest_client/src/reqwest_client.rs 🔗

@@ -220,7 +220,7 @@ impl http_client::HttpClient for ReqwestClient {
         req: http::Request<http_client::AsyncBody>,
     ) -> futures::future::BoxFuture<
         'static,
-        Result<http_client::Response<http_client::AsyncBody>, anyhow::Error>,
+        anyhow::Result<http_client::Response<http_client::AsyncBody>>,
     > {
         let (parts, body) = req.into_parts();
 

crates/rope/Cargo.toml 🔗

@@ -23,11 +23,11 @@ workspace-hack.workspace = true
 
 [dev-dependencies]
 ctor.workspace = true
-env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 rand.workspace = true
 util = { workspace = true, features = ["test-support"] }
-criterion = { version = "0.5", features = ["html_reports"] }
+criterion.workspace = true
+zlog.workspace = true
 
 [[bench]]
 name = "rope_benchmark"

crates/rope/src/chunk.rs 🔗

@@ -53,7 +53,7 @@ impl Chunk {
     }
 
     #[inline(always)]
-    pub fn as_slice(&self) -> ChunkSlice {
+    pub fn as_slice(&self) -> ChunkSlice<'_> {
         ChunkSlice {
             chars: self.chars,
             chars_utf16: self.chars_utf16,
@@ -64,7 +64,7 @@ impl Chunk {
     }
 
     #[inline(always)]
-    pub fn slice(&self, range: Range<usize>) -> ChunkSlice {
+    pub fn slice(&self, range: Range<usize>) -> ChunkSlice<'_> {
         self.as_slice().slice(range)
     }
 }

crates/rope/src/rope.rs 🔗

@@ -241,7 +241,7 @@ impl Rope {
         self.chunks.extent(&())
     }
 
-    pub fn cursor(&self, offset: usize) -> Cursor {
+    pub fn cursor(&self, offset: usize) -> Cursor<'_> {
         Cursor::new(self, offset)
     }
 
@@ -258,23 +258,23 @@ impl Rope {
             .flat_map(|chunk| chunk.chars().rev())
     }
 
-    pub fn bytes_in_range(&self, range: Range<usize>) -> Bytes {
+    pub fn bytes_in_range(&self, range: Range<usize>) -> Bytes<'_> {
         Bytes::new(self, range, false)
     }
 
-    pub fn reversed_bytes_in_range(&self, range: Range<usize>) -> Bytes {
+    pub fn reversed_bytes_in_range(&self, range: Range<usize>) -> Bytes<'_> {
         Bytes::new(self, range, true)
     }
 
-    pub fn chunks(&self) -> Chunks {
+    pub fn chunks(&self) -> Chunks<'_> {
         self.chunks_in_range(0..self.len())
     }
 
-    pub fn chunks_in_range(&self, range: Range<usize>) -> Chunks {
+    pub fn chunks_in_range(&self, range: Range<usize>) -> Chunks<'_> {
         Chunks::new(self, range, false)
     }
 
-    pub fn reversed_chunks_in_range(&self, range: Range<usize>) -> Chunks {
+    pub fn reversed_chunks_in_range(&self, range: Range<usize>) -> Chunks<'_> {
         Chunks::new(self, range, true)
     }
 
@@ -1435,9 +1435,7 @@ mod tests {
 
     #[ctor::ctor]
     fn init_logger() {
-        if std::env::var("RUST_LOG").is_ok() {
-            env_logger::init();
-        }
+        zlog::init_test();
     }
 
     #[test]

crates/rpc/Cargo.toml 🔗

@@ -40,6 +40,6 @@ workspace-hack.workspace = true
 
 [dev-dependencies]
 collections = { workspace = true, features = ["test-support"] }
-env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 proto = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/rpc/src/conn.rs 🔗

@@ -4,12 +4,8 @@ use futures::{SinkExt as _, StreamExt as _};
 pub struct Connection {
     pub(crate) tx:
         Box<dyn 'static + Send + Unpin + futures::Sink<WebSocketMessage, Error = anyhow::Error>>,
-    pub(crate) rx: Box<
-        dyn 'static
-            + Send
-            + Unpin
-            + futures::Stream<Item = Result<WebSocketMessage, anyhow::Error>>,
-    >,
+    pub(crate) rx:
+        Box<dyn 'static + Send + Unpin + futures::Stream<Item = anyhow::Result<WebSocketMessage>>>,
 }
 
 impl Connection {
@@ -19,7 +15,7 @@ impl Connection {
             + Send
             + Unpin
             + futures::Sink<WebSocketMessage, Error = anyhow::Error>
-            + futures::Stream<Item = Result<WebSocketMessage, anyhow::Error>>,
+            + futures::Stream<Item = anyhow::Result<WebSocketMessage>>,
     {
         let (tx, rx) = stream.split();
         Self {
@@ -28,7 +24,7 @@ impl Connection {
         }
     }
 
-    pub async fn send(&mut self, message: WebSocketMessage) -> Result<(), anyhow::Error> {
+    pub async fn send(&mut self, message: WebSocketMessage) -> anyhow::Result<()> {
         self.tx.send(message).await
     }
 
@@ -56,7 +52,7 @@ impl Connection {
             executor: gpui::BackgroundExecutor,
         ) -> (
             Box<dyn Send + Unpin + futures::Sink<WebSocketMessage, Error = anyhow::Error>>,
-            Box<dyn Send + Unpin + futures::Stream<Item = Result<WebSocketMessage, anyhow::Error>>>,
+            Box<dyn Send + Unpin + futures::Stream<Item = anyhow::Result<WebSocketMessage>>>,
         ) {
             use anyhow::anyhow;
             use futures::channel::mpsc;

crates/rpc/src/extension.rs 🔗

@@ -45,6 +45,7 @@ pub enum ExtensionProvides {
     SlashCommands,
     IndexedDocsProviders,
     Snippets,
+    DebugAdapters,
 }
 
 #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]

crates/rpc/src/message_stream.rs 🔗

@@ -2,7 +2,6 @@
 
 pub use ::proto::*;
 
-use anyhow::anyhow;
 use async_tungstenite::tungstenite::Message as WebSocketMessage;
 use futures::{SinkExt as _, StreamExt as _};
 use proto::Message as _;
@@ -20,7 +19,6 @@ pub struct MessageStream<S> {
     encoding_buffer: Vec<u8>,
 }
 
-#[allow(clippy::large_enum_variant)]
 #[derive(Debug)]
 pub enum Message {
     Envelope(Envelope),
@@ -41,7 +39,7 @@ impl<S> MessageStream<S>
 where
     S: futures::Sink<WebSocketMessage, Error = anyhow::Error> + Unpin,
 {
-    pub async fn write(&mut self, message: Message) -> Result<(), anyhow::Error> {
+    pub async fn write(&mut self, message: Message) -> anyhow::Result<()> {
         #[cfg(any(test, feature = "test-support"))]
         const COMPRESSION_LEVEL: i32 = -7;
 
@@ -82,9 +80,9 @@ where
 
 impl<S> MessageStream<S>
 where
-    S: futures::Stream<Item = Result<WebSocketMessage, anyhow::Error>> + Unpin,
+    S: futures::Stream<Item = anyhow::Result<WebSocketMessage>> + Unpin,
 {
-    pub async fn read(&mut self) -> Result<(Message, Instant), anyhow::Error> {
+    pub async fn read(&mut self) -> anyhow::Result<(Message, Instant)> {
         while let Some(bytes) = self.stream.next().await {
             let received_at = Instant::now();
             match bytes? {
@@ -103,7 +101,7 @@ where
                 _ => {}
             }
         }
-        Err(anyhow!("connection closed"))
+        anyhow::bail!("connection closed");
     }
 }
 
@@ -114,7 +112,7 @@ mod tests {
     #[gpui::test]
     async fn test_buffer_size() {
         let (tx, rx) = futures::channel::mpsc::unbounded();
-        let mut sink = MessageStream::new(tx.sink_map_err(|_| anyhow!("")));
+        let mut sink = MessageStream::new(tx.sink_map_err(|_| anyhow::anyhow!("")));
         sink.write(Message::Envelope(Envelope {
             payload: Some(envelope::Payload::UpdateWorktree(UpdateWorktree {
                 root_name: "abcdefg".repeat(10),

crates/rpc/src/peer.rs 🔗

@@ -197,7 +197,7 @@ impl Peer {
                                     }
                                     _ = create_timer(WRITE_TIMEOUT).fuse() => {
                                         tracing::trace!(%connection_id, "outgoing rpc message: writing timed out");
-                                        Err(anyhow!("timed out writing message"))?;
+                                        anyhow::bail!("timed out writing message");
                                     }
                                 }
                             }
@@ -217,7 +217,7 @@ impl Peer {
                                 }
                                 _ = create_timer(WRITE_TIMEOUT).fuse() => {
                                     tracing::trace!(%connection_id, "keepalive interval: pinging timed out");
-                                    Err(anyhow!("timed out sending keepalive"))?;
+                                    anyhow::bail!("timed out sending keepalive");
                                 }
                             }
                         }
@@ -240,7 +240,7 @@ impl Peer {
                                     },
                                     _ = create_timer(WRITE_TIMEOUT).fuse() => {
                                         tracing::trace!(%connection_id, "incoming rpc message: processing timed out");
-                                        Err(anyhow!("timed out processing incoming message"))?
+                                        anyhow::bail!("timed out processing incoming message");
                                     }
                                 }
                             }
@@ -248,7 +248,7 @@ impl Peer {
                         },
                         _ = receive_timeout => {
                             tracing::trace!(%connection_id, "receive timeout: delay between messages too long");
-                            Err(anyhow!("delay between messages too long"))?
+                            anyhow::bail!("delay between messages too long");
                         }
                     }
                 }
@@ -441,7 +441,7 @@ impl Peer {
                 sender_id: receiver_id.into(),
                 original_sender_id: response.original_sender_id,
                 payload: T::Response::from_envelope(response)
-                    .ok_or_else(|| anyhow!("received response of the wrong type"))?,
+                    .context("received response of the wrong type")?,
                 received_at,
             })
         }
@@ -465,18 +465,17 @@ impl Peer {
                 .response_channels
                 .lock()
                 .as_mut()
-                .ok_or_else(|| anyhow!("connection was closed"))?
+                .context("connection was closed")?
                 .insert(envelope.id, tx);
             connection
                 .outgoing_tx
                 .unbounded_send(Message::Envelope(envelope))
-                .map_err(|_| anyhow!("connection was closed"))?;
+                .context("connection was closed")?;
             Ok(())
         });
         async move {
             send?;
-            let (response, received_at, _barrier) =
-                rx.await.map_err(|_| anyhow!("connection was closed"))?;
+            let (response, received_at, _barrier) = rx.await.context("connection was closed")?;
             if let Some(proto::envelope::Payload::Error(error)) = &response.payload {
                 return Err(RpcError::from_proto(error, type_name));
             }
@@ -496,14 +495,14 @@ impl Peer {
             stream_response_channels
                 .lock()
                 .as_mut()
-                .ok_or_else(|| anyhow!("connection was closed"))?
+                .context("connection was closed")?
                 .insert(message_id, tx);
             connection
                 .outgoing_tx
                 .unbounded_send(Message::Envelope(
                     request.into_envelope(message_id, None, None),
                 ))
-                .map_err(|_| anyhow!("connection was closed"))?;
+                .context("connection was closed")?;
             Ok((message_id, stream_response_channels))
         });
 
@@ -530,7 +529,7 @@ impl Peer {
                         } else {
                             Some(
                                 T::Response::from_envelope(response)
-                                    .ok_or_else(|| anyhow!("received response of the wrong type")),
+                                    .context("received response of the wrong type"),
                             )
                         }
                     }
@@ -662,7 +661,7 @@ impl Peer {
         let connections = self.connections.read();
         let connection = connections
             .get(&connection_id)
-            .ok_or_else(|| anyhow!("no such connection: {}", connection_id))?;
+            .with_context(|| format!("no such connection: {connection_id}"))?;
         Ok(connection.clone())
     }
 }
@@ -685,9 +684,7 @@ mod tests {
     use gpui::TestAppContext;
 
     fn init_logger() {
-        if std::env::var("RUST_LOG").is_ok() {
-            env_logger::init();
-        }
+        zlog::init_test();
     }
 
     #[gpui::test(iterations = 50)]

crates/rpc/src/proto_client.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::anyhow;
+use anyhow::Context;
 use collections::HashMap;
 use futures::{
     Future, FutureExt as _,
@@ -190,7 +190,7 @@ impl AnyProtoClient {
         let response = self.0.request(envelope, T::NAME);
         async move {
             T::Response::from_envelope(response.await?)
-                .ok_or_else(|| anyhow!("received response of the wrong type"))
+                .context("received response of the wrong type")
         }
     }
 

crates/rules_library/Cargo.toml 🔗

@@ -27,6 +27,7 @@ rope.workspace = true
 serde.workspace = true
 settings.workspace = true
 theme.workspace = true
+title_bar.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace-hack.workspace = true

crates/rules_library/src/rules_library.rs 🔗

@@ -1,6 +1,6 @@
 use anyhow::Result;
 use collections::{HashMap, HashSet};
-use editor::CompletionProvider;
+use editor::{CompletionProvider, SelectionEffects};
 use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab};
 use gpui::{
     Action, App, Bounds, Entity, EventEmitter, Focusable, PromptLevel, Subscription, Task,
@@ -15,16 +15,18 @@ use picker::{Picker, PickerDelegate};
 use release_channel::ReleaseChannel;
 use rope::Rope;
 use settings::Settings;
+use std::rc::Rc;
 use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
 use std::time::Duration;
 use theme::ThemeSettings;
+use title_bar::platform_title_bar::PlatformTitleBar;
 use ui::{
     Context, IconButtonShape, KeyBinding, ListItem, ListItemSpacing, ParentElement, Render,
     SharedString, Styled, Tooltip, Window, div, prelude::*,
 };
 use util::{ResultExt, TryFutureExt};
-use workspace::Workspace;
+use workspace::{Workspace, client_side_decorations};
 use zed_actions::assistant::InlineAssist;
 
 use prompt_store::*;
@@ -70,7 +72,7 @@ pub trait InlineAssistDelegate {
 pub fn open_rules_library(
     language_registry: Arc<LanguageRegistry>,
     inline_assist_delegate: Box<dyn InlineAssistDelegate>,
-    make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
+    make_completion_provider: Rc<dyn Fn() -> Rc<dyn CompletionProvider>>,
     prompt_to_select: Option<PromptId>,
     cx: &mut App,
 ) -> Task<Result<WindowHandle<RulesLibrary>>> {
@@ -109,15 +111,22 @@ pub fn open_rules_library(
         cx.update(|cx| {
             let app_id = ReleaseChannel::global(cx).app_id();
             let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx);
+            let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") {
+                Ok(val) if val == "server" => gpui::WindowDecorations::Server,
+                Ok(val) if val == "client" => gpui::WindowDecorations::Client,
+                _ => gpui::WindowDecorations::Client,
+            };
             cx.open_window(
                 WindowOptions {
                     titlebar: Some(TitlebarOptions {
                         title: Some("Rules Library".into()),
-                        appears_transparent: cfg!(target_os = "macos"),
+                        appears_transparent: true,
                         traffic_light_position: Some(point(px(9.0), px(9.0))),
                     }),
                     app_id: Some(app_id.to_owned()),
                     window_bounds: Some(WindowBounds::Windowed(bounds)),
+                    window_background: cx.theme().window_background_appearance(),
+                    window_decorations: Some(window_decorations),
                     ..Default::default()
                 },
                 |window, cx| {
@@ -139,6 +148,7 @@ pub fn open_rules_library(
 }
 
 pub struct RulesLibrary {
+    title_bar: Option<Entity<PlatformTitleBar>>,
     store: Entity<PromptStore>,
     language_registry: Arc<LanguageRegistry>,
     rule_editors: HashMap<PromptId, RuleEditor>,
@@ -146,14 +156,14 @@ pub struct RulesLibrary {
     picker: Entity<Picker<RulePickerDelegate>>,
     pending_load: Task<()>,
     inline_assist_delegate: Box<dyn InlineAssistDelegate>,
-    make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
+    make_completion_provider: Rc<dyn Fn() -> Rc<dyn CompletionProvider>>,
     _subscriptions: Vec<Subscription>,
 }
 
 struct RuleEditor {
     title_editor: Entity<Editor>,
     body_editor: Entity<Editor>,
-    token_count: Option<usize>,
+    token_count: Option<u64>,
     pending_token_count: Task<Option<()>>,
     next_title_and_body_to_save: Option<(String, Rope)>,
     pending_save: Option<Task<Option<()>>>,
@@ -260,6 +270,7 @@ impl PickerDelegate for RulePickerDelegate {
         let rule = self.matches.get(ix)?;
         let default = rule.default;
         let prompt_id = rule.id;
+
         let element = ListItem::new(ix)
             .inset(true)
             .spacing(ListItemSpacing::Sparse)
@@ -271,9 +282,10 @@ impl PickerDelegate for RulePickerDelegate {
                     .child(Label::new(rule.title.clone().unwrap_or("Untitled".into()))),
             )
             .end_slot::<IconButton>(default.then(|| {
-                IconButton::new("toggle-default-rule", IconName::SparkleFilled)
+                IconButton::new("toggle-default-rule", IconName::StarFilled)
                     .toggle_state(true)
                     .icon_color(Color::Accent)
+                    .icon_size(IconSize::Small)
                     .shape(IconButtonShape::Square)
                     .tooltip(Tooltip::text("Remove from Default Rules"))
                     .on_click(cx.listener(move |_, _, _, cx| {
@@ -282,7 +294,7 @@ impl PickerDelegate for RulePickerDelegate {
             }))
             .end_hover_slot(
                 h_flex()
-                    .gap_2()
+                    .gap_1()
                     .child(if prompt_id.is_built_in() {
                         div()
                             .id("built-in-rule")
@@ -298,8 +310,9 @@ impl PickerDelegate for RulePickerDelegate {
                             })
                             .into_any()
                     } else {
-                        IconButton::new("delete-rule", IconName::Trash)
+                        IconButton::new("delete-rule", IconName::TrashAlt)
                             .icon_color(Color::Muted)
+                            .icon_size(IconSize::Small)
                             .shape(IconButtonShape::Square)
                             .tooltip(Tooltip::text("Delete Rule"))
                             .on_click(cx.listener(move |_, _, _, cx| {
@@ -308,16 +321,27 @@ impl PickerDelegate for RulePickerDelegate {
                             .into_any_element()
                     })
                     .child(
-                        IconButton::new("toggle-default-rule", IconName::Sparkle)
+                        IconButton::new("toggle-default-rule", IconName::Star)
                             .toggle_state(default)
-                            .selected_icon(IconName::SparkleFilled)
+                            .selected_icon(IconName::StarFilled)
                             .icon_color(if default { Color::Accent } else { Color::Muted })
+                            .icon_size(IconSize::Small)
                             .shape(IconButtonShape::Square)
-                            .tooltip(Tooltip::text(if default {
-                                "Remove from Default Rules"
-                            } else {
-                                "Add to Default Rules"
-                            }))
+                            .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.",
+                                            window,
+                                            cx,
+                                        )
+                                    })
+                                }
+                            })
                             .on_click(cx.listener(move |_, _, _, cx| {
                                 cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
                             })),
@@ -349,7 +373,7 @@ impl RulesLibrary {
         store: Entity<PromptStore>,
         language_registry: Arc<LanguageRegistry>,
         inline_assist_delegate: Box<dyn InlineAssistDelegate>,
-        make_completion_provider: Arc<dyn Fn() -> Box<dyn CompletionProvider>>,
+        make_completion_provider: Rc<dyn Fn() -> Rc<dyn CompletionProvider>>,
         rule_to_select: Option<PromptId>,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -380,6 +404,11 @@ impl RulesLibrary {
             picker
         });
         Self {
+            title_bar: if !cfg!(target_os = "macos") {
+                Some(cx.new(|_| PlatformTitleBar::new("rules-library-title-bar")))
+            } else {
+                None
+            },
             store: store.clone(),
             language_registry,
             rule_editors: HashMap::default(),
@@ -866,10 +895,15 @@ impl RulesLibrary {
             }
             EditorEvent::Blurred => {
                 title_editor.update(cx, |title_editor, cx| {
-                    title_editor.change_selections(None, window, cx, |selections| {
-                        let cursor = selections.oldest_anchor().head();
-                        selections.select_anchor_ranges([cursor..cursor]);
-                    });
+                    title_editor.change_selections(
+                        SelectionEffects::no_scroll(),
+                        window,
+                        cx,
+                        |selections| {
+                            let cursor = selections.oldest_anchor().head();
+                            selections.select_anchor_ranges([cursor..cursor]);
+                        },
+                    );
                 });
             }
             _ => {}
@@ -891,10 +925,15 @@ impl RulesLibrary {
             }
             EditorEvent::Blurred => {
                 body_editor.update(cx, |body_editor, cx| {
-                    body_editor.change_selections(None, window, cx, |selections| {
-                        let cursor = selections.oldest_anchor().head();
-                        selections.select_anchor_ranges([cursor..cursor]);
-                    });
+                    body_editor.change_selections(
+                        SelectionEffects::no_scroll(),
+                        window,
+                        cx,
+                        |selections| {
+                            let cursor = selections.oldest_anchor().head();
+                            selections.select_anchor_ranges([cursor..cursor]);
+                        },
+                    );
                 });
             }
             _ => {}
@@ -922,6 +961,7 @@ impl RulesLibrary {
                                 LanguageModelRequest {
                                     thread_id: None,
                                     prompt_id: None,
+                                    intent: None,
                                     mode: None,
                                     messages: vec![LanguageModelRequestMessage {
                                         role: Role::System,
@@ -1006,216 +1046,180 @@ impl RulesLibrary {
                         .size_full()
                         .relative()
                         .overflow_hidden()
-                        .pl(DynamicSpacing::Base16.rems(cx))
-                        .pt(DynamicSpacing::Base08.rems(cx))
                         .on_click(cx.listener(move |_, _, window, _| {
                             window.focus(&focus_handle);
                         }))
                         .child(
                             h_flex()
                                 .group("active-editor-header")
-                                .pr(DynamicSpacing::Base16.rems(cx))
-                                .pt(DynamicSpacing::Base02.rems(cx))
-                                .pb(DynamicSpacing::Base08.rems(cx))
+                                .pt_2()
+                                .px_2p5()
+                                .gap_2()
                                 .justify_between()
                                 .child(
-                                    h_flex().gap_1().child(
-                                        div()
-                                            .max_w_80()
-                                            .on_action(cx.listener(Self::move_down_from_title))
-                                            .border_1()
-                                            .border_color(transparent_black())
-                                            .rounded_sm()
-                                            .group_hover("active-editor-header", |this| {
-                                                this.border_color(
-                                                    cx.theme().colors().border_variant,
-                                                )
-                                            })
-                                            .child(EditorElement::new(
-                                                &rule_editor.title_editor,
-                                                EditorStyle {
-                                                    background: cx.theme().system().transparent,
-                                                    local_player: cx.theme().players().local(),
-                                                    text: TextStyle {
-                                                        color: cx
-                                                            .theme()
-                                                            .colors()
-                                                            .editor_foreground,
-                                                        font_family: settings
-                                                            .ui_font
-                                                            .family
-                                                            .clone(),
-                                                        font_features: settings
-                                                            .ui_font
-                                                            .features
-                                                            .clone(),
-                                                        font_size: HeadlineSize::Large
-                                                            .rems()
-                                                            .into(),
-                                                        font_weight: settings.ui_font.weight,
-                                                        line_height: relative(
-                                                            settings.buffer_line_height.value(),
-                                                        ),
-                                                        ..Default::default()
-                                                    },
-                                                    scrollbar_width: Pixels::ZERO,
-                                                    syntax: cx.theme().syntax().clone(),
-                                                    status: cx.theme().status().clone(),
-                                                    inlay_hints_style:
-                                                        editor::make_inlay_hints_style(cx),
-                                                    inline_completion_styles:
-                                                        editor::make_suggestion_styles(cx),
-                                                    ..EditorStyle::default()
+                                    div()
+                                        .w_full()
+                                        .on_action(cx.listener(Self::move_down_from_title))
+                                        .border_1()
+                                        .border_color(transparent_black())
+                                        .rounded_sm()
+                                        .group_hover("active-editor-header", |this| {
+                                            this.border_color(cx.theme().colors().border_variant)
+                                        })
+                                        .child(EditorElement::new(
+                                            &rule_editor.title_editor,
+                                            EditorStyle {
+                                                background: cx.theme().system().transparent,
+                                                local_player: cx.theme().players().local(),
+                                                text: TextStyle {
+                                                    color: cx.theme().colors().editor_foreground,
+                                                    font_family: settings.ui_font.family.clone(),
+                                                    font_features: settings
+                                                        .ui_font
+                                                        .features
+                                                        .clone(),
+                                                    font_size: HeadlineSize::Large.rems().into(),
+                                                    font_weight: settings.ui_font.weight,
+                                                    line_height: relative(
+                                                        settings.buffer_line_height.value(),
+                                                    ),
+                                                    ..Default::default()
                                                 },
-                                            )),
-                                    ),
+                                                scrollbar_width: Pixels::ZERO,
+                                                syntax: cx.theme().syntax().clone(),
+                                                status: cx.theme().status().clone(),
+                                                inlay_hints_style: editor::make_inlay_hints_style(
+                                                    cx,
+                                                ),
+                                                inline_completion_styles:
+                                                    editor::make_suggestion_styles(cx),
+                                                ..EditorStyle::default()
+                                            },
+                                        )),
                                 )
                                 .child(
                                     h_flex()
                                         .h_full()
+                                        .flex_shrink_0()
+                                        .gap(DynamicSpacing::Base04.rems(cx))
+                                        .children(rule_editor.token_count.map(|token_count| {
+                                            let token_count: SharedString =
+                                                token_count.to_string().into();
+                                            let label_token_count: SharedString =
+                                                token_count.to_string().into();
+
+                                            div()
+                                                .id("token_count")
+                                                .mr_1()
+                                                .flex_shrink_0()
+                                                .tooltip(move |window, cx| {
+                                                    Tooltip::with_meta(
+                                                        "Token Estimation",
+                                                        None,
+                                                        format!(
+                                                            "Model: {}",
+                                                            model
+                                                                .as_ref()
+                                                                .map(|model| model.name().0)
+                                                                .unwrap_or_default()
+                                                        ),
+                                                        window,
+                                                        cx,
+                                                    )
+                                                })
+                                                .child(
+                                                    Label::new(format!(
+                                                        "{} tokens",
+                                                        label_token_count.clone()
+                                                    ))
+                                                    .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,
+                                                        window,
+                                                        cx,
+                                                    )
+                                                })
+                                                .into_any()
+                                        } else {
+                                            IconButton::new("delete-rule", IconName::TrashAlt)
+                                                .icon_size(IconSize::Small)
+                                                .tooltip(move |window, cx| {
+                                                    Tooltip::for_action(
+                                                        "Delete Rule",
+                                                        &DeleteRule,
+                                                        window,
+                                                        cx,
+                                                    )
+                                                })
+                                                .on_click(|_, window, cx| {
+                                                    window
+                                                        .dispatch_action(Box::new(DeleteRule), cx);
+                                                })
+                                                .into_any_element()
+                                        })
                                         .child(
-                                            h_flex()
-                                                .h_full()
-                                                .gap(DynamicSpacing::Base16.rems(cx))
-                                                .child(div()),
+                                            IconButton::new("duplicate-rule", IconName::BookCopy)
+                                                .icon_size(IconSize::Small)
+                                                .tooltip(move |window, cx| {
+                                                    Tooltip::for_action(
+                                                        "Duplicate Rule",
+                                                        &DuplicateRule,
+                                                        window,
+                                                        cx,
+                                                    )
+                                                })
+                                                .on_click(|_, window, cx| {
+                                                    window.dispatch_action(
+                                                        Box::new(DuplicateRule),
+                                                        cx,
+                                                    );
+                                                }),
                                         )
                                         .child(
-                                            h_flex()
-                                                .h_full()
-                                                .gap(DynamicSpacing::Base16.rems(cx))
-                                                .children(rule_editor.token_count.map(
-                                                    |token_count| {
-                                                        let token_count: SharedString =
-                                                            token_count.to_string().into();
-                                                        let label_token_count: SharedString =
-                                                            token_count.to_string().into();
-
-                                                        h_flex()
-                                                            .id("token_count")
-                                                            .tooltip(move |window, cx| {
-                                                                let token_count =
-                                                                    token_count.clone();
-
-                                                                Tooltip::with_meta(
-                                                                    format!(
-                                                                        "{} tokens",
-                                                                        token_count.clone()
-                                                                    ),
-                                                                    None,
-                                                                    format!(
-                                                                        "Model: {}",
-                                                                        model
-                                                                            .as_ref()
-                                                                            .map(|model| model
-                                                                                .name()
-                                                                                .0)
-                                                                            .unwrap_or_default()
-                                                                    ),
-                                                                    window,
-                                                                    cx,
-                                                                )
-                                                            })
-                                                            .child(
-                                                                Label::new(format!(
-                                                                    "{} tokens",
-                                                                    label_token_count.clone()
-                                                                ))
-                                                                .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| {
+                                            IconButton::new("toggle-default-rule", IconName::Star)
+                                                .icon_size(IconSize::Small)
+                                                .toggle_state(rule_metadata.default)
+                                                .selected_icon(IconName::StarFilled)
+                                                .icon_color(if rule_metadata.default {
+                                                    Color::Accent
+                                                } 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(
-                                                                "Built-in rule",
+                                                                "Add to Default Rules",
                                                                 None,
-                                                                BUILT_IN_TOOLTIP_TEXT,
-                                                                window,
-                                                                cx,
-                                                            )
-                                                        })
-                                                        .into_any()
-                                                } else {
-                                                    IconButton::new("delete-rule", IconName::Trash)
-                                                        .size(ButtonSize::Large)
-                                                        .style(ButtonStyle::Transparent)
-                                                        .shape(IconButtonShape::Square)
-                                                        .size(ButtonSize::Large)
-                                                        .tooltip(move |window, cx| {
-                                                            Tooltip::for_action(
-                                                                "Delete Rule",
-                                                                &DeleteRule,
+                                                                "Always included in every thread.",
                                                                 window,
                                                                 cx,
                                                             )
                                                         })
-                                                        .on_click(|_, window, cx| {
-                                                            window.dispatch_action(
-                                                                Box::new(DeleteRule),
-                                                                cx,
-                                                            );
-                                                        })
-                                                        .into_any_element()
+                                                    }
                                                 })
-                                                .child(
-                                                    IconButton::new(
-                                                        "duplicate-rule",
-                                                        IconName::BookCopy,
-                                                    )
-                                                    .size(ButtonSize::Large)
-                                                    .style(ButtonStyle::Transparent)
-                                                    .shape(IconButtonShape::Square)
-                                                    .size(ButtonSize::Large)
-                                                    .tooltip(move |window, cx| {
-                                                        Tooltip::for_action(
-                                                            "Duplicate Rule",
-                                                            &DuplicateRule,
-                                                            window,
-                                                            cx,
-                                                        )
-                                                    })
-                                                    .on_click(|_, window, cx| {
-                                                        window.dispatch_action(
-                                                            Box::new(DuplicateRule),
-                                                            cx,
-                                                        );
-                                                    }),
-                                                )
-                                                .child(
-                                                    IconButton::new(
-                                                        "toggle-default-rule",
-                                                        IconName::Sparkle,
-                                                    )
-                                                    .style(ButtonStyle::Transparent)
-                                                    .toggle_state(rule_metadata.default)
-                                                    .selected_icon(IconName::SparkleFilled)
-                                                    .icon_color(if rule_metadata.default {
-                                                        Color::Accent
-                                                    } else {
-                                                        Color::Muted
-                                                    })
-                                                    .shape(IconButtonShape::Square)
-                                                    .size(ButtonSize::Large)
-                                                    .tooltip(Tooltip::text(
-                                                        if rule_metadata.default {
-                                                            "Remove from Default Rules"
-                                                        } else {
-                                                            "Add to Default Rules"
-                                                        },
-                                                    ))
-                                                    .on_click(|_, window, cx| {
-                                                        window.dispatch_action(
-                                                            Box::new(ToggleDefaultRule),
-                                                            cx,
-                                                        );
-                                                    }),
-                                                ),
+                                                .on_click(|_, window, cx| {
+                                                    window.dispatch_action(
+                                                        Box::new(ToggleDefaultRule),
+                                                        cx,
+                                                    );
+                                                }),
                                         ),
                                 ),
                         )
@@ -1226,7 +1230,14 @@ impl RulesLibrary {
                                 .on_action(cx.listener(Self::move_up_from_body))
                                 .flex_grow()
                                 .h_full()
-                                .child(rule_editor.body_editor.clone()),
+                                .child(
+                                    h_flex()
+                                        .py_2()
+                                        .pl_2p5()
+                                        .h_full()
+                                        .flex_1()
+                                        .child(rule_editor.body_editor.clone()),
+                                ),
                         ),
                 )
             }))
@@ -1238,75 +1249,90 @@ impl Render for RulesLibrary {
         let ui_font = theme::setup_ui_font(window, cx);
         let theme = cx.theme().clone();
 
-        h_flex()
-            .id("rules-library")
-            .key_context("PromptLibrary")
-            .on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx)))
-            .on_action(
-                cx.listener(|this, &DeleteRule, window, cx| this.delete_active_rule(window, cx)),
-            )
-            .on_action(cx.listener(|this, &DuplicateRule, window, cx| {
-                this.duplicate_active_rule(window, cx)
-            }))
-            .on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
-                this.toggle_default_for_active_rule(window, cx)
-            }))
-            .size_full()
-            .overflow_hidden()
-            .font(ui_font)
-            .text_color(theme.colors().text)
-            .child(self.render_rule_list(cx))
-            .map(|el| {
-                if self.store.read(cx).prompt_count() == 0 {
-                    el.child(
-                        v_flex()
-                            .w_2_3()
-                            .h_full()
-                            .items_center()
-                            .justify_center()
-                            .gap_4()
-                            .bg(cx.theme().colors().editor_background)
-                            .child(
-                                h_flex()
-                                    .gap_2()
-                                    .child(
-                                        Icon::new(IconName::Book)
-                                            .size(IconSize::Medium)
-                                            .color(Color::Muted),
-                                    )
-                                    .child(
-                                        Label::new("No rules yet")
-                                            .size(LabelSize::Large)
-                                            .color(Color::Muted),
-                                    ),
-                            )
-                            .child(
-                                h_flex()
-                                    .child(h_flex())
-                                    .child(
-                                        v_flex()
-                                            .gap_1()
-                                            .child(Label::new("Create your first rule:"))
-                                            .child(
-                                                Button::new("create-rule", "New Rule")
-                                                    .full_width()
-                                                    .key_binding(KeyBinding::for_action(
-                                                        &NewRule, window, cx,
-                                                    ))
-                                                    .on_click(|_, window, cx| {
-                                                        window.dispatch_action(
-                                                            NewRule.boxed_clone(),
-                                                            cx,
-                                                        )
-                                                    }),
-                                            ),
-                                    )
-                                    .child(h_flex()),
-                            ),
-                    )
-                } else {
-                    el.child(self.render_active_rule(cx))
-                }
-            })
+        client_side_decorations(
+            v_flex()
+                .id("rules-library")
+                .key_context("PromptLibrary")
+                .on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx)))
+                .on_action(
+                    cx.listener(|this, &DeleteRule, window, cx| {
+                        this.delete_active_rule(window, cx)
+                    }),
+                )
+                .on_action(cx.listener(|this, &DuplicateRule, window, cx| {
+                    this.duplicate_active_rule(window, cx)
+                }))
+                .on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
+                    this.toggle_default_for_active_rule(window, cx)
+                }))
+                .size_full()
+                .overflow_hidden()
+                .font(ui_font)
+                .text_color(theme.colors().text)
+                .children(self.title_bar.clone())
+                .child(
+                    h_flex()
+                        .flex_1()
+                        .child(self.render_rule_list(cx))
+                        .map(|el| {
+                            if self.store.read(cx).prompt_count() == 0 {
+                                el.child(
+                                    v_flex()
+                                        .w_2_3()
+                                        .h_full()
+                                        .items_center()
+                                        .justify_center()
+                                        .gap_4()
+                                        .bg(cx.theme().colors().editor_background)
+                                        .child(
+                                            h_flex()
+                                                .gap_2()
+                                                .child(
+                                                    Icon::new(IconName::Book)
+                                                        .size(IconSize::Medium)
+                                                        .color(Color::Muted),
+                                                )
+                                                .child(
+                                                    Label::new("No rules yet")
+                                                        .size(LabelSize::Large)
+                                                        .color(Color::Muted),
+                                                ),
+                                        )
+                                        .child(
+                                            h_flex()
+                                                .child(h_flex())
+                                                .child(
+                                                    v_flex()
+                                                        .gap_1()
+                                                        .child(Label::new(
+                                                            "Create your first rule:",
+                                                        ))
+                                                        .child(
+                                                            Button::new("create-rule", "New Rule")
+                                                                .full_width()
+                                                                .key_binding(
+                                                                    KeyBinding::for_action(
+                                                                        &NewRule, window, cx,
+                                                                    ),
+                                                                )
+                                                                .on_click(|_, window, cx| {
+                                                                    window.dispatch_action(
+                                                                        NewRule.boxed_clone(),
+                                                                        cx,
+                                                                    )
+                                                                }),
+                                                        ),
+                                                )
+                                                .child(h_flex()),
+                                        ),
+                                )
+                            } else {
+                                el.child(self.render_active_rule(cx))
+                            }
+                        }),
+                ),
+            window,
+            cx,
+        )
     }
 }

crates/search/src/buffer_search.rs 🔗

@@ -16,7 +16,7 @@ use futures::channel::oneshot;
 use gpui::{
     Action, App, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
     InteractiveElement as _, IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle,
-    Styled, Subscription, Task, TextStyle, Window, actions, div, impl_actions,
+    Styled, Subscription, Task, TextStyle, Window, actions, div,
 };
 use language::{Language, LanguageRegistry};
 use project::{
@@ -46,7 +46,8 @@ use registrar::{ForDeployed, ForDismissed, SearchActionsRegistrar, WithResults};
 
 const MAX_BUFFER_SEARCH_HISTORY_SIZE: usize = 50;
 
-#[derive(PartialEq, Clone, Deserialize, JsonSchema)]
+#[derive(PartialEq, Clone, Deserialize, JsonSchema, Action)]
+#[action(namespace = buffer_search)]
 #[serde(deny_unknown_fields)]
 pub struct Deploy {
     #[serde(default = "util::serde::default_true")]
@@ -57,8 +58,6 @@ pub struct Deploy {
     pub selection_search_enabled: bool,
 }
 
-impl_actions!(buffer_search, [Deploy]);
-
 actions!(buffer_search, [DeployReplace, Dismiss, FocusEditor]);
 
 impl Deploy {
@@ -696,9 +695,9 @@ impl BufferSearchBar {
                 .read(cx)
                 .as_singleton()
                 .expect("query editor should be backed by a singleton buffer");
-            query_buffer.update(cx, |query_buffer, _| {
-                query_buffer.set_language_registry(languages.clone());
-            });
+            query_buffer
+                .read(cx)
+                .set_language_registry(languages.clone());
 
             cx.spawn(async move |buffer_search_bar, cx| {
                 let regex_language = languages
@@ -1460,7 +1459,6 @@ impl BufferSearchBar {
                             self.select_next_match(&SelectNextMatch, window, cx);
                         }
                         should_propagate = false;
-                        self.focus_editor(&FocusEditor, window, cx);
                     }
                 }
             }
@@ -1542,7 +1540,10 @@ mod tests {
     use std::ops::Range;
 
     use super::*;
-    use editor::{DisplayPoint, Editor, MultiBuffer, SearchSettings, display_map::DisplayRow};
+    use editor::{
+        DisplayPoint, Editor, MultiBuffer, SearchSettings, SelectionEffects,
+        display_map::DisplayRow,
+    };
     use gpui::{Hsla, TestAppContext, UpdateGlobal, VisualTestContext};
     use language::{Buffer, Point};
     use project::Project;
@@ -1679,7 +1680,7 @@ mod tests {
         });
 
         editor.update_in(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_display_ranges([
                     DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)
                 ])
@@ -1693,7 +1694,7 @@ mod tests {
                 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
             );
         });
-        search_bar.update(cx, |search_bar, _| {
+        search_bar.read_with(cx, |search_bar, _| {
             assert_eq!(search_bar.active_match_index, Some(0));
         });
 
@@ -1704,7 +1705,7 @@ mod tests {
                 [DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13)]
             );
         });
-        search_bar.update(cx, |search_bar, _| {
+        search_bar.read_with(cx, |search_bar, _| {
             assert_eq!(search_bar.active_match_index, Some(1));
         });
 
@@ -1715,7 +1716,7 @@ mod tests {
                 [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)]
             );
         });
-        search_bar.update(cx, |search_bar, _| {
+        search_bar.read_with(cx, |search_bar, _| {
             assert_eq!(search_bar.active_match_index, Some(2));
         });
 
@@ -1726,7 +1727,7 @@ mod tests {
                 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
             );
         });
-        search_bar.update(cx, |search_bar, _| {
+        search_bar.read_with(cx, |search_bar, _| {
             assert_eq!(search_bar.active_match_index, Some(0));
         });
 
@@ -1737,7 +1738,7 @@ mod tests {
                 [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)]
             );
         });
-        search_bar.update(cx, |search_bar, _| {
+        search_bar.read_with(cx, |search_bar, _| {
             assert_eq!(search_bar.active_match_index, Some(2));
         });
 
@@ -1748,7 +1749,7 @@ mod tests {
                 [DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13)]
             );
         });
-        search_bar.update(cx, |search_bar, _| {
+        search_bar.read_with(cx, |search_bar, _| {
             assert_eq!(search_bar.active_match_index, Some(1));
         });
 
@@ -1759,14 +1760,14 @@ mod tests {
                 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
             );
         });
-        search_bar.update(cx, |search_bar, _| {
+        search_bar.read_with(cx, |search_bar, _| {
             assert_eq!(search_bar.active_match_index, Some(0));
         });
 
         // Park the cursor in between matches and ensure that going to the previous match selects
         // the closest match to the left.
         editor.update_in(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_display_ranges([
                     DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)
                 ])
@@ -1780,14 +1781,14 @@ mod tests {
                 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
             );
         });
-        search_bar.update(cx, |search_bar, _| {
+        search_bar.read_with(cx, |search_bar, _| {
             assert_eq!(search_bar.active_match_index, Some(0));
         });
 
         // Park the cursor in between matches and ensure that going to the next match selects the
         // closest match to the right.
         editor.update_in(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_display_ranges([
                     DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)
                 ])
@@ -1801,14 +1802,14 @@ mod tests {
                 [DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13)]
             );
         });
-        search_bar.update(cx, |search_bar, _| {
+        search_bar.read_with(cx, |search_bar, _| {
             assert_eq!(search_bar.active_match_index, Some(1));
         });
 
         // Park the cursor after the last match and ensure that going to the previous match selects
         // the last match.
         editor.update_in(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_display_ranges([
                     DisplayPoint::new(DisplayRow(3), 60)..DisplayPoint::new(DisplayRow(3), 60)
                 ])
@@ -1822,14 +1823,14 @@ mod tests {
                 [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)]
             );
         });
-        search_bar.update(cx, |search_bar, _| {
+        search_bar.read_with(cx, |search_bar, _| {
             assert_eq!(search_bar.active_match_index, Some(2));
         });
 
         // Park the cursor after the last match and ensure that going to the next match selects the
         // first match.
         editor.update_in(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_display_ranges([
                     DisplayPoint::new(DisplayRow(3), 60)..DisplayPoint::new(DisplayRow(3), 60)
                 ])
@@ -1843,14 +1844,14 @@ mod tests {
                 [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)]
             );
         });
-        search_bar.update(cx, |search_bar, _| {
+        search_bar.read_with(cx, |search_bar, _| {
             assert_eq!(search_bar.active_match_index, Some(0));
         });
 
         // Park the cursor before the first match and ensure that going to the previous match
         // selects the last match.
         editor.update_in(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_display_ranges([
                     DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)
                 ])
@@ -1864,7 +1865,7 @@ mod tests {
                 [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)]
             );
         });
-        search_bar.update(cx, |search_bar, _| {
+        search_bar.read_with(cx, |search_bar, _| {
             assert_eq!(search_bar.active_match_index, Some(2));
         });
     }
@@ -2398,7 +2399,7 @@ mod tests {
             search_bar.replace_all(&ReplaceAll, window, cx)
         });
         assert_eq!(
-            editor.update(cx, |this, cx| { this.text(cx) }),
+            editor.read_with(cx, |this, cx| { this.text(cx) }),
             r#"
         A regular expr$1 (shortened as regex or regexp;[1] also referred to as
         rational expr$1[2][3]) is a sequence of characters that specifies a search
@@ -2424,7 +2425,7 @@ mod tests {
         });
         // Notice how the first or in the text (shORtened) is not replaced. Neither are the remaining hits of `or` in the text.
         assert_eq!(
-            editor.update(cx, |this, cx| { this.text(cx) }),
+            editor.read_with(cx, |this, cx| { this.text(cx) }),
             r#"
         A regular expr$1 (shortened as regex banana regexp;[1] also referred to as
         rational expr$1[2][3]) is a sequence of characters that specifies a search
@@ -2447,7 +2448,7 @@ mod tests {
             search_bar.replace_all(&ReplaceAll, window, cx)
         });
         assert_eq!(
-            editor.update(cx, |this, cx| { this.text(cx) }),
+            editor.read_with(cx, |this, cx| { this.text(cx) }),
             r#"
         A regular expr$1 (shortened as regex banana regexp;1number also referred to as
         rational expr$12number3number) is a sequence of characters that specifies a search
@@ -2477,7 +2478,7 @@ mod tests {
         // The only word affected by this edit should be `algorithms`, even though there's a bunch
         // of words in this text that would match this regex if not for WHOLE_WORD.
         assert_eq!(
-            editor.update(cx, |this, cx| { this.text(cx) }),
+            editor.read_with(cx, |this, cx| { this.text(cx) }),
             r#"
         A regular expr$1 (shortened as regex banana regexp;1number also referred to as
         rational expr$12number3number) is a sequence of characters that specifies a search
@@ -2528,7 +2529,7 @@ mod tests {
         assert_eq!(
             options
                 .editor
-                .update(options.cx, |this, cx| { this.text(cx) }),
+                .read_with(options.cx, |this, cx| { this.text(cx) }),
             options.expected_text
         );
     }
@@ -2627,7 +2628,7 @@ mod tests {
         });
 
         editor.update_in(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_ranges(vec![Point::new(1, 0)..Point::new(2, 4)])
             })
         });
@@ -2710,7 +2711,7 @@ mod tests {
         });
 
         editor.update_in(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_ranges(vec![
                     Point::new(1, 0)..Point::new(1, 4),
                     Point::new(5, 3)..Point::new(6, 4),
@@ -2788,6 +2789,7 @@ mod tests {
         let (_editor, search_bar, cx) = init_test(cx);
         update_search_settings(
             SearchSettings {
+                button: true,
                 whole_word: false,
                 case_sensitive: false,
                 include_ignored: false,
@@ -2853,6 +2855,7 @@ mod tests {
 
         update_search_settings(
             SearchSettings {
+                button: true,
                 whole_word: false,
                 case_sensitive: true,
                 include_ignored: false,

crates/search/src/project_search.rs 🔗

@@ -7,7 +7,7 @@ use anyhow::Context as _;
 use collections::{HashMap, HashSet};
 use editor::{
     Anchor, Editor, EditorElement, EditorEvent, EditorSettings, EditorStyle, MAX_TAB_TITLE_LEN,
-    MultiBuffer, actions::SelectAll, items::active_match_index, scroll::Autoscroll,
+    MultiBuffer, SelectionEffects, actions::SelectAll, items::active_match_index,
 };
 use futures::{StreamExt, stream::FuturesOrdered};
 use gpui::{
@@ -37,11 +37,11 @@ use ui::{
     Icon, IconButton, IconButtonShape, IconName, KeyBinding, Label, LabelCommon, LabelSize,
     Toggleable, Tooltip, h_flex, prelude::*, utils::SearchInputWidth, v_flex,
 };
-use util::paths::PathMatcher;
+use util::{ResultExt as _, paths::PathMatcher};
 use workspace::{
     DeploySearch, ItemNavHistory, NewSearch, ToolbarItemEvent, ToolbarItemLocation,
     ToolbarItemView, Workspace, WorkspaceId,
-    item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
+    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions},
     searchable::{Direction, SearchableItem, SearchableItemHandle},
 };
 
@@ -72,15 +72,18 @@ pub fn init(cx: &mut App) {
         );
         register_workspace_action(
             workspace,
-            move |search_bar, _: &ToggleCaseSensitive, _, cx| {
-                search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
+            move |search_bar, _: &ToggleCaseSensitive, window, cx| {
+                search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
             },
         );
-        register_workspace_action(workspace, move |search_bar, _: &ToggleWholeWord, _, cx| {
-            search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
-        });
-        register_workspace_action(workspace, move |search_bar, _: &ToggleRegex, _, cx| {
-            search_bar.toggle_search_option(SearchOptions::REGEX, cx);
+        register_workspace_action(
+            workspace,
+            move |search_bar, _: &ToggleWholeWord, window, cx| {
+                search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
+            },
+        );
+        register_workspace_action(workspace, move |search_bar, _: &ToggleRegex, window, cx| {
+            search_bar.toggle_search_option(SearchOptions::REGEX, window, cx);
         });
         register_workspace_action(
             workspace,
@@ -106,6 +109,40 @@ pub fn init(cx: &mut App) {
             ProjectSearchView::search_in_new(workspace, action, window, cx)
         });
 
+        register_workspace_action_for_present_search(
+            workspace,
+            |workspace, _: &menu::Cancel, window, cx| {
+                if let Some(project_search_bar) = workspace
+                    .active_pane()
+                    .read(cx)
+                    .toolbar()
+                    .read(cx)
+                    .item_of_type::<ProjectSearchBar>()
+                {
+                    project_search_bar.update(cx, |project_search_bar, cx| {
+                        let search_is_focused = project_search_bar
+                            .active_project_search
+                            .as_ref()
+                            .is_some_and(|search_view| {
+                                search_view
+                                    .read(cx)
+                                    .query_editor
+                                    .read(cx)
+                                    .focus_handle(cx)
+                                    .is_focused(window)
+                            });
+                        if search_is_focused {
+                            project_search_bar.move_focus_to_results(window, cx);
+                        } else {
+                            project_search_bar.focus_search(window, cx)
+                        }
+                    });
+                } else {
+                    cx.propagate();
+                }
+            },
+        );
+
         // Both on present and dismissed search, we need to unconditionally handle those actions to focus from the editor.
         workspace.register_action(move |workspace, action: &DeploySearch, window, cx| {
             if workspace.has_active_modal(window, cx) {
@@ -127,7 +164,7 @@ pub fn init(cx: &mut App) {
     .detach();
 }
 
-fn is_contains_uppercase(str: &str) -> bool {
+fn contains_uppercase(str: &str) -> bool {
     str.chars().any(|c| c.is_uppercase())
 }
 
@@ -287,24 +324,24 @@ impl ProjectSearch {
                     }
                 }
 
-                let excerpts = project_search
-                    .update(cx, |project_search, _| project_search.excerpts.clone())
-                    .ok()?;
-                let mut new_ranges = excerpts
-                    .update(cx, |excerpts, cx| {
-                        buffers_with_ranges
-                            .into_iter()
-                            .map(|(buffer, ranges)| {
-                                excerpts.set_anchored_excerpts_for_path(
-                                    buffer,
-                                    ranges,
-                                    editor::DEFAULT_MULTIBUFFER_CONTEXT,
-                                    cx,
-                                )
-                            })
-                            .collect::<FuturesOrdered<_>>()
+                let mut new_ranges = project_search
+                    .update(cx, |project_search, cx| {
+                        project_search.excerpts.update(cx, |excerpts, cx| {
+                            buffers_with_ranges
+                                .into_iter()
+                                .map(|(buffer, ranges)| {
+                                    excerpts.set_anchored_excerpts_for_path(
+                                        buffer,
+                                        ranges,
+                                        editor::DEFAULT_MULTIBUFFER_CONTEXT,
+                                        cx,
+                                    )
+                                })
+                                .collect::<FuturesOrdered<_>>()
+                        })
                     })
                     .ok()?;
+
                 while let Some(new_ranges) = new_ranges.next().await {
                     project_search
                         .update(cx, |project_search, _| {
@@ -493,13 +530,13 @@ impl Item for ProjectSearchView {
 
     fn save(
         &mut self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<anyhow::Result<()>> {
         self.results_editor
-            .update(cx, |editor, cx| editor.save(format, project, window, cx))
+            .update(cx, |editor, cx| editor.save(options, project, window, cx))
     }
 
     fn save_as(
@@ -730,7 +767,7 @@ impl ProjectSearchView {
                         let query = this.search_query_text(cx);
                         if !query.is_empty()
                             && this.search_options.contains(SearchOptions::CASE_SENSITIVE)
-                                != is_contains_uppercase(&query)
+                                != contains_uppercase(&query)
                         {
                             this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
                         }
@@ -994,10 +1031,89 @@ impl ProjectSearchView {
                     .update(cx, |editor, cx| editor.set_text(included_files, window, cx));
                 search.filters_enabled = true;
             }
+            if let Some(excluded_files) = action.excluded_files.as_deref() {
+                search
+                    .excluded_files_editor
+                    .update(cx, |editor, cx| editor.set_text(excluded_files, window, cx));
+                search.filters_enabled = true;
+            }
             search.focus_query_editor(window, cx)
         });
     }
 
+    fn prompt_to_save_if_dirty_then_search(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<anyhow::Result<()>> {
+        use workspace::AutosaveSetting;
+
+        let project = self.entity.read(cx).project.clone();
+
+        let can_autosave = self.results_editor.can_autosave(cx);
+        let autosave_setting = self.results_editor.workspace_settings(cx).autosave;
+
+        let will_autosave = can_autosave
+            && matches!(
+                autosave_setting,
+                AutosaveSetting::OnFocusChange | AutosaveSetting::OnWindowChange
+            );
+
+        let is_dirty = self.is_dirty(cx);
+
+        cx.spawn_in(window, async move |this, cx| {
+            let skip_save_on_close = this
+                .read_with(cx, |this, cx| {
+                    this.workspace.read_with(cx, |workspace, cx| {
+                        workspace::Pane::skip_save_on_close(&this.results_editor, workspace, cx)
+                    })
+                })?
+                .unwrap_or(false);
+
+            let should_prompt_to_save = !skip_save_on_close && !will_autosave && is_dirty;
+
+            let should_search = if should_prompt_to_save {
+                let options = &["Save", "Don't Save", "Cancel"];
+                let result_channel = this.update_in(cx, |_, window, cx| {
+                    window.prompt(
+                        gpui::PromptLevel::Warning,
+                        "Project search buffer contains unsaved edits. Do you want to save it?",
+                        None,
+                        options,
+                        cx,
+                    )
+                })?;
+                let result = result_channel.await?;
+                let should_save = result == 0;
+                if should_save {
+                    this.update_in(cx, |this, window, cx| {
+                        this.save(
+                            SaveOptions {
+                                format: true,
+                                autosave: false,
+                            },
+                            project,
+                            window,
+                            cx,
+                        )
+                    })?
+                    .await
+                    .log_err();
+                }
+                let should_search = result != 2;
+                should_search
+            } else {
+                true
+            };
+            if should_search {
+                this.update(cx, |this, cx| {
+                    this.search(cx);
+                })?;
+            }
+            anyhow::Ok(())
+        })
+    }
+
     fn search(&mut self, cx: &mut Context<Self>) {
         if let Some(query) = self.build_search_query(cx) {
             self.entity.update(cx, |model, cx| model.search(query, cx));
@@ -1186,8 +1302,8 @@ impl ProjectSearchView {
             let range_to_select = match_ranges[new_index].clone();
             self.results_editor.update(cx, |editor, cx| {
                 let range_to_select = editor.range_for_match(&range_to_select);
-                editor.unfold_ranges(&[range_to_select.clone()], false, true, cx);
-                editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+                editor.unfold_ranges(std::slice::from_ref(&range_to_select), false, true, cx);
+                editor.change_selections(Default::default(), window, cx, |s| {
                     s.select_ranges([range_to_select])
                 });
             });
@@ -1207,7 +1323,7 @@ impl ProjectSearchView {
         if EditorSettings::get_global(cx).use_smartcase_search
             && !query.is_empty()
             && self.search_options.contains(SearchOptions::CASE_SENSITIVE)
-                != is_contains_uppercase(query)
+                != contains_uppercase(query)
         {
             self.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx)
         }
@@ -1234,7 +1350,9 @@ impl ProjectSearchView {
     fn focus_results_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         self.query_editor.update(cx, |query_editor, cx| {
             let cursor = query_editor.selections.newest_anchor().head();
-            query_editor.change_selections(None, window, cx, |s| s.select_ranges([cursor..cursor]));
+            query_editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+                s.select_ranges([cursor..cursor])
+            });
         });
         let results_handle = self.results_editor.focus_handle(cx);
         window.focus(&results_handle);
@@ -1254,14 +1372,14 @@ impl ProjectSearchView {
                     let range_to_select = match_ranges
                         .first()
                         .map(|range| editor.range_for_match(range));
-                    editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
+                    editor.change_selections(Default::default(), window, cx, |s| {
                         s.select_ranges(range_to_select)
                     });
                     editor.scroll(Point::default(), Some(Axis::Vertical), window, cx);
                 }
                 editor.highlight_background::<Self>(
                     &match_ranges,
-                    |theme| theme.search_match_background,
+                    |theme| theme.colors().search_match_background,
                     cx,
                 );
             });
@@ -1469,7 +1587,9 @@ impl ProjectSearchBar {
                     .is_focused(window)
                 {
                     cx.stop_propagation();
-                    search_view.search(cx);
+                    search_view
+                        .prompt_to_save_if_dirty_then_search(window, cx)
+                        .detach_and_log_err(cx);
                 }
             });
         }
@@ -1536,19 +1656,39 @@ impl ProjectSearchBar {
         });
     }
 
-    fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut Context<Self>) -> bool {
-        if let Some(search_view) = self.active_project_search.as_ref() {
-            search_view.update(cx, |search_view, cx| {
-                search_view.toggle_search_option(option, cx);
-                if search_view.entity.read(cx).active_query.is_some() {
-                    search_view.search(cx);
-                }
-            });
-            cx.notify();
-            true
-        } else {
-            false
+    fn toggle_search_option(
+        &mut self,
+        option: SearchOptions,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> bool {
+        if self.active_project_search.is_none() {
+            return false;
         }
+
+        cx.spawn_in(window, async move |this, cx| {
+            let task = this.update_in(cx, |this, window, cx| {
+                let search_view = this.active_project_search.as_ref()?;
+                search_view.update(cx, |search_view, cx| {
+                    search_view.toggle_search_option(option, cx);
+                    search_view
+                        .entity
+                        .read(cx)
+                        .active_query
+                        .is_some()
+                        .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
+                })
+            })?;
+            if let Some(task) = task {
+                task.await?;
+            }
+            this.update(cx, |_, cx| {
+                cx.notify();
+            })?;
+            anyhow::Ok(())
+        })
+        .detach();
+        true
     }
 
     fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context<Self>) {
@@ -1587,19 +1727,33 @@ impl ProjectSearchBar {
     }
 
     fn toggle_opened_only(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
-        if let Some(search_view) = self.active_project_search.as_ref() {
-            search_view.update(cx, |search_view, cx| {
-                search_view.toggle_opened_only(window, cx);
-                if search_view.entity.read(cx).active_query.is_some() {
-                    search_view.search(cx);
-                }
-            });
-
-            cx.notify();
-            true
-        } else {
-            false
+        if self.active_project_search.is_none() {
+            return false;
         }
+
+        cx.spawn_in(window, async move |this, cx| {
+            let task = this.update_in(cx, |this, window, cx| {
+                let search_view = this.active_project_search.as_ref()?;
+                search_view.update(cx, |search_view, cx| {
+                    search_view.toggle_opened_only(window, cx);
+                    search_view
+                        .entity
+                        .read(cx)
+                        .active_query
+                        .is_some()
+                        .then(|| search_view.prompt_to_save_if_dirty_then_search(window, cx))
+                })
+            })?;
+            if let Some(task) = task {
+                task.await?;
+            }
+            this.update(cx, |_, cx| {
+                cx.notify();
+            })?;
+            anyhow::Ok(())
+        })
+        .detach();
+        true
     }
 
     fn is_opened_only_enabled(&self, cx: &App) -> bool {
@@ -1826,22 +1980,22 @@ impl Render for ProjectSearchBar {
                     .child(SearchOptions::CASE_SENSITIVE.as_button(
                         self.is_option_enabled(SearchOptions::CASE_SENSITIVE, cx),
                         focus_handle.clone(),
-                        cx.listener(|this, _, _, cx| {
-                            this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
+                        cx.listener(|this, _, window, cx| {
+                            this.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
                         }),
                     ))
                     .child(SearchOptions::WHOLE_WORD.as_button(
                         self.is_option_enabled(SearchOptions::WHOLE_WORD, cx),
                         focus_handle.clone(),
-                        cx.listener(|this, _, _, cx| {
-                            this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
+                        cx.listener(|this, _, window, cx| {
+                            this.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
                         }),
                     ))
                     .child(SearchOptions::REGEX.as_button(
                         self.is_option_enabled(SearchOptions::REGEX, cx),
                         focus_handle.clone(),
-                        cx.listener(|this, _, _, cx| {
-                            this.toggle_search_option(SearchOptions::REGEX, cx);
+                        cx.listener(|this, _, window, cx| {
+                            this.toggle_search_option(SearchOptions::REGEX, window, cx);
                         }),
                     )),
             );
@@ -1912,9 +2066,9 @@ impl Render for ProjectSearchBar {
                 if match_quantity > 0 {
                     debug_assert!(match_quantity >= index);
                     if limit_reached {
-                        Some(format!("{index}/{match_quantity}+").to_string())
+                        Some(format!("{index}/{match_quantity}+"))
                     } else {
-                        Some(format!("{index}/{match_quantity}").to_string())
+                        Some(format!("{index}/{match_quantity}"))
                     }
                 } else {
                     None
@@ -2113,8 +2267,12 @@ impl Render for ProjectSearchBar {
                                     .search_options
                                     .contains(SearchOptions::INCLUDE_IGNORED),
                                 focus_handle.clone(),
-                                cx.listener(|this, _, _, cx| {
-                                    this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
+                                cx.listener(|this, _, window, cx| {
+                                    this.toggle_search_option(
+                                        SearchOptions::INCLUDE_IGNORED,
+                                        window,
+                                        cx,
+                                    );
                                 }),
                             ),
                         ),
@@ -2154,11 +2312,11 @@ impl Render for ProjectSearchBar {
             .on_action(cx.listener(|this, action, window, cx| {
                 this.toggle_replace(action, window, cx);
             }))
-            .on_action(cx.listener(|this, _: &ToggleWholeWord, _, cx| {
-                this.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
+            .on_action(cx.listener(|this, _: &ToggleWholeWord, window, cx| {
+                this.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
             }))
-            .on_action(cx.listener(|this, _: &ToggleCaseSensitive, _, cx| {
-                this.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
+            .on_action(cx.listener(|this, _: &ToggleCaseSensitive, window, cx| {
+                this.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx);
             }))
             .on_action(cx.listener(|this, action, window, cx| {
                 if let Some(search) = this.active_project_search.as_ref() {
@@ -2175,8 +2333,8 @@ impl Render for ProjectSearchBar {
                 }
             }))
             .when(search.filters_enabled, |this| {
-                this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, _, cx| {
-                    this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
+                this.on_action(cx.listener(|this, _: &ToggleIncludeIgnored, window, cx| {
+                    this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, window, cx);
                 }))
             })
             .on_action(cx.listener(Self::select_next_match))
@@ -3873,7 +4031,7 @@ pub mod tests {
         window
             .update(cx, |workspace, window, cx| {
                 assert_eq!(workspace.active_pane(), &first_pane);
-                first_pane.update(cx, |this, _| {
+                first_pane.read_with(cx, |this, _| {
                     assert_eq!(this.active_item_index(), 1);
                     assert_eq!(this.items_len(), 2);
                 });
@@ -4057,7 +4215,7 @@ pub mod tests {
         });
         cx.run_until_parked();
         let project_search_view = pane
-            .update(&mut cx, |pane, _| {
+            .read_with(&mut cx, |pane, _| {
                 pane.active_item()
                     .and_then(|item| item.downcast::<ProjectSearchView>())
             })

crates/search/src/search_status_button.rs 🔗

@@ -1,3 +1,5 @@
+use editor::EditorSettings;
+use settings::Settings as _;
 use ui::{
     ButtonCommon, ButtonLike, Clickable, Color, Context, Icon, IconName, IconSize, ParentElement,
     Render, Styled, Tooltip, Window, h_flex,
@@ -14,7 +16,12 @@ impl SearchButton {
 
 impl Render for SearchButton {
     fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
-        h_flex().gap_2().child(
+        let button = h_flex().gap_2();
+        if !EditorSettings::get_global(cx).search.button {
+            return button;
+        }
+
+        button.child(
             ButtonLike::new("project-search-indicator")
                 .child(
                     Icon::new(IconName::MagnifyingGlass)

crates/semantic_index/Cargo.toml 🔗

@@ -54,7 +54,6 @@ workspace-hack.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }
-env_logger.workspace = true
 fs = { workspace = true, features = ["test-support"] }
 futures.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
@@ -67,3 +66,4 @@ reqwest_client.workspace = true
 util = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
 worktree = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/semantic_index/src/embedding_index.rs 🔗

@@ -3,7 +3,7 @@ use crate::{
     embedding::{Embedding, EmbeddingProvider, TextToEmbed},
     indexing::{IndexingEntryHandle, IndexingEntrySet},
 };
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use collections::Bound;
 use feature_flags::FeatureFlagAppExt;
 use fs::Fs;
@@ -422,7 +422,7 @@ impl EmbeddingIndex {
                 .context("failed to create read transaction")?;
             Ok(db
                 .get(&tx, &db_key_for_path(&path))?
-                .ok_or_else(|| anyhow!("no such path"))?
+                .context("no such path")?
                 .chunks
                 .clone())
         })

crates/semantic_index/src/project_index.rs 🔗

@@ -282,11 +282,10 @@ impl ProjectIndex {
                 .collect();
 
             let query_embeddings = embedding_provider.embed(&queries[..]).await?;
-            if query_embeddings.len() != queries.len() {
-                return Err(anyhow!(
-                    "The number of query embeddings does not match the number of queries"
-                ));
-            }
+            anyhow::ensure!(
+                query_embeddings.len() == queries.len(),
+                "The number of query embeddings does not match the number of queries"
+            );
 
             let mut results_by_worker = Vec::new();
             for _ in 0..cx.background_executor().num_cpus() {

crates/semantic_index/src/project_index_debug_view.rs 🔗

@@ -6,7 +6,7 @@ use gpui::{
 };
 use project::WorktreeId;
 use settings::Settings;
-use std::{path::Path, sync::Arc};
+use std::{ops::Range, path::Path, sync::Arc};
 use theme::ThemeSettings;
 use ui::prelude::*;
 use workspace::item::Item;
@@ -224,10 +224,9 @@ impl Render for ProjectIndexDebugView {
                 .into_any_element()
         } else {
             let mut list = uniform_list(
-                cx.entity().clone(),
                 "ProjectIndexDebugView",
                 self.rows.len(),
-                move |this, range, _, cx| {
+                cx.processor(move |this, range: Range<usize>, _, cx| {
                     this.rows[range]
                         .iter()
                         .enumerate()
@@ -262,7 +261,7 @@ impl Render for ProjectIndexDebugView {
                                 })),
                         })
                         .collect()
-                },
+                }),
             )
             .track_scroll(self.list_scroll_handle.clone())
             .size_full()

crates/semantic_index/src/semantic_index.rs 🔗

@@ -264,7 +264,6 @@ impl Drop for SemanticDb {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use anyhow::anyhow;
     use chunking::Chunk;
     use embedding_index::{ChunkedFile, EmbeddingIndex};
     use feature_flags::FeatureFlagAppExt;
@@ -278,10 +277,10 @@ mod tests {
     use settings::SettingsStore;
     use smol::channel;
     use std::{future, path::Path, sync::Arc};
-    use util::separator;
+    use util::path;
 
     fn init_test(cx: &mut TestAppContext) {
-        env_logger::try_init().ok();
+        zlog::init_test();
 
         cx.update(|cx| {
             let store = SettingsStore::test(cx);
@@ -423,7 +422,7 @@ mod tests {
 
         assert_eq!(
             search_result.path.to_string_lossy(),
-            separator!("fixture/needle.md")
+            path!("fixture/needle.md")
         );
 
         let content = cx
@@ -446,15 +445,15 @@ mod tests {
         cx.executor().allow_parking();
 
         let provider = Arc::new(TestEmbeddingProvider::new(3, |text| {
-            if text.contains('g') {
-                Err(anyhow!("cannot embed text containing a 'g' character"))
-            } else {
-                Ok(Embedding::new(
-                    ('a'..='z')
-                        .map(|char| text.chars().filter(|c| *c == char).count() as f32)
-                        .collect(),
-                ))
-            }
+            anyhow::ensure!(
+                !text.contains('g'),
+                "cannot embed text containing a 'g' character"
+            );
+            Ok(Embedding::new(
+                ('a'..='z')
+                    .map(|char| text.chars().filter(|c| *c == char).count() as f32)
+                    .collect(),
+            ))
         }));
 
         let (indexing_progress_tx, _) = channel::unbounded();

crates/semantic_index/src/summary_index.rs 🔗

@@ -543,7 +543,7 @@ impl SummaryIndex {
             .find(|model| &model.id() == &summary_model_id)
         else {
             return cx.background_spawn(async move {
-                Err(anyhow!("Couldn't find the preferred summarization model ({:?}) in the language registry's available models", summary_model_id))
+                anyhow::bail!("Couldn't find the preferred summarization model ({summary_model_id:?}) in the language registry's available models")
             });
         };
         let utf8_path = path.to_string_lossy();
@@ -560,6 +560,7 @@ impl SummaryIndex {
             thread_id: None,
             prompt_id: None,
             mode: None,
+            intent: None,
             messages: vec![LanguageModelRequestMessage {
                 role: Role::User,
                 content: vec![prompt.into()],

crates/semantic_version/src/semantic_version.rs 🔗

@@ -7,7 +7,7 @@ use std::{
     str::FromStr,
 };
 
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use serde::{Deserialize, Serialize, de::Error};
 
 /// A [semantic version](https://semver.org/) number.
@@ -54,15 +54,15 @@ impl FromStr for SemanticVersion {
         let mut components = s.trim().split('.');
         let major = components
             .next()
-            .ok_or_else(|| anyhow!("missing major version number"))?
+            .context("missing major version number")?
             .parse()?;
         let minor = components
             .next()
-            .ok_or_else(|| anyhow!("missing minor version number"))?
+            .context("missing minor version number")?
             .parse()?;
         let patch = components
             .next()
-            .ok_or_else(|| anyhow!("missing patch version number"))?
+            .context("missing patch version number")?
             .parse()?;
         Ok(Self {
             major,

crates/settings/Cargo.toml 🔗

@@ -33,11 +33,11 @@ serde_derive.workspace = true
 serde_json.workspace = true
 serde_json_lenient.workspace = true
 smallvec.workspace = true
-streaming-iterator.workspace = true
 tree-sitter-json.workspace = true
 tree-sitter.workspace = true
 util.workspace = true
 workspace-hack.workspace = true
+zlog.workspace = true
 
 [dev-dependencies]
 fs = { workspace = true, features = ["test-support"] }

crates/settings/src/json_schema.rs 🔗

@@ -1,75 +0,0 @@
-use schemars::schema::{
-    ArrayValidation, InstanceType, RootSchema, Schema, SchemaObject, SingleOrVec,
-};
-use serde_json::Value;
-
-pub struct SettingsJsonSchemaParams<'a> {
-    pub language_names: &'a [String],
-    pub font_names: &'a [String],
-}
-
-impl SettingsJsonSchemaParams<'_> {
-    pub fn font_family_schema(&self) -> Schema {
-        let available_fonts: Vec<_> = self.font_names.iter().cloned().map(Value::String).collect();
-
-        SchemaObject {
-            instance_type: Some(InstanceType::String.into()),
-            enum_values: Some(available_fonts),
-            ..Default::default()
-        }
-        .into()
-    }
-
-    pub fn font_fallback_schema(&self) -> Schema {
-        SchemaObject {
-            instance_type: Some(SingleOrVec::Vec(vec![
-                InstanceType::Array,
-                InstanceType::Null,
-            ])),
-            array: Some(Box::new(ArrayValidation {
-                items: Some(schemars::schema::SingleOrVec::Single(Box::new(
-                    self.font_family_schema(),
-                ))),
-                unique_items: Some(true),
-                ..Default::default()
-            })),
-            ..Default::default()
-        }
-        .into()
-    }
-}
-
-type PropertyName<'a> = &'a str;
-type ReferencePath<'a> = &'a str;
-
-/// Modifies the provided [`RootSchema`] by adding references to all of the specified properties.
-///
-/// # Examples
-///
-/// ```
-/// # let root_schema = RootSchema::default();
-/// add_references_to_properties(&mut root_schema, &[
-///     ("property_a", "#/definitions/DefinitionA"),
-///     ("property_b", "#/definitions/DefinitionB"),
-/// ])
-/// ```
-pub fn add_references_to_properties(
-    root_schema: &mut RootSchema,
-    properties_with_references: &[(PropertyName, ReferencePath)],
-) {
-    for (property, definition) in properties_with_references {
-        let Some(schema) = root_schema.schema.object().properties.get_mut(*property) else {
-            log::warn!("property '{property}' not found in JSON schema");
-            continue;
-        };
-
-        match schema {
-            Schema::Object(schema) => {
-                schema.reference = Some(definition.to_string());
-            }
-            Schema::Bool(_) => {
-                // Boolean schemas can't have references.
-            }
-        }
-    }
-}

crates/settings/src/keymap_file.rs 🔗

@@ -1,9 +1,9 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use collections::{BTreeMap, HashMap, IndexMap};
 use fs::Fs;
 use gpui::{
     Action, ActionBuildError, App, InvalidKeystrokeError, KEYSTROKE_PARSE_EXPECTED_MESSAGE,
-    KeyBinding, KeyBindingContextPredicate, NoAction, SharedString,
+    KeyBinding, KeyBindingContextPredicate, KeyBindingMetaIndex, NoAction,
 };
 use schemars::{
     JsonSchema,
@@ -18,7 +18,10 @@ use util::{
     markdown::{MarkdownEscaped, MarkdownInlineCode, MarkdownString},
 };
 
-use crate::{SettingsAssets, settings_store::parse_json_with_comments};
+use crate::{
+    SettingsAssets, append_top_level_array_value_in_json_text, parse_json_with_comments,
+    replace_top_level_array_value_in_json_text,
+};
 
 pub trait KeyBindingValidator: Send + Sync {
     fn action_type_id(&self) -> TypeId;
@@ -151,15 +154,27 @@ impl KeymapFile {
         parse_json_with_comments::<Self>(content)
     }
 
-    pub fn load_asset(asset_path: &str, cx: &App) -> anyhow::Result<Vec<KeyBinding>> {
+    pub fn load_asset(
+        asset_path: &str,
+        source: Option<KeybindSource>,
+        cx: &App,
+    ) -> anyhow::Result<Vec<KeyBinding>> {
         match Self::load(asset_str::<SettingsAssets>(asset_path).as_ref(), cx) {
-            KeymapFileLoadResult::Success { key_bindings } => Ok(key_bindings),
-            KeymapFileLoadResult::SomeFailedToLoad { error_message, .. } => Err(anyhow!(
-                "Error loading built-in keymap \"{asset_path}\": {error_message}",
-            )),
-            KeymapFileLoadResult::JsonParseFailure { error } => Err(anyhow!(
-                "JSON parse error in built-in keymap \"{asset_path}\": {error}"
-            )),
+            KeymapFileLoadResult::Success { mut key_bindings } => match source {
+                Some(source) => Ok({
+                    for key_binding in &mut key_bindings {
+                        key_binding.set_meta(source.meta());
+                    }
+                    key_bindings
+                }),
+                None => Ok(key_bindings),
+            },
+            KeymapFileLoadResult::SomeFailedToLoad { error_message, .. } => {
+                anyhow::bail!("Error loading built-in keymap \"{asset_path}\": {error_message}",)
+            }
+            KeymapFileLoadResult::JsonParseFailure { error } => {
+                anyhow::bail!("JSON parse error in built-in keymap \"{asset_path}\": {error}")
+            }
         }
     }
 
@@ -173,14 +188,14 @@ impl KeymapFile {
                 key_bindings,
                 error_message,
                 ..
-            } if key_bindings.is_empty() => Err(anyhow!(
-                "Error loading built-in keymap \"{asset_path}\": {error_message}",
-            )),
+            } if key_bindings.is_empty() => {
+                anyhow::bail!("Error loading built-in keymap \"{asset_path}\": {error_message}",)
+            }
             KeymapFileLoadResult::Success { key_bindings, .. }
             | KeymapFileLoadResult::SomeFailedToLoad { key_bindings, .. } => Ok(key_bindings),
-            KeymapFileLoadResult::JsonParseFailure { error } => Err(anyhow!(
-                "JSON parse error in built-in keymap \"{asset_path}\": {error}"
-            )),
+            KeymapFileLoadResult::JsonParseFailure { error } => {
+                anyhow::bail!("JSON parse error in built-in keymap \"{asset_path}\": {error}")
+            }
         }
     }
 
@@ -206,7 +221,7 @@ impl KeymapFile {
                 key_bindings: Vec::new(),
             };
         }
-        let keymap_file = match parse_json_with_comments::<Self>(content) {
+        let keymap_file = match Self::parse(content) {
             Ok(keymap_file) => keymap_file,
             Err(error) => {
                 return KeymapFileLoadResult::JsonParseFailure { error };
@@ -414,14 +429,21 @@ impl KeymapFile {
             .into_generator();
 
         let action_schemas = cx.action_schemas(&mut generator);
-        let deprecations = cx.action_deprecations();
-        KeymapFile::generate_json_schema(generator, action_schemas, deprecations)
+        let deprecations = cx.deprecated_actions_to_preferred_actions();
+        let deprecation_messages = cx.action_deprecation_messages();
+        KeymapFile::generate_json_schema(
+            generator,
+            action_schemas,
+            deprecations,
+            deprecation_messages,
+        )
     }
 
     fn generate_json_schema(
         generator: SchemaGenerator,
-        action_schemas: Vec<(SharedString, Option<Schema>)>,
-        deprecations: &HashMap<SharedString, SharedString>,
+        action_schemas: Vec<(&'static str, Option<Schema>)>,
+        deprecations: &HashMap<&'static str, &'static str>,
+        deprecation_messages: &HashMap<&'static str, &'static str>,
     ) -> serde_json::Value {
         fn set<I, O>(input: I) -> Option<O>
         where
@@ -492,9 +514,9 @@ impl KeymapFile {
         };
         let mut keymap_action_alternatives = vec![plain_action.into(), action_with_input.into()];
 
-        for (name, action_schema) in action_schemas.iter() {
+        for (name, action_schema) in action_schemas.into_iter() {
             let schema = if let Some(Schema::Object(schema)) = action_schema {
-                Some(schema.clone())
+                Some(schema)
             } else {
                 None
             };
@@ -509,7 +531,7 @@ impl KeymapFile {
             let deprecation = if name == NoAction.name() {
                 Some("null")
             } else {
-                deprecations.get(name).map(|new_name| new_name.as_ref())
+                deprecations.get(name).copied()
             };
 
             // Add an alternative for plain action names.
@@ -518,7 +540,9 @@ impl KeymapFile {
                 const_value: Some(Value::String(name.to_string())),
                 ..Default::default()
             };
-            if let Some(new_name) = deprecation {
+            if let Some(message) = deprecation_messages.get(name) {
+                add_deprecation(&mut plain_action, message.to_string());
+            } else if let Some(new_name) = deprecation {
                 add_deprecation_preferred_name(&mut plain_action, new_name);
             }
             if let Some(description) = description.clone() {
@@ -538,9 +562,11 @@ impl KeymapFile {
                         ..Default::default()
                     };
                     if let Some(description) = description.clone() {
-                        add_description(&mut matches_action_name, description.to_string());
+                        add_description(&mut matches_action_name, description);
                     }
-                    if let Some(new_name) = deprecation {
+                    if let Some(message) = deprecation_messages.get(name) {
+                        add_deprecation(&mut matches_action_name, message.to_string());
+                    } else if let Some(new_name) = deprecation {
                         add_deprecation_preferred_name(&mut matches_action_name, new_name);
                     }
                     let action_with_input = SchemaObject {
@@ -584,7 +610,7 @@ impl KeymapFile {
             .definitions
             .insert(KeymapAction::schema_name(), action_schema);
 
-        // This and other json schemas can be viewed via `debug: open language server logs` ->
+        // This and other json schemas can be viewed via `dev: open language server logs` ->
         // `json-language-server` -> `Server Info`.
         serde_json::to_value(root_schema).unwrap()
     }
@@ -606,11 +632,207 @@ impl KeymapFile {
             }
         }
     }
+
+    pub fn update_keybinding<'a>(
+        mut operation: KeybindUpdateOperation<'a>,
+        mut keymap_contents: String,
+        tab_size: usize,
+    ) -> Result<String> {
+        // if trying to replace a keybinding that is not user-defined, treat it as an add operation
+        match operation {
+            KeybindUpdateOperation::Replace {
+                target_source,
+                source,
+                ..
+            } if target_source != KeybindSource::User => {
+                operation = KeybindUpdateOperation::Add(source);
+            }
+            _ => {}
+        }
+
+        // Sanity check that keymap contents are valid, even though we only use it for Replace.
+        // We don't want to modify the file if it's invalid.
+        let keymap = Self::parse(&keymap_contents).context("Failed to parse keymap")?;
+
+        if let KeybindUpdateOperation::Replace { source, target, .. } = operation {
+            let mut found_index = None;
+            let target_action_value = target
+                .action_value()
+                .context("Failed to generate target action JSON value")?;
+            let source_action_value = source
+                .action_value()
+                .context("Failed to generate source action JSON value")?;
+            'sections: for (index, section) in keymap.sections().enumerate() {
+                if section.context != target.context.unwrap_or("") {
+                    continue;
+                }
+                if section.use_key_equivalents != target.use_key_equivalents {
+                    continue;
+                }
+                let Some(bindings) = &section.bindings else {
+                    continue;
+                };
+                for (keystrokes, action) in bindings {
+                    if keystrokes != target.keystrokes {
+                        continue;
+                    }
+                    if action.0 != target_action_value {
+                        continue;
+                    }
+                    found_index = Some(index);
+                    break 'sections;
+                }
+            }
+
+            if let Some(index) = found_index {
+                let (replace_range, replace_value) = replace_top_level_array_value_in_json_text(
+                    &keymap_contents,
+                    &["bindings", target.keystrokes],
+                    Some(&source_action_value),
+                    Some(source.keystrokes),
+                    index,
+                    tab_size,
+                )
+                .context("Failed to replace keybinding")?;
+                keymap_contents.replace_range(replace_range, &replace_value);
+
+                return Ok(keymap_contents);
+            } else {
+                log::warn!(
+                    "Failed to find keybinding to update `{:?} -> {}` creating new binding for `{:?} -> {}` instead",
+                    target.keystrokes,
+                    target_action_value,
+                    source.keystrokes,
+                    source_action_value,
+                );
+                operation = KeybindUpdateOperation::Add(source);
+            }
+        }
+
+        if let KeybindUpdateOperation::Add(keybinding) = operation {
+            let mut value = serde_json::Map::with_capacity(4);
+            if let Some(context) = keybinding.context {
+                value.insert("context".to_string(), context.into());
+            }
+            if keybinding.use_key_equivalents {
+                value.insert("use_key_equivalents".to_string(), true.into());
+            }
+
+            value.insert("bindings".to_string(), {
+                let mut bindings = serde_json::Map::new();
+                let action = keybinding.action_value()?;
+                bindings.insert(keybinding.keystrokes.into(), action);
+                bindings.into()
+            });
+
+            let (replace_range, replace_value) = append_top_level_array_value_in_json_text(
+                &keymap_contents,
+                &value.into(),
+                tab_size,
+            )?;
+            keymap_contents.replace_range(replace_range, &replace_value);
+        }
+        return Ok(keymap_contents);
+    }
+}
+
+pub enum KeybindUpdateOperation<'a> {
+    Replace {
+        /// Describes the keybind to create
+        source: KeybindUpdateTarget<'a>,
+        /// Describes the keybind to remove
+        target: KeybindUpdateTarget<'a>,
+        target_source: KeybindSource,
+    },
+    Add(KeybindUpdateTarget<'a>),
+}
+
+pub struct KeybindUpdateTarget<'a> {
+    context: Option<&'a str>,
+    keystrokes: &'a str,
+    action_name: &'a str,
+    use_key_equivalents: bool,
+    input: Option<&'a str>,
+}
+
+impl<'a> KeybindUpdateTarget<'a> {
+    fn action_value(&self) -> Result<Value> {
+        let action_name: Value = self.action_name.into();
+        let value = match self.input {
+            Some(input) => {
+                let input = serde_json::from_str::<Value>(input)
+                    .context("Failed to parse action input as JSON")?;
+                serde_json::json!([action_name, input])
+            }
+            None => action_name,
+        };
+        return Ok(value);
+    }
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum KeybindSource {
+    User,
+    Default,
+    Base,
+    Vim,
+}
+
+impl KeybindSource {
+    const BASE: KeyBindingMetaIndex = KeyBindingMetaIndex(0);
+    const DEFAULT: KeyBindingMetaIndex = KeyBindingMetaIndex(1);
+    const VIM: KeyBindingMetaIndex = KeyBindingMetaIndex(2);
+    const USER: KeyBindingMetaIndex = KeyBindingMetaIndex(3);
+
+    pub fn name(&self) -> &'static str {
+        match self {
+            KeybindSource::User => "User",
+            KeybindSource::Default => "Default",
+            KeybindSource::Base => "Base",
+            KeybindSource::Vim => "Vim",
+        }
+    }
+
+    pub fn meta(&self) -> KeyBindingMetaIndex {
+        match self {
+            KeybindSource::User => Self::USER,
+            KeybindSource::Default => Self::DEFAULT,
+            KeybindSource::Base => Self::BASE,
+            KeybindSource::Vim => Self::VIM,
+        }
+    }
+
+    pub fn from_meta(index: KeyBindingMetaIndex) -> Self {
+        match index {
+            _ if index == Self::USER => KeybindSource::User,
+            _ if index == Self::USER => KeybindSource::Base,
+            _ if index == Self::DEFAULT => KeybindSource::Default,
+            _ if index == Self::VIM => KeybindSource::Vim,
+            _ => unreachable!(),
+        }
+    }
+}
+
+impl From<KeyBindingMetaIndex> for KeybindSource {
+    fn from(index: KeyBindingMetaIndex) -> Self {
+        Self::from_meta(index)
+    }
+}
+
+impl From<KeybindSource> for KeyBindingMetaIndex {
+    fn from(source: KeybindSource) -> Self {
+        return source.meta();
+    }
 }
 
 #[cfg(test)]
 mod tests {
-    use crate::KeymapFile;
+    use unindent::Unindent;
+
+    use crate::{
+        KeybindSource, KeymapFile,
+        keymap_file::{KeybindUpdateOperation, KeybindUpdateTarget},
+    };
 
     #[test]
     fn can_deserialize_keymap_with_trailing_comma() {
@@ -626,4 +848,316 @@ mod tests {
         };
         KeymapFile::parse(json).unwrap();
     }
+
+    #[test]
+    fn keymap_update() {
+        zlog::init_test();
+        #[track_caller]
+        fn check_keymap_update(
+            input: impl ToString,
+            operation: KeybindUpdateOperation,
+            expected: impl ToString,
+        ) {
+            let result = KeymapFile::update_keybinding(operation, input.to_string(), 4)
+                .expect("Update succeeded");
+            pretty_assertions::assert_eq!(expected.to_string(), result);
+        }
+
+        check_keymap_update(
+            "[]",
+            KeybindUpdateOperation::Add(KeybindUpdateTarget {
+                keystrokes: "ctrl-a",
+                action_name: "zed::SomeAction",
+                context: None,
+                use_key_equivalents: false,
+                input: None,
+            }),
+            r#"[
+                {
+                    "bindings": {
+                        "ctrl-a": "zed::SomeAction"
+                    }
+                }
+            ]"#
+            .unindent(),
+        );
+
+        check_keymap_update(
+            r#"[
+                {
+                    "bindings": {
+                        "ctrl-a": "zed::SomeAction"
+                    }
+                }
+            ]"#
+            .unindent(),
+            KeybindUpdateOperation::Add(KeybindUpdateTarget {
+                keystrokes: "ctrl-b",
+                action_name: "zed::SomeOtherAction",
+                context: None,
+                use_key_equivalents: false,
+                input: None,
+            }),
+            r#"[
+                {
+                    "bindings": {
+                        "ctrl-a": "zed::SomeAction"
+                    }
+                },
+                {
+                    "bindings": {
+                        "ctrl-b": "zed::SomeOtherAction"
+                    }
+                }
+            ]"#
+            .unindent(),
+        );
+
+        check_keymap_update(
+            r#"[
+                {
+                    "bindings": {
+                        "ctrl-a": "zed::SomeAction"
+                    }
+                }
+            ]"#
+            .unindent(),
+            KeybindUpdateOperation::Add(KeybindUpdateTarget {
+                keystrokes: "ctrl-b",
+                action_name: "zed::SomeOtherAction",
+                context: None,
+                use_key_equivalents: false,
+                input: Some(r#"{"foo": "bar"}"#),
+            }),
+            r#"[
+                {
+                    "bindings": {
+                        "ctrl-a": "zed::SomeAction"
+                    }
+                },
+                {
+                    "bindings": {
+                        "ctrl-b": [
+                            "zed::SomeOtherAction",
+                            {
+                                "foo": "bar"
+                            }
+                        ]
+                    }
+                }
+            ]"#
+            .unindent(),
+        );
+
+        check_keymap_update(
+            r#"[
+                {
+                    "bindings": {
+                        "ctrl-a": "zed::SomeAction"
+                    }
+                }
+            ]"#
+            .unindent(),
+            KeybindUpdateOperation::Add(KeybindUpdateTarget {
+                keystrokes: "ctrl-b",
+                action_name: "zed::SomeOtherAction",
+                context: Some("Zed > Editor && some_condition = true"),
+                use_key_equivalents: true,
+                input: Some(r#"{"foo": "bar"}"#),
+            }),
+            r#"[
+                {
+                    "bindings": {
+                        "ctrl-a": "zed::SomeAction"
+                    }
+                },
+                {
+                    "context": "Zed > Editor && some_condition = true",
+                    "use_key_equivalents": true,
+                    "bindings": {
+                        "ctrl-b": [
+                            "zed::SomeOtherAction",
+                            {
+                                "foo": "bar"
+                            }
+                        ]
+                    }
+                }
+            ]"#
+            .unindent(),
+        );
+
+        check_keymap_update(
+            r#"[
+                {
+                    "bindings": {
+                        "ctrl-a": "zed::SomeAction"
+                    }
+                }
+            ]"#
+            .unindent(),
+            KeybindUpdateOperation::Replace {
+                target: KeybindUpdateTarget {
+                    keystrokes: "ctrl-a",
+                    action_name: "zed::SomeAction",
+                    context: None,
+                    use_key_equivalents: false,
+                    input: None,
+                },
+                source: KeybindUpdateTarget {
+                    keystrokes: "ctrl-b",
+                    action_name: "zed::SomeOtherAction",
+                    context: None,
+                    use_key_equivalents: false,
+                    input: Some(r#"{"foo": "bar"}"#),
+                },
+                target_source: KeybindSource::Base,
+            },
+            r#"[
+                {
+                    "bindings": {
+                        "ctrl-a": "zed::SomeAction"
+                    }
+                },
+                {
+                    "bindings": {
+                        "ctrl-b": [
+                            "zed::SomeOtherAction",
+                            {
+                                "foo": "bar"
+                            }
+                        ]
+                    }
+                }
+            ]"#
+            .unindent(),
+        );
+
+        check_keymap_update(
+            r#"[
+                {
+                    "bindings": {
+                        "ctrl-a": "zed::SomeAction"
+                    }
+                }
+            ]"#
+            .unindent(),
+            KeybindUpdateOperation::Replace {
+                target: KeybindUpdateTarget {
+                    keystrokes: "ctrl-a",
+                    action_name: "zed::SomeAction",
+                    context: None,
+                    use_key_equivalents: false,
+                    input: None,
+                },
+                source: KeybindUpdateTarget {
+                    keystrokes: "ctrl-b",
+                    action_name: "zed::SomeOtherAction",
+                    context: None,
+                    use_key_equivalents: false,
+                    input: Some(r#"{"foo": "bar"}"#),
+                },
+                target_source: KeybindSource::User,
+            },
+            r#"[
+                {
+                    "bindings": {
+                        "ctrl-b": [
+                            "zed::SomeOtherAction",
+                            {
+                                "foo": "bar"
+                            }
+                        ]
+                    }
+                }
+            ]"#
+            .unindent(),
+        );
+
+        check_keymap_update(
+            r#"[
+                {
+                    "bindings": {
+                        "ctrl-a": "zed::SomeAction"
+                    }
+                }
+            ]"#
+            .unindent(),
+            KeybindUpdateOperation::Replace {
+                target: KeybindUpdateTarget {
+                    keystrokes: "ctrl-a",
+                    action_name: "zed::SomeNonexistentAction",
+                    context: None,
+                    use_key_equivalents: false,
+                    input: None,
+                },
+                source: KeybindUpdateTarget {
+                    keystrokes: "ctrl-b",
+                    action_name: "zed::SomeOtherAction",
+                    context: None,
+                    use_key_equivalents: false,
+                    input: None,
+                },
+                target_source: KeybindSource::User,
+            },
+            r#"[
+                {
+                    "bindings": {
+                        "ctrl-a": "zed::SomeAction"
+                    }
+                },
+                {
+                    "bindings": {
+                        "ctrl-b": "zed::SomeOtherAction"
+                    }
+                }
+            ]"#
+            .unindent(),
+        );
+
+        check_keymap_update(
+            r#"[
+                {
+                    "bindings": {
+                        // some comment
+                        "ctrl-a": "zed::SomeAction"
+                        // some other comment
+                    }
+                }
+            ]"#
+            .unindent(),
+            KeybindUpdateOperation::Replace {
+                target: KeybindUpdateTarget {
+                    keystrokes: "ctrl-a",
+                    action_name: "zed::SomeAction",
+                    context: None,
+                    use_key_equivalents: false,
+                    input: None,
+                },
+                source: KeybindUpdateTarget {
+                    keystrokes: "ctrl-b",
+                    action_name: "zed::SomeOtherAction",
+                    context: None,
+                    use_key_equivalents: false,
+                    input: Some(r#"{"foo": "bar"}"#),
+                },
+                target_source: KeybindSource::User,
+            },
+            r#"[
+                {
+                    "bindings": {
+                        // some comment
+                        "ctrl-b": [
+                            "zed::SomeOtherAction",
+                            {
+                                "foo": "bar"
+                            }
+                        ]
+                        // some other comment
+                    }
+                }
+            ]"#
+            .unindent(),
+        );
+    }
 }

crates/settings/src/settings.rs 🔗

@@ -1,8 +1,8 @@
 mod editable_setting_control;
-mod json_schema;
 mod key_equivalents;
 mod keymap_file;
 mod settings_file;
+mod settings_json;
 mod settings_store;
 mod vscode_import;
 
@@ -12,17 +12,18 @@ use std::{borrow::Cow, fmt, str};
 use util::asset_str;
 
 pub use editable_setting_control::*;
-pub use json_schema::*;
 pub use key_equivalents::*;
 pub use keymap_file::{
-    KeyBindingValidator, KeyBindingValidatorRegistration, KeymapFile, KeymapFileLoadResult,
+    KeyBindingValidator, KeyBindingValidatorRegistration, KeybindSource, KeymapFile,
+    KeymapFileLoadResult,
 };
 pub use settings_file::*;
+pub use settings_json::*;
 pub use settings_store::{
     InvalidSettingsError, LocalSettingsKind, Settings, SettingsLocation, SettingsSources,
-    SettingsStore, parse_json_with_comments,
+    SettingsStore,
 };
-pub use vscode_import::VsCodeSettings;
+pub use vscode_import::{VsCodeSettings, VsCodeSettingsSource};
 
 #[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
 pub struct WorktreeId(usize);
@@ -115,3 +116,7 @@ pub fn initial_tasks_content() -> Cow<'static, str> {
 pub fn initial_debug_tasks_content() -> Cow<'static, str> {
     asset_str::<SettingsAssets>("settings/initial_debug_tasks.json")
 }
+
+pub fn initial_local_debug_tasks_content() -> Cow<'static, str> {
+    asset_str::<SettingsAssets>("settings/initial_local_debug_tasks.json")
+}

crates/settings/src/settings_file.rs 🔗

@@ -9,10 +9,9 @@ pub const EMPTY_THEME_NAME: &str = "empty-theme";
 
 #[cfg(any(test, feature = "test-support"))]
 pub fn test_settings() -> String {
-    let mut value = crate::settings_store::parse_json_with_comments::<serde_json::Value>(
-        crate::default_settings().as_ref(),
-    )
-    .unwrap();
+    let mut value =
+        crate::parse_json_with_comments::<serde_json::Value>(crate::default_settings().as_ref())
+            .unwrap();
     #[cfg(not(target_os = "windows"))]
     util::merge_non_null_json_value_into(
         serde_json::json!({

crates/settings/src/settings_json.rs 🔗

@@ -0,0 +1,1646 @@
+use std::{ops::Range, sync::LazyLock};
+
+use anyhow::Result;
+use schemars::schema::{
+    ArrayValidation, InstanceType, RootSchema, Schema, SchemaObject, SingleOrVec,
+};
+use serde::{Serialize, de::DeserializeOwned};
+use serde_json::Value;
+use tree_sitter::{Query, StreamingIterator as _};
+use util::RangeExt;
+
+pub struct SettingsJsonSchemaParams<'a> {
+    pub language_names: &'a [String],
+    pub font_names: &'a [String],
+}
+
+impl SettingsJsonSchemaParams<'_> {
+    pub fn font_family_schema(&self) -> Schema {
+        let available_fonts: Vec<_> = self.font_names.iter().cloned().map(Value::String).collect();
+
+        SchemaObject {
+            instance_type: Some(InstanceType::String.into()),
+            enum_values: Some(available_fonts),
+            ..Default::default()
+        }
+        .into()
+    }
+
+    pub fn font_fallback_schema(&self) -> Schema {
+        SchemaObject {
+            instance_type: Some(SingleOrVec::Vec(vec![
+                InstanceType::Array,
+                InstanceType::Null,
+            ])),
+            array: Some(Box::new(ArrayValidation {
+                items: Some(schemars::schema::SingleOrVec::Single(Box::new(
+                    self.font_family_schema(),
+                ))),
+                unique_items: Some(true),
+                ..Default::default()
+            })),
+            ..Default::default()
+        }
+        .into()
+    }
+}
+
+type PropertyName<'a> = &'a str;
+type ReferencePath<'a> = &'a str;
+
+/// Modifies the provided [`RootSchema`] by adding references to all of the specified properties.
+///
+/// # Examples
+///
+/// ```
+/// # let root_schema = RootSchema::default();
+/// add_references_to_properties(&mut root_schema, &[
+///     ("property_a", "#/definitions/DefinitionA"),
+///     ("property_b", "#/definitions/DefinitionB"),
+/// ])
+/// ```
+pub fn add_references_to_properties(
+    root_schema: &mut RootSchema,
+    properties_with_references: &[(PropertyName, ReferencePath)],
+) {
+    for (property, definition) in properties_with_references {
+        let Some(schema) = root_schema.schema.object().properties.get_mut(*property) else {
+            log::warn!("property '{property}' not found in JSON schema");
+            continue;
+        };
+
+        match schema {
+            Schema::Object(schema) => {
+                schema.reference = Some(definition.to_string());
+            }
+            Schema::Bool(_) => {
+                // Boolean schemas can't have references.
+            }
+        }
+    }
+}
+
+pub fn update_value_in_json_text<'a>(
+    text: &mut String,
+    key_path: &mut Vec<&'a str>,
+    tab_size: usize,
+    old_value: &'a Value,
+    new_value: &'a Value,
+    preserved_keys: &[&str],
+    edits: &mut Vec<(Range<usize>, String)>,
+) {
+    // If the old and new values are both objects, then compare them key by key,
+    // preserving the comments and formatting of the unchanged parts. Otherwise,
+    // replace the old value with the new value.
+    if let (Value::Object(old_object), Value::Object(new_object)) = (old_value, new_value) {
+        for (key, old_sub_value) in old_object.iter() {
+            key_path.push(key);
+            if let Some(new_sub_value) = new_object.get(key) {
+                // Key exists in both old and new, recursively update
+                update_value_in_json_text(
+                    text,
+                    key_path,
+                    tab_size,
+                    old_sub_value,
+                    new_sub_value,
+                    preserved_keys,
+                    edits,
+                );
+            } else {
+                // Key was removed from new object, remove the entire key-value pair
+                let (range, replacement) =
+                    replace_value_in_json_text(text, key_path, 0, None, None);
+                text.replace_range(range.clone(), &replacement);
+                edits.push((range, replacement));
+            }
+            key_path.pop();
+        }
+        for (key, new_sub_value) in new_object.iter() {
+            key_path.push(key);
+            if !old_object.contains_key(key) {
+                update_value_in_json_text(
+                    text,
+                    key_path,
+                    tab_size,
+                    &Value::Null,
+                    new_sub_value,
+                    preserved_keys,
+                    edits,
+                );
+            }
+            key_path.pop();
+        }
+    } else if key_path
+        .last()
+        .map_or(false, |key| preserved_keys.contains(key))
+        || old_value != new_value
+    {
+        let mut new_value = new_value.clone();
+        if let Some(new_object) = new_value.as_object_mut() {
+            new_object.retain(|_, v| !v.is_null());
+        }
+        let (range, replacement) =
+            replace_value_in_json_text(text, key_path, tab_size, Some(&new_value), None);
+        text.replace_range(range.clone(), &replacement);
+        edits.push((range, replacement));
+    }
+}
+
+/// * `replace_key` - When an exact key match according to `key_path` is found, replace the key with `replace_key` if `Some`.
+fn replace_value_in_json_text(
+    text: &str,
+    key_path: &[&str],
+    tab_size: usize,
+    new_value: Option<&Value>,
+    replace_key: Option<&str>,
+) -> (Range<usize>, String) {
+    static PAIR_QUERY: LazyLock<Query> = LazyLock::new(|| {
+        Query::new(
+            &tree_sitter_json::LANGUAGE.into(),
+            "(pair key: (string) @key value: (_) @value)",
+        )
+        .expect("Failed to create PAIR_QUERY")
+    });
+
+    let mut parser = tree_sitter::Parser::new();
+    parser
+        .set_language(&tree_sitter_json::LANGUAGE.into())
+        .unwrap();
+    let syntax_tree = parser.parse(text, None).unwrap();
+
+    let mut cursor = tree_sitter::QueryCursor::new();
+
+    let mut depth = 0;
+    let mut last_value_range = 0..0;
+    let mut first_key_start = None;
+    let mut existing_value_range = 0..text.len();
+
+    let mut matches = cursor.matches(&PAIR_QUERY, syntax_tree.root_node(), text.as_bytes());
+    while let Some(mat) = matches.next() {
+        if mat.captures.len() != 2 {
+            continue;
+        }
+
+        let key_range = mat.captures[0].node.byte_range();
+        let value_range = mat.captures[1].node.byte_range();
+
+        // Don't enter sub objects until we find an exact
+        // match for the current keypath
+        if last_value_range.contains_inclusive(&value_range) {
+            continue;
+        }
+
+        last_value_range = value_range.clone();
+
+        if key_range.start > existing_value_range.end {
+            break;
+        }
+
+        first_key_start.get_or_insert(key_range.start);
+
+        let found_key = text
+            .get(key_range.clone())
+            .map(|key_text| {
+                depth < key_path.len() && key_text == format!("\"{}\"", key_path[depth])
+            })
+            .unwrap_or(false);
+
+        if found_key {
+            existing_value_range = value_range;
+            // Reset last value range when increasing in depth
+            last_value_range = existing_value_range.start..existing_value_range.start;
+            depth += 1;
+
+            if depth == key_path.len() {
+                break;
+            }
+
+            first_key_start = None;
+        }
+    }
+
+    // We found the exact key we want
+    if depth == key_path.len() {
+        if let Some(new_value) = new_value {
+            let new_val = to_pretty_json(new_value, tab_size, tab_size * depth);
+            if let Some(replace_key) = replace_key {
+                let new_key = format!("\"{}\": ", replace_key);
+                if let Some(key_start) = text[..existing_value_range.start].rfind('"') {
+                    if let Some(prev_key_start) = text[..key_start].rfind('"') {
+                        existing_value_range.start = prev_key_start;
+                    } else {
+                        existing_value_range.start = key_start;
+                    }
+                }
+                (existing_value_range, new_key + &new_val)
+            } else {
+                (existing_value_range, new_val)
+            }
+        } else {
+            let mut removal_start = first_key_start.unwrap_or(existing_value_range.start);
+            let mut removal_end = existing_value_range.end;
+
+            // Find the actual key position by looking for the key in the pair
+            // We need to extend the range to include the key, not just the value
+            if let Some(key_start) = text[..existing_value_range.start].rfind('"') {
+                if let Some(prev_key_start) = text[..key_start].rfind('"') {
+                    removal_start = prev_key_start;
+                } else {
+                    removal_start = key_start;
+                }
+            }
+
+            // Look backward for a preceding comma first
+            let preceding_text = text.get(0..removal_start).unwrap_or("");
+            if let Some(comma_pos) = preceding_text.rfind(',') {
+                // Check if there are only whitespace characters between the comma and our key
+                let between_comma_and_key = text.get(comma_pos + 1..removal_start).unwrap_or("");
+                if between_comma_and_key.trim().is_empty() {
+                    removal_start = comma_pos;
+                }
+            }
+
+            if let Some(remaining_text) = text.get(existing_value_range.end..) {
+                let mut chars = remaining_text.char_indices();
+                while let Some((offset, ch)) = chars.next() {
+                    if ch == ',' {
+                        removal_end = existing_value_range.end + offset + 1;
+                        // Also consume whitespace after the comma
+                        while let Some((_, next_ch)) = chars.next() {
+                            if next_ch.is_whitespace() {
+                                removal_end += next_ch.len_utf8();
+                            } else {
+                                break;
+                            }
+                        }
+                        break;
+                    } else if !ch.is_whitespace() {
+                        break;
+                    }
+                }
+            }
+            (removal_start..removal_end, String::new())
+        }
+    } else {
+        // We have key paths, construct the sub objects
+        let new_key = key_path[depth];
+
+        // We don't have the key, construct the nested objects
+        let mut new_value =
+            serde_json::to_value(new_value.unwrap_or(&serde_json::Value::Null)).unwrap();
+        for key in key_path[(depth + 1)..].iter().rev() {
+            new_value = serde_json::json!({ key.to_string(): new_value });
+        }
+
+        if let Some(first_key_start) = first_key_start {
+            let mut row = 0;
+            let mut column = 0;
+            for (ix, char) in text.char_indices() {
+                if ix == first_key_start {
+                    break;
+                }
+                if char == '\n' {
+                    row += 1;
+                    column = 0;
+                } else {
+                    column += char.len_utf8();
+                }
+            }
+
+            if row > 0 {
+                // depth is 0 based, but division needs to be 1 based.
+                let new_val = to_pretty_json(&new_value, column / (depth + 1), column);
+                let space = ' ';
+                let content = format!("\"{new_key}\": {new_val},\n{space:width$}", width = column);
+                (first_key_start..first_key_start, content)
+            } else {
+                let new_val = serde_json::to_string(&new_value).unwrap();
+                let mut content = format!(r#""{new_key}": {new_val},"#);
+                content.push(' ');
+                (first_key_start..first_key_start, content)
+            }
+        } else {
+            new_value = serde_json::json!({ new_key.to_string(): new_value });
+            let indent_prefix_len = 4 * depth;
+            let mut new_val = to_pretty_json(&new_value, 4, indent_prefix_len);
+            if depth == 0 {
+                new_val.push('\n');
+            }
+            // best effort to keep comments with best effort indentation
+            let mut replace_text = &text[existing_value_range.clone()];
+            while let Some(comment_start) = replace_text.rfind("//") {
+                if let Some(comment_end) = replace_text[comment_start..].find('\n') {
+                    let mut comment_with_indent_start = replace_text[..comment_start]
+                        .rfind('\n')
+                        .unwrap_or(comment_start);
+                    if !replace_text[comment_with_indent_start..comment_start]
+                        .trim()
+                        .is_empty()
+                    {
+                        comment_with_indent_start = comment_start;
+                    }
+                    new_val.insert_str(
+                        1,
+                        &replace_text[comment_with_indent_start..comment_start + comment_end],
+                    );
+                }
+                replace_text = &replace_text[..comment_start];
+            }
+
+            (existing_value_range, new_val)
+        }
+    }
+}
+
+const TS_DOCUMENT_KIND: &'static str = "document";
+const TS_ARRAY_KIND: &'static str = "array";
+const TS_COMMENT_KIND: &'static str = "comment";
+
+pub fn replace_top_level_array_value_in_json_text(
+    text: &str,
+    key_path: &[&str],
+    new_value: Option<&Value>,
+    replace_key: Option<&str>,
+    array_index: usize,
+    tab_size: usize,
+) -> Result<(Range<usize>, String)> {
+    let mut parser = tree_sitter::Parser::new();
+    parser
+        .set_language(&tree_sitter_json::LANGUAGE.into())
+        .unwrap();
+    let syntax_tree = parser.parse(text, None).unwrap();
+
+    let mut cursor = syntax_tree.walk();
+
+    if cursor.node().kind() == TS_DOCUMENT_KIND {
+        anyhow::ensure!(
+            cursor.goto_first_child(),
+            "Document empty - No top level array"
+        );
+    }
+
+    while cursor.node().kind() != TS_ARRAY_KIND {
+        anyhow::ensure!(cursor.goto_next_sibling(), "EOF - No top level array");
+    }
+
+    // false if no children
+    //
+    cursor.goto_first_child();
+    debug_assert_eq!(cursor.node().kind(), "[");
+
+    let mut index = 0;
+
+    while index <= array_index {
+        let node = cursor.node();
+        if !matches!(node.kind(), "[" | "]" | TS_COMMENT_KIND | ",")
+            && !node.is_extra()
+            && !node.is_missing()
+        {
+            if index == array_index {
+                break;
+            }
+            index += 1;
+        }
+        if !cursor.goto_next_sibling() {
+            if let Some(new_value) = new_value {
+                return append_top_level_array_value_in_json_text(text, new_value, tab_size);
+            } else {
+                return Ok((0..0, String::new()));
+            }
+        }
+    }
+
+    let range = cursor.node().range();
+    let indent_width = range.start_point.column;
+    let offset = range.start_byte;
+    let value_str = &text[range.start_byte..range.end_byte];
+    let needs_indent = range.start_point.row > 0;
+
+    let (mut replace_range, mut replace_value) =
+        replace_value_in_json_text(value_str, key_path, tab_size, new_value, replace_key);
+
+    replace_range.start += offset;
+    replace_range.end += offset;
+
+    if needs_indent {
+        let increased_indent = format!("\n{space:width$}", space = ' ', width = indent_width);
+        replace_value = replace_value.replace('\n', &increased_indent);
+        // replace_value.push('\n');
+    } else {
+        while let Some(idx) = replace_value.find("\n ") {
+            replace_value.remove(idx + 1);
+        }
+        while let Some(idx) = replace_value.find("\n") {
+            replace_value.replace_range(idx..idx + 1, " ");
+        }
+    }
+
+    return Ok((replace_range, replace_value));
+}
+
+pub fn append_top_level_array_value_in_json_text(
+    text: &str,
+    new_value: &Value,
+    tab_size: usize,
+) -> Result<(Range<usize>, String)> {
+    let mut parser = tree_sitter::Parser::new();
+    parser
+        .set_language(&tree_sitter_json::LANGUAGE.into())
+        .unwrap();
+    let syntax_tree = parser.parse(text, None).unwrap();
+
+    let mut cursor = syntax_tree.walk();
+
+    if cursor.node().kind() == TS_DOCUMENT_KIND {
+        anyhow::ensure!(
+            cursor.goto_first_child(),
+            "Document empty - No top level array"
+        );
+    }
+
+    while cursor.node().kind() != TS_ARRAY_KIND {
+        anyhow::ensure!(cursor.goto_next_sibling(), "EOF - No top level array");
+    }
+
+    anyhow::ensure!(
+        cursor.goto_last_child(),
+        "Malformed JSON syntax tree, expected `]` at end of array"
+    );
+    debug_assert_eq!(cursor.node().kind(), "]");
+    let close_bracket_start = cursor.node().start_byte();
+    cursor.goto_previous_sibling();
+    while (cursor.node().is_extra() || cursor.node().is_missing()) && cursor.goto_previous_sibling()
+    {
+    }
+
+    let mut comma_range = None;
+    let mut prev_item_range = None;
+
+    if cursor.node().kind() == "," {
+        comma_range = Some(cursor.node().byte_range());
+        while cursor.goto_previous_sibling() && cursor.node().is_extra() {}
+
+        debug_assert_ne!(cursor.node().kind(), "[");
+        prev_item_range = Some(cursor.node().range());
+    } else {
+        while (cursor.node().is_extra() || cursor.node().is_missing())
+            && cursor.goto_previous_sibling()
+        {}
+        if cursor.node().kind() != "[" {
+            prev_item_range = Some(cursor.node().range());
+        }
+    }
+
+    let (mut replace_range, mut replace_value) =
+        replace_value_in_json_text("", &[], tab_size, Some(new_value), None);
+
+    replace_range.start = close_bracket_start;
+    replace_range.end = close_bracket_start;
+
+    let space = ' ';
+    if let Some(prev_item_range) = prev_item_range {
+        let needs_newline = prev_item_range.start_point.row > 0;
+        let indent_width = text[..prev_item_range.start_byte].rfind('\n').map_or(
+            prev_item_range.start_point.column,
+            |idx| {
+                prev_item_range.start_point.column
+                    - text[idx + 1..prev_item_range.start_byte].trim_start().len()
+            },
+        );
+
+        let prev_item_end = comma_range
+            .as_ref()
+            .map_or(prev_item_range.end_byte, |range| range.end);
+        if text[prev_item_end..replace_range.start].trim().is_empty() {
+            replace_range.start = prev_item_end;
+        }
+
+        if needs_newline {
+            let increased_indent = format!("\n{space:width$}", width = indent_width);
+            replace_value = replace_value.replace('\n', &increased_indent);
+            replace_value.push('\n');
+            replace_value.insert_str(0, &format!("\n{space:width$}", width = indent_width));
+        } else {
+            while let Some(idx) = replace_value.find("\n ") {
+                replace_value.remove(idx + 1);
+            }
+            while let Some(idx) = replace_value.find('\n') {
+                replace_value.replace_range(idx..idx + 1, " ");
+            }
+            replace_value.insert(0, ' ');
+        }
+
+        if comma_range.is_none() {
+            replace_value.insert(0, ',');
+        }
+    } else {
+        if let Some(prev_newline) = text[..replace_range.start].rfind('\n') {
+            if text[prev_newline..replace_range.start].trim().is_empty() {
+                replace_range.start = prev_newline;
+            }
+        }
+        let indent = format!("\n{space:width$}", width = tab_size);
+        replace_value = replace_value.replace('\n', &indent);
+        replace_value.insert_str(0, &indent);
+        replace_value.push('\n');
+    }
+    return Ok((replace_range, replace_value));
+}
+
+pub fn to_pretty_json(
+    value: &impl Serialize,
+    indent_size: usize,
+    indent_prefix_len: usize,
+) -> String {
+    const SPACES: [u8; 32] = [b' '; 32];
+
+    debug_assert!(indent_size <= SPACES.len());
+    debug_assert!(indent_prefix_len <= SPACES.len());
+
+    let mut output = Vec::new();
+    let mut ser = serde_json::Serializer::with_formatter(
+        &mut output,
+        serde_json::ser::PrettyFormatter::with_indent(&SPACES[0..indent_size.min(SPACES.len())]),
+    );
+
+    value.serialize(&mut ser).unwrap();
+    let text = String::from_utf8(output).unwrap();
+
+    let mut adjusted_text = String::new();
+    for (i, line) in text.split('\n').enumerate() {
+        if i > 0 {
+            adjusted_text.push_str(str::from_utf8(&SPACES[0..indent_prefix_len]).unwrap());
+        }
+        adjusted_text.push_str(line);
+        adjusted_text.push('\n');
+    }
+    adjusted_text.pop();
+    adjusted_text
+}
+
+pub fn parse_json_with_comments<T: DeserializeOwned>(content: &str) -> Result<T> {
+    Ok(serde_json_lenient::from_str(content)?)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::{Value, json};
+    use unindent::Unindent;
+
+    #[test]
+    fn object_replace() {
+        #[track_caller]
+        fn check_object_replace(
+            input: String,
+            key_path: &[&str],
+            value: Option<Value>,
+            expected: String,
+        ) {
+            let result = replace_value_in_json_text(&input, key_path, 4, value.as_ref(), None);
+            let mut result_str = input.to_string();
+            result_str.replace_range(result.0, &result.1);
+            pretty_assertions::assert_eq!(expected, result_str);
+        }
+        check_object_replace(
+            r#"{
+                "a": 1,
+                "b": 2
+            }"#
+            .unindent(),
+            &["b"],
+            Some(json!(3)),
+            r#"{
+                "a": 1,
+                "b": 3
+            }"#
+            .unindent(),
+        );
+        check_object_replace(
+            r#"{
+                "a": 1,
+                "b": 2
+            }"#
+            .unindent(),
+            &["b"],
+            None,
+            r#"{
+                "a": 1
+            }"#
+            .unindent(),
+        );
+        check_object_replace(
+            r#"{
+                "a": 1,
+                "b": 2
+            }"#
+            .unindent(),
+            &["c"],
+            Some(json!(3)),
+            r#"{
+                "c": 3,
+                "a": 1,
+                "b": 2
+            }"#
+            .unindent(),
+        );
+        check_object_replace(
+            r#"{
+                "a": 1,
+                "b": {
+                    "c": 2,
+                    "d": 3,
+                }
+            }"#
+            .unindent(),
+            &["b", "c"],
+            Some(json!([1, 2, 3])),
+            r#"{
+                "a": 1,
+                "b": {
+                    "c": [
+                        1,
+                        2,
+                        3
+                    ],
+                    "d": 3,
+                }
+            }"#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{
+                "name": "old_name",
+                "id": 123
+            }"#
+            .unindent(),
+            &["name"],
+            Some(json!("new_name")),
+            r#"{
+                "name": "new_name",
+                "id": 123
+            }"#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{
+                "enabled": false,
+                "count": 5
+            }"#
+            .unindent(),
+            &["enabled"],
+            Some(json!(true)),
+            r#"{
+                "enabled": true,
+                "count": 5
+            }"#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{
+                "value": null,
+                "other": "test"
+            }"#
+            .unindent(),
+            &["value"],
+            Some(json!(42)),
+            r#"{
+                "value": 42,
+                "other": "test"
+            }"#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{
+                "config": {
+                    "old": true
+                },
+                "name": "test"
+            }"#
+            .unindent(),
+            &["config"],
+            Some(json!({"new": false, "count": 3})),
+            r#"{
+                "config": {
+                    "new": false,
+                    "count": 3
+                },
+                "name": "test"
+            }"#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{
+                // This is a comment
+                "a": 1,
+                "b": 2 // Another comment
+            }"#
+            .unindent(),
+            &["b"],
+            Some(json!({"foo": "bar"})),
+            r#"{
+                // This is a comment
+                "a": 1,
+                "b": {
+                    "foo": "bar"
+                } // Another comment
+            }"#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{}"#.to_string(),
+            &["new_key"],
+            Some(json!("value")),
+            r#"{
+                "new_key": "value"
+            }
+            "#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{
+                "only_key": 123
+            }"#
+            .unindent(),
+            &["only_key"],
+            None,
+            "{\n    \n}".to_string(),
+        );
+
+        check_object_replace(
+            r#"{
+                "level1": {
+                    "level2": {
+                        "level3": {
+                            "target": "old"
+                        }
+                    }
+                }
+            }"#
+            .unindent(),
+            &["level1", "level2", "level3", "target"],
+            Some(json!("new")),
+            r#"{
+                "level1": {
+                    "level2": {
+                        "level3": {
+                            "target": "new"
+                        }
+                    }
+                }
+            }"#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{
+                "parent": {}
+            }"#
+            .unindent(),
+            &["parent", "child"],
+            Some(json!("value")),
+            r#"{
+                "parent": {
+                    "child": "value"
+                }
+            }"#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{
+                "a": 1,
+                "b": 2,
+            }"#
+            .unindent(),
+            &["b"],
+            Some(json!(3)),
+            r#"{
+                "a": 1,
+                "b": 3,
+            }"#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{
+                "items": [1, 2, 3],
+                "count": 3
+            }"#
+            .unindent(),
+            &["items", "1"],
+            Some(json!(5)),
+            r#"{
+                "items": {
+                    "1": 5
+                },
+                "count": 3
+            }"#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{
+                "items": [1, 2, 3],
+                "count": 3
+            }"#
+            .unindent(),
+            &["items", "1"],
+            None,
+            r#"{
+                "items": {
+                    "1": null
+                },
+                "count": 3
+            }"#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{
+                "items": [1, 2, 3],
+                "count": 3
+            }"#
+            .unindent(),
+            &["items"],
+            Some(json!(["a", "b", "c", "d"])),
+            r#"{
+                "items": [
+                    "a",
+                    "b",
+                    "c",
+                    "d"
+                ],
+                "count": 3
+            }"#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{
+                "0": "zero",
+                "1": "one"
+            }"#
+            .unindent(),
+            &["1"],
+            Some(json!("ONE")),
+            r#"{
+                "0": "zero",
+                "1": "ONE"
+            }"#
+            .unindent(),
+        );
+        // Test with comments between object members
+        check_object_replace(
+            r#"{
+                "a": 1,
+                // Comment between members
+                "b": 2,
+                /* Block comment */
+                "c": 3
+            }"#
+            .unindent(),
+            &["b"],
+            Some(json!({"nested": true})),
+            r#"{
+                "a": 1,
+                // Comment between members
+                "b": {
+                    "nested": true
+                },
+                /* Block comment */
+                "c": 3
+            }"#
+            .unindent(),
+        );
+
+        // Test with trailing comments on replaced value
+        check_object_replace(
+            r#"{
+                "a": 1, // keep this comment
+                "b": 2  // this should stay
+            }"#
+            .unindent(),
+            &["a"],
+            Some(json!("changed")),
+            r#"{
+                "a": "changed", // keep this comment
+                "b": 2  // this should stay
+            }"#
+            .unindent(),
+        );
+
+        // Test with deep indentation
+        check_object_replace(
+            r#"{
+                        "deeply": {
+                                "nested": {
+                                        "value": "old"
+                                }
+                        }
+                }"#
+            .unindent(),
+            &["deeply", "nested", "value"],
+            Some(json!("new")),
+            r#"{
+                        "deeply": {
+                                "nested": {
+                                        "value": "new"
+                                }
+                        }
+                }"#
+            .unindent(),
+        );
+
+        // Test removing value with comment preservation
+        check_object_replace(
+            r#"{
+                // Header comment
+                "a": 1,
+                // This comment belongs to b
+                "b": 2,
+                // This comment belongs to c
+                "c": 3
+            }"#
+            .unindent(),
+            &["b"],
+            None,
+            r#"{
+                // Header comment
+                "a": 1,
+                // This comment belongs to b
+                // This comment belongs to c
+                "c": 3
+            }"#
+            .unindent(),
+        );
+
+        // Test with multiline block comments
+        check_object_replace(
+            r#"{
+                /*
+                 * This is a multiline
+                 * block comment
+                 */
+                "value": "old",
+                /* Another block */ "other": 123
+            }"#
+            .unindent(),
+            &["value"],
+            Some(json!("new")),
+            r#"{
+                /*
+                 * This is a multiline
+                 * block comment
+                 */
+                "value": "new",
+                /* Another block */ "other": 123
+            }"#
+            .unindent(),
+        );
+
+        check_object_replace(
+            r#"{
+                // This object is empty
+            }"#
+            .unindent(),
+            &["key"],
+            Some(json!("value")),
+            r#"{
+                // This object is empty
+                "key": "value"
+            }
+            "#
+            .unindent(),
+        );
+
+        // Test replacing in object with only comments
+        check_object_replace(
+            r#"{
+                // Comment 1
+                // Comment 2
+            }"#
+            .unindent(),
+            &["new"],
+            Some(json!(42)),
+            r#"{
+                // Comment 1
+                // Comment 2
+                "new": 42
+            }
+            "#
+            .unindent(),
+        );
+
+        // Test with inconsistent spacing
+        check_object_replace(
+            r#"{
+              "a":1,
+                    "b"  :  2  ,
+                "c":   3
+            }"#
+            .unindent(),
+            &["b"],
+            Some(json!("spaced")),
+            r#"{
+              "a":1,
+                    "b"  :  "spaced"  ,
+                "c":   3
+            }"#
+            .unindent(),
+        );
+    }
+
+    #[test]
+    fn array_replace() {
+        #[track_caller]
+        fn check_array_replace(
+            input: impl ToString,
+            index: usize,
+            key_path: &[&str],
+            value: Value,
+            expected: impl ToString,
+        ) {
+            let input = input.to_string();
+            let result = replace_top_level_array_value_in_json_text(
+                &input,
+                key_path,
+                Some(&value),
+                None,
+                index,
+                4,
+            )
+            .expect("replace succeeded");
+            let mut result_str = input;
+            result_str.replace_range(result.0, &result.1);
+            pretty_assertions::assert_eq!(expected.to_string(), result_str);
+        }
+
+        check_array_replace(r#"[1, 3, 3]"#, 1, &[], json!(2), r#"[1, 2, 3]"#);
+        check_array_replace(r#"[1, 3, 3]"#, 2, &[], json!(2), r#"[1, 3, 2]"#);
+        check_array_replace(r#"[1, 3, 3,]"#, 3, &[], json!(2), r#"[1, 3, 3, 2]"#);
+        check_array_replace(r#"[1, 3, 3,]"#, 100, &[], json!(2), r#"[1, 3, 3, 2]"#);
+        check_array_replace(
+            r#"[
+                1,
+                2,
+                3,
+            ]"#
+            .unindent(),
+            1,
+            &[],
+            json!({"foo": "bar", "baz": "qux"}),
+            r#"[
+                1,
+                {
+                    "foo": "bar",
+                    "baz": "qux"
+                },
+                3,
+            ]"#
+            .unindent(),
+        );
+        check_array_replace(
+            r#"[1, 3, 3,]"#,
+            1,
+            &[],
+            json!({"foo": "bar", "baz": "qux"}),
+            r#"[1, { "foo": "bar", "baz": "qux" }, 3,]"#,
+        );
+
+        check_array_replace(
+            r#"[1, { "foo": "bar", "baz": "qux" }, 3,]"#,
+            1,
+            &["baz"],
+            json!({"qux": "quz"}),
+            r#"[1, { "foo": "bar", "baz": { "qux": "quz" } }, 3,]"#,
+        );
+
+        check_array_replace(
+            r#"[
+                1,
+                {
+                    "foo": "bar",
+                    "baz": "qux"
+                },
+                3
+            ]"#,
+            1,
+            &["baz"],
+            json!({"qux": "quz"}),
+            r#"[
+                1,
+                {
+                    "foo": "bar",
+                    "baz": {
+                        "qux": "quz"
+                    }
+                },
+                3
+            ]"#,
+        );
+
+        check_array_replace(
+            r#"[
+                1,
+                {
+                    "foo": "bar",
+                    "baz": {
+                        "qux": "quz"
+                    }
+                },
+                3
+            ]"#,
+            1,
+            &["baz"],
+            json!("qux"),
+            r#"[
+                1,
+                {
+                    "foo": "bar",
+                    "baz": "qux"
+                },
+                3
+            ]"#,
+        );
+
+        check_array_replace(
+            r#"[
+                1,
+                {
+                    "foo": "bar",
+                    // some comment to keep
+                    "baz": {
+                        // some comment to remove
+                        "qux": "quz"
+                    }
+                    // some other comment to keep
+                },
+                3
+            ]"#,
+            1,
+            &["baz"],
+            json!("qux"),
+            r#"[
+                1,
+                {
+                    "foo": "bar",
+                    // some comment to keep
+                    "baz": "qux"
+                    // some other comment to keep
+                },
+                3
+            ]"#,
+        );
+
+        // Test with comments between array elements
+        check_array_replace(
+            r#"[
+                1,
+                // This is element 2
+                2,
+                /* Block comment */ 3,
+                4 // Trailing comment
+            ]"#,
+            2,
+            &[],
+            json!("replaced"),
+            r#"[
+                1,
+                // This is element 2
+                2,
+                /* Block comment */ "replaced",
+                4 // Trailing comment
+            ]"#,
+        );
+
+        // Test empty array with comments
+        check_array_replace(
+            r#"[
+                // Empty array with comment
+            ]"#
+            .unindent(),
+            0,
+            &[],
+            json!("first"),
+            r#"[
+                // Empty array with comment
+                "first"
+            ]"#
+            .unindent(),
+        );
+        check_array_replace(
+            r#"[]"#.unindent(),
+            0,
+            &[],
+            json!("first"),
+            r#"[
+                "first"
+            ]"#
+            .unindent(),
+        );
+
+        // Test array with leading comments
+        check_array_replace(
+            r#"[
+                // Leading comment
+                // Another leading comment
+                1,
+                2
+            ]"#,
+            0,
+            &[],
+            json!({"new": "object"}),
+            r#"[
+                // Leading comment
+                // Another leading comment
+                {
+                    "new": "object"
+                },
+                2
+            ]"#,
+        );
+
+        // Test with deep indentation
+        check_array_replace(
+            r#"[
+                        1,
+                        2,
+                        3
+                    ]"#,
+            1,
+            &[],
+            json!("deep"),
+            r#"[
+                        1,
+                        "deep",
+                        3
+                    ]"#,
+        );
+
+        // Test with mixed spacing
+        check_array_replace(
+            r#"[1,2,   3,    4]"#,
+            2,
+            &[],
+            json!("spaced"),
+            r#"[1,2,   "spaced",    4]"#,
+        );
+
+        // Test replacing nested array element
+        check_array_replace(
+            r#"[
+                [1, 2, 3],
+                [4, 5, 6],
+                [7, 8, 9]
+            ]"#,
+            1,
+            &[],
+            json!(["a", "b", "c", "d"]),
+            r#"[
+                [1, 2, 3],
+                [
+                    "a",
+                    "b",
+                    "c",
+                    "d"
+                ],
+                [7, 8, 9]
+            ]"#,
+        );
+
+        // Test with multiline block comments
+        check_array_replace(
+            r#"[
+                /*
+                 * This is a
+                 * multiline comment
+                 */
+                "first",
+                "second"
+            ]"#,
+            0,
+            &[],
+            json!("updated"),
+            r#"[
+                /*
+                 * This is a
+                 * multiline comment
+                 */
+                "updated",
+                "second"
+            ]"#,
+        );
+
+        // Test replacing with null
+        check_array_replace(
+            r#"[true, false, true]"#,
+            1,
+            &[],
+            json!(null),
+            r#"[true, null, true]"#,
+        );
+
+        // Test single element array
+        check_array_replace(
+            r#"[42]"#,
+            0,
+            &[],
+            json!({"answer": 42}),
+            r#"[{ "answer": 42 }]"#,
+        );
+
+        // Test array with only comments
+        check_array_replace(
+            r#"[
+                // Comment 1
+                // Comment 2
+                // Comment 3
+            ]"#
+            .unindent(),
+            10,
+            &[],
+            json!(123),
+            r#"[
+                // Comment 1
+                // Comment 2
+                // Comment 3
+                123
+            ]"#
+            .unindent(),
+        );
+    }
+
+    #[test]
+    fn array_append() {
+        #[track_caller]
+        fn check_array_append(input: impl ToString, value: Value, expected: impl ToString) {
+            let input = input.to_string();
+            let result = append_top_level_array_value_in_json_text(&input, &value, 4)
+                .expect("append succeeded");
+            let mut result_str = input;
+            result_str.replace_range(result.0, &result.1);
+            pretty_assertions::assert_eq!(expected.to_string(), result_str);
+        }
+        check_array_append(r#"[1, 3, 3]"#, json!(4), r#"[1, 3, 3, 4]"#);
+        check_array_append(r#"[1, 3, 3,]"#, json!(4), r#"[1, 3, 3, 4]"#);
+        check_array_append(r#"[1, 3, 3   ]"#, json!(4), r#"[1, 3, 3, 4]"#);
+        check_array_append(r#"[1, 3, 3,   ]"#, json!(4), r#"[1, 3, 3, 4]"#);
+        check_array_append(
+            r#"[
+                1,
+                2,
+                3
+            ]"#
+            .unindent(),
+            json!(4),
+            r#"[
+                1,
+                2,
+                3,
+                4
+            ]"#
+            .unindent(),
+        );
+        check_array_append(
+            r#"[
+                1,
+                2,
+                3,
+            ]"#
+            .unindent(),
+            json!(4),
+            r#"[
+                1,
+                2,
+                3,
+                4
+            ]"#
+            .unindent(),
+        );
+        check_array_append(
+            r#"[
+                1,
+                2,
+                3,
+            ]"#
+            .unindent(),
+            json!({"foo": "bar", "baz": "qux"}),
+            r#"[
+                1,
+                2,
+                3,
+                {
+                    "foo": "bar",
+                    "baz": "qux"
+                }
+            ]"#
+            .unindent(),
+        );
+        check_array_append(
+            r#"[ 1, 2, 3, ]"#.unindent(),
+            json!({"foo": "bar", "baz": "qux"}),
+            r#"[ 1, 2, 3, { "foo": "bar", "baz": "qux" }]"#.unindent(),
+        );
+        check_array_append(
+            r#"[]"#,
+            json!({"foo": "bar"}),
+            r#"[
+                {
+                    "foo": "bar"
+                }
+            ]"#
+            .unindent(),
+        );
+
+        // Test with comments between array elements
+        check_array_append(
+            r#"[
+                1,
+                // Comment between elements
+                2,
+                /* Block comment */ 3
+            ]"#
+            .unindent(),
+            json!(4),
+            r#"[
+                1,
+                // Comment between elements
+                2,
+                /* Block comment */ 3,
+                4
+            ]"#
+            .unindent(),
+        );
+
+        // Test with trailing comment on last element
+        check_array_append(
+            r#"[
+                1,
+                2,
+                3 // Trailing comment
+            ]"#
+            .unindent(),
+            json!("new"),
+            r#"[
+                1,
+                2,
+                3 // Trailing comment
+            ,
+                "new"
+            ]"#
+            .unindent(),
+        );
+
+        // Test empty array with comments
+        check_array_append(
+            r#"[
+                // Empty array with comment
+            ]"#
+            .unindent(),
+            json!("first"),
+            r#"[
+                // Empty array with comment
+                "first"
+            ]"#
+            .unindent(),
+        );
+
+        // Test with multiline block comment at end
+        check_array_append(
+            r#"[
+                1,
+                2
+                /*
+                 * This is a
+                 * multiline comment
+                 */
+            ]"#
+            .unindent(),
+            json!(3),
+            r#"[
+                1,
+                2
+                /*
+                 * This is a
+                 * multiline comment
+                 */
+            ,
+                3
+            ]"#
+            .unindent(),
+        );
+
+        // Test with deep indentation
+        check_array_append(
+            r#"[
+                1,
+                    2,
+                        3
+            ]"#
+            .unindent(),
+            json!("deep"),
+            r#"[
+                1,
+                    2,
+                        3,
+                        "deep"
+            ]"#
+            .unindent(),
+        );
+
+        // Test with no spacing
+        check_array_append(r#"[1,2,3]"#, json!(4), r#"[1,2,3, 4]"#);
+
+        // Test appending complex nested structure
+        check_array_append(
+            r#"[
+                {"a": 1},
+                {"b": 2}
+            ]"#
+            .unindent(),
+            json!({"c": {"nested": [1, 2, 3]}}),
+            r#"[
+                {"a": 1},
+                {"b": 2},
+                {
+                    "c": {
+                        "nested": [
+                            1,
+                            2,
+                            3
+                        ]
+                    }
+                }
+            ]"#
+            .unindent(),
+        );
+
+        // Test array ending with comment after bracket
+        check_array_append(
+            r#"[
+                1,
+                2,
+                3
+            ] // Comment after array"#
+                .unindent(),
+            json!(4),
+            r#"[
+                1,
+                2,
+                3,
+                4
+            ] // Comment after array"#
+                .unindent(),
+        );
+
+        // Test with inconsistent element formatting
+        check_array_append(
+            r#"[1,
+               2,
+                    3,
+            ]"#
+            .unindent(),
+            json!(4),
+            r#"[1,
+               2,
+                    3,
+                    4
+            ]"#
+            .unindent(),
+        );
+
+        // Test appending to single-line array with trailing comma
+        check_array_append(
+            r#"[1, 2, 3,]"#,
+            json!({"key": "value"}),
+            r#"[1, 2, 3, { "key": "value" }]"#,
+        );
+
+        // Test appending null value
+        check_array_append(r#"[true, false]"#, json!(null), r#"[true, false, null]"#);
+
+        // Test appending to array with only comments
+        check_array_append(
+            r#"[
+                // Just comments here
+                // More comments
+            ]"#
+            .unindent(),
+            json!(42),
+            r#"[
+                // Just comments here
+                // More comments
+                42
+            ]"#
+            .unindent(),
+        );
+    }
+}

crates/settings/src/settings_store.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use collections::{BTreeMap, HashMap, btree_map, hash_map};
 use ec4rs::{ConfigParser, PropertiesSource, Section};
 use fs::Fs;
@@ -16,17 +16,17 @@ use std::{
     ops::Range,
     path::{Path, PathBuf},
     str::{self, FromStr},
-    sync::{Arc, LazyLock},
+    sync::Arc,
 };
-use streaming_iterator::StreamingIterator;
-use tree_sitter::Query;
-use util::RangeExt;
 
 use util::{ResultExt as _, merge_non_null_json_value_into};
 
 pub type EditorconfigProperties = ec4rs::Properties;
 
-use crate::{SettingsJsonSchemaParams, VsCodeSettings, WorktreeId};
+use crate::{
+    SettingsJsonSchemaParams, VsCodeSettings, WorktreeId, parse_json_with_comments,
+    update_value_in_json_text,
+};
 
 /// A value that can be defined as a user setting.
 ///
@@ -120,6 +120,8 @@ pub trait Settings: 'static + Send + Sync {
 pub struct SettingsSources<'a, T> {
     /// The default Zed settings.
     pub default: &'a T,
+    /// Global settings (loaded before user settings).
+    pub global: Option<&'a T>,
     /// Settings provided by extensions.
     pub extensions: Option<&'a T>,
     /// The user settings.
@@ -140,8 +142,9 @@ impl<'a, T: Serialize> SettingsSources<'a, T> {
 
     /// Returns an iterator over all of the settings customizations.
     pub fn customizations(&self) -> impl Iterator<Item = &T> {
-        self.extensions
+        self.global
             .into_iter()
+            .chain(self.extensions)
             .chain(self.user)
             .chain(self.release_channel)
             .chain(self.server)
@@ -180,6 +183,7 @@ pub struct SettingsLocation<'a> {
 pub struct SettingsStore {
     setting_values: HashMap<TypeId, Box<dyn AnySettingValue>>,
     raw_default_settings: Value,
+    raw_global_settings: Option<Value>,
     raw_user_settings: Value,
     raw_server_settings: Option<Value>,
     raw_extension_settings: Value,
@@ -246,6 +250,7 @@ trait AnySettingValue: 'static + Send + Sync {
         cx: &mut App,
     ) -> Result<Box<dyn Any>>;
     fn value_for_path(&self, path: Option<SettingsLocation>) -> &dyn Any;
+    fn all_local_values(&self) -> Vec<(WorktreeId, Arc<Path>, &dyn Any)>;
     fn set_global_value(&mut self, value: Box<dyn Any>);
     fn set_local_value(&mut self, root_id: WorktreeId, path: Arc<Path>, value: Box<dyn Any>);
     fn json_schema(
@@ -272,6 +277,7 @@ impl SettingsStore {
         Self {
             setting_values: Default::default(),
             raw_default_settings: serde_json::json!({}),
+            raw_global_settings: None,
             raw_user_settings: serde_json::json!({}),
             raw_server_settings: None,
             raw_extension_settings: serde_json::json!({}),
@@ -341,6 +347,7 @@ impl SettingsStore {
                 .load_setting(
                     SettingsSources {
                         default: &default_settings,
+                        global: None,
                         extensions: extension_value.as_ref(),
                         user: user_value.as_ref(),
                         release_channel: release_channel_value.as_ref(),
@@ -370,6 +377,24 @@ impl SettingsStore {
             .expect("no default value for setting type")
     }
 
+    /// Get all values from project specific settings
+    pub fn get_all_locals<T: Settings>(&self) -> Vec<(WorktreeId, Arc<Path>, &T)> {
+        self.setting_values
+            .get(&TypeId::of::<T>())
+            .unwrap_or_else(|| panic!("unregistered setting type {}", type_name::<T>()))
+            .all_local_values()
+            .into_iter()
+            .map(|(id, path, any)| {
+                (
+                    id,
+                    path,
+                    any.downcast_ref::<T>()
+                        .expect("wrong value type for setting"),
+                )
+            })
+            .collect()
+    }
+
     /// Override the global value for a setting.
     ///
     /// The given value will be overwritten if the user settings file changes.
@@ -388,6 +413,11 @@ impl SettingsStore {
         &self.raw_user_settings
     }
 
+    /// Access the raw JSON value of the global settings.
+    pub fn raw_global_settings(&self) -> Option<&Value> {
+        self.raw_global_settings.as_ref()
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn test(cx: &mut App) -> Self {
         let mut this = Self::new(cx);
@@ -426,6 +456,20 @@ impl SettingsStore {
         }
     }
 
+    pub async fn load_global_settings(fs: &Arc<dyn Fs>) -> Result<String> {
+        match fs.load(paths::global_settings_file()).await {
+            result @ Ok(_) => result,
+            Err(err) => {
+                if let Some(e) = err.downcast_ref::<std::io::Error>() {
+                    if e.kind() == std::io::ErrorKind::NotFound {
+                        return Ok("{}".to_string());
+                    }
+                }
+                Err(err)
+            }
+        }
+    }
+
     pub fn update_settings_file<T: Settings>(
         &self,
         fs: Arc<dyn Fs>,
@@ -610,13 +654,10 @@ impl SettingsStore {
         cx: &mut App,
     ) -> Result<()> {
         let settings: Value = parse_json_with_comments(default_settings_content)?;
-        if settings.is_object() {
-            self.raw_default_settings = settings;
-            self.recompute_values(None, cx)?;
-            Ok(())
-        } else {
-            Err(anyhow!("settings must be an object"))
-        }
+        anyhow::ensure!(settings.is_object(), "settings must be an object");
+        self.raw_default_settings = settings;
+        self.recompute_values(None, cx)?;
+        Ok(())
     }
 
     /// Sets the user settings via a JSON string.
@@ -637,6 +678,24 @@ impl SettingsStore {
         Ok(settings)
     }
 
+    /// Sets the global settings via a JSON string.
+    pub fn set_global_settings(
+        &mut self,
+        global_settings_content: &str,
+        cx: &mut App,
+    ) -> Result<Value> {
+        let settings: Value = if global_settings_content.is_empty() {
+            parse_json_with_comments("{}")?
+        } else {
+            parse_json_with_comments(global_settings_content)?
+        };
+
+        anyhow::ensure!(settings.is_object(), "settings must be an object");
+        self.raw_global_settings = Some(settings.clone());
+        self.recompute_values(None, cx)?;
+        Ok(settings)
+    }
+
     pub fn set_server_settings(
         &mut self,
         server_settings_content: &str,
@@ -935,6 +994,11 @@ impl SettingsStore {
                     message: e.to_string(),
                 })?;
 
+            let global_settings = self
+                .raw_global_settings
+                .as_ref()
+                .and_then(|setting| setting_value.deserialize_setting(setting).log_err());
+
             let extension_settings = setting_value
                 .deserialize_setting(&self.raw_extension_settings)
                 .log_err();
@@ -972,6 +1036,7 @@ impl SettingsStore {
                     .load_setting(
                         SettingsSources {
                             default: &default_settings,
+                            global: global_settings.as_ref(),
                             extensions: extension_settings.as_ref(),
                             user: user_settings.as_ref(),
                             release_channel: release_channel_settings.as_ref(),
@@ -1023,6 +1088,7 @@ impl SettingsStore {
                             .load_setting(
                                 SettingsSources {
                                     default: &default_settings,
+                                    global: global_settings.as_ref(),
                                     extensions: extension_settings.as_ref(),
                                     user: user_settings.as_ref(),
                                     release_channel: release_channel_settings.as_ref(),
@@ -1139,6 +1205,9 @@ impl<T: Settings> AnySettingValue for SettingValue<T> {
         Ok(Box::new(T::load(
             SettingsSources {
                 default: values.default.0.downcast_ref::<T::FileContent>().unwrap(),
+                global: values
+                    .global
+                    .map(|value| value.0.downcast_ref::<T::FileContent>().unwrap()),
                 extensions: values
                     .extensions
                     .map(|value| value.0.downcast_ref::<T::FileContent>().unwrap()),
@@ -1185,6 +1254,13 @@ impl<T: Settings> AnySettingValue for SettingValue<T> {
         (key, value)
     }
 
+    fn all_local_values(&self) -> Vec<(WorktreeId, Arc<Path>, &dyn Any)> {
+        self.local_values
+            .iter()
+            .map(|(id, path, value)| (*id, path.clone(), value as _))
+            .collect()
+    }
+
     fn value_for_path(&self, path: Option<SettingsLocation>) -> &dyn Any {
         if let Some(SettingsLocation { worktree_id, path }) = path {
             for (settings_root_id, settings_path, value) in self.local_values.iter().rev() {
@@ -1258,220 +1334,10 @@ impl<T: Settings> AnySettingValue for SettingValue<T> {
     }
 }
 
-fn update_value_in_json_text<'a>(
-    text: &mut String,
-    key_path: &mut Vec<&'a str>,
-    tab_size: usize,
-    old_value: &'a Value,
-    new_value: &'a Value,
-    preserved_keys: &[&str],
-    edits: &mut Vec<(Range<usize>, String)>,
-) {
-    // If the old and new values are both objects, then compare them key by key,
-    // preserving the comments and formatting of the unchanged parts. Otherwise,
-    // replace the old value with the new value.
-    if let (Value::Object(old_object), Value::Object(new_object)) = (old_value, new_value) {
-        for (key, old_sub_value) in old_object.iter() {
-            key_path.push(key);
-            let new_sub_value = new_object.get(key).unwrap_or(&Value::Null);
-            update_value_in_json_text(
-                text,
-                key_path,
-                tab_size,
-                old_sub_value,
-                new_sub_value,
-                preserved_keys,
-                edits,
-            );
-            key_path.pop();
-        }
-        for (key, new_sub_value) in new_object.iter() {
-            key_path.push(key);
-            if !old_object.contains_key(key) {
-                update_value_in_json_text(
-                    text,
-                    key_path,
-                    tab_size,
-                    &Value::Null,
-                    new_sub_value,
-                    preserved_keys,
-                    edits,
-                );
-            }
-            key_path.pop();
-        }
-    } else if key_path
-        .last()
-        .map_or(false, |key| preserved_keys.contains(key))
-        || old_value != new_value
-    {
-        let mut new_value = new_value.clone();
-        if let Some(new_object) = new_value.as_object_mut() {
-            new_object.retain(|_, v| !v.is_null());
-        }
-        let (range, replacement) = replace_value_in_json_text(text, key_path, tab_size, &new_value);
-        text.replace_range(range.clone(), &replacement);
-        edits.push((range, replacement));
-    }
-}
-
-fn replace_value_in_json_text(
-    text: &str,
-    key_path: &[&str],
-    tab_size: usize,
-    new_value: &Value,
-) -> (Range<usize>, String) {
-    static PAIR_QUERY: LazyLock<Query> = LazyLock::new(|| {
-        Query::new(
-            &tree_sitter_json::LANGUAGE.into(),
-            "(pair key: (string) @key value: (_) @value)",
-        )
-        .expect("Failed to create PAIR_QUERY")
-    });
-
-    let mut parser = tree_sitter::Parser::new();
-    parser
-        .set_language(&tree_sitter_json::LANGUAGE.into())
-        .unwrap();
-    let syntax_tree = parser.parse(text, None).unwrap();
-
-    let mut cursor = tree_sitter::QueryCursor::new();
-
-    let mut depth = 0;
-    let mut last_value_range = 0..0;
-    let mut first_key_start = None;
-    let mut existing_value_range = 0..text.len();
-    let mut matches = cursor.matches(&PAIR_QUERY, syntax_tree.root_node(), text.as_bytes());
-    while let Some(mat) = matches.next() {
-        if mat.captures.len() != 2 {
-            continue;
-        }
-
-        let key_range = mat.captures[0].node.byte_range();
-        let value_range = mat.captures[1].node.byte_range();
-
-        // Don't enter sub objects until we find an exact
-        // match for the current keypath
-        if last_value_range.contains_inclusive(&value_range) {
-            continue;
-        }
-
-        last_value_range = value_range.clone();
-
-        if key_range.start > existing_value_range.end {
-            break;
-        }
-
-        first_key_start.get_or_insert(key_range.start);
-
-        let found_key = text
-            .get(key_range.clone())
-            .map(|key_text| {
-                depth < key_path.len() && key_text == format!("\"{}\"", key_path[depth])
-            })
-            .unwrap_or(false);
-
-        if found_key {
-            existing_value_range = value_range;
-            // Reset last value range when increasing in depth
-            last_value_range = existing_value_range.start..existing_value_range.start;
-            depth += 1;
-
-            if depth == key_path.len() {
-                break;
-            }
-
-            first_key_start = None;
-        }
-    }
-
-    // We found the exact key we want, insert the new value
-    if depth == key_path.len() {
-        let new_val = to_pretty_json(&new_value, tab_size, tab_size * depth);
-        (existing_value_range, new_val)
-    } else {
-        // We have key paths, construct the sub objects
-        let new_key = key_path[depth];
-
-        // We don't have the key, construct the nested objects
-        let mut new_value = serde_json::to_value(new_value).unwrap();
-        for key in key_path[(depth + 1)..].iter().rev() {
-            new_value = serde_json::json!({ key.to_string(): new_value });
-        }
-
-        if let Some(first_key_start) = first_key_start {
-            let mut row = 0;
-            let mut column = 0;
-            for (ix, char) in text.char_indices() {
-                if ix == first_key_start {
-                    break;
-                }
-                if char == '\n' {
-                    row += 1;
-                    column = 0;
-                } else {
-                    column += char.len_utf8();
-                }
-            }
-
-            if row > 0 {
-                // depth is 0 based, but division needs to be 1 based.
-                let new_val = to_pretty_json(&new_value, column / (depth + 1), column);
-                let space = ' ';
-                let content = format!("\"{new_key}\": {new_val},\n{space:width$}", width = column);
-                (first_key_start..first_key_start, content)
-            } else {
-                let new_val = serde_json::to_string(&new_value).unwrap();
-                let mut content = format!(r#""{new_key}": {new_val},"#);
-                content.push(' ');
-                (first_key_start..first_key_start, content)
-            }
-        } else {
-            new_value = serde_json::json!({ new_key.to_string(): new_value });
-            let indent_prefix_len = 4 * depth;
-            let mut new_val = to_pretty_json(&new_value, 4, indent_prefix_len);
-            if depth == 0 {
-                new_val.push('\n');
-            }
-
-            (existing_value_range, new_val)
-        }
-    }
-}
-
-fn to_pretty_json(value: &impl Serialize, indent_size: usize, indent_prefix_len: usize) -> String {
-    const SPACES: [u8; 32] = [b' '; 32];
-
-    debug_assert!(indent_size <= SPACES.len());
-    debug_assert!(indent_prefix_len <= SPACES.len());
-
-    let mut output = Vec::new();
-    let mut ser = serde_json::Serializer::with_formatter(
-        &mut output,
-        serde_json::ser::PrettyFormatter::with_indent(&SPACES[0..indent_size.min(SPACES.len())]),
-    );
-
-    value.serialize(&mut ser).unwrap();
-    let text = String::from_utf8(output).unwrap();
-
-    let mut adjusted_text = String::new();
-    for (i, line) in text.split('\n').enumerate() {
-        if i > 0 {
-            adjusted_text.push_str(str::from_utf8(&SPACES[0..indent_prefix_len]).unwrap());
-        }
-        adjusted_text.push_str(line);
-        adjusted_text.push('\n');
-    }
-    adjusted_text.pop();
-    adjusted_text
-}
-
-pub fn parse_json_with_comments<T: DeserializeOwned>(content: &str) -> Result<T> {
-    Ok(serde_json_lenient::from_str(content)?)
-}
-
 #[cfg(test)]
 mod tests {
+    use crate::VsCodeSettingsSource;
+
     use super::*;
     use serde_derive::Deserialize;
     use unindent::Unindent;
@@ -1651,6 +1517,22 @@ mod tests {
         );
     }
 
+    fn check_settings_update<T: Settings>(
+        store: &mut SettingsStore,
+        old_json: String,
+        update: fn(&mut T::FileContent),
+        expected_new_json: String,
+        cx: &mut App,
+    ) {
+        store.set_user_settings(&old_json, cx).ok();
+        let edits = store.edits_for_update::<T>(&old_json, update);
+        let mut new_json = old_json;
+        for (range, replacement) in edits.into_iter() {
+            new_json.replace_range(range, &replacement);
+        }
+        pretty_assertions::assert_eq!(new_json, expected_new_json);
+    }
+
     #[gpui::test]
     fn test_setting_store_update(cx: &mut App) {
         let mut store = SettingsStore::new(cx);
@@ -1697,17 +1579,72 @@ mod tests {
             cx,
         );
 
+        // entries removed
+        check_settings_update::<LanguageSettings>(
+            &mut store,
+            r#"{
+                "languages": {
+                    "Rust": {
+                        "language_setting_2": true
+                    },
+                    "JSON": {
+                        "language_setting_1": false
+                    }
+                }
+            }"#
+            .unindent(),
+            |settings| {
+                settings.languages.remove("JSON").unwrap();
+            },
+            r#"{
+                "languages": {
+                    "Rust": {
+                        "language_setting_2": true
+                    }
+                }
+            }"#
+            .unindent(),
+            cx,
+        );
+
+        check_settings_update::<LanguageSettings>(
+            &mut store,
+            r#"{
+                "languages": {
+                    "Rust": {
+                        "language_setting_2": true
+                    },
+                    "JSON": {
+                        "language_setting_1": false
+                    }
+                }
+            }"#
+            .unindent(),
+            |settings| {
+                settings.languages.remove("Rust").unwrap();
+            },
+            r#"{
+                "languages": {
+                    "JSON": {
+                        "language_setting_1": false
+                    }
+                }
+            }"#
+            .unindent(),
+            cx,
+        );
+
         // weird formatting
         check_settings_update::<UserSettings>(
             &mut store,
             r#"{
                 "user":   { "age": 36, "name": "Max", "staff": true }
-            }"#
+                }"#
             .unindent(),
             |settings| settings.age = Some(37),
             r#"{
                 "user":   { "age": 37, "name": "Max", "staff": true }
-            }"#
+                }"#
             .unindent(),
             cx,
         );
@@ -1930,22 +1867,6 @@ mod tests {
         );
     }
 
-    fn check_settings_update<T: Settings>(
-        store: &mut SettingsStore,
-        old_json: String,
-        update: fn(&mut T::FileContent),
-        expected_new_json: String,
-        cx: &mut App,
-    ) {
-        store.set_user_settings(&old_json, cx).ok();
-        let edits = store.edits_for_update::<T>(&old_json, update);
-        let mut new_json = old_json;
-        for (range, replacement) in edits.into_iter() {
-            new_json.replace_range(range, &replacement);
-        }
-        pretty_assertions::assert_eq!(new_json, expected_new_json);
-    }
-
     fn check_vscode_import(
         store: &mut SettingsStore,
         old: String,
@@ -1954,7 +1875,10 @@ mod tests {
         cx: &mut App,
     ) {
         store.set_user_settings(&old, cx).ok();
-        let new = store.get_vscode_edits(old, &VsCodeSettings::from_str(&vscode).unwrap());
+        let new = store.get_vscode_edits(
+            old,
+            &VsCodeSettings::from_str(&vscode, VsCodeSettingsSource::VsCode).unwrap(),
+        );
         pretty_assertions::assert_eq!(new, expected);
     }
 
@@ -1966,7 +1890,8 @@ mod tests {
     }
 
     #[derive(Default, Clone, Serialize, Deserialize, JsonSchema)]
-    struct UserSettingsJson {
+    #[schemars(deny_unknown_fields)]
+    struct UserSettingsContent {
         name: Option<String>,
         age: Option<u32>,
         staff: Option<bool>,
@@ -1974,7 +1899,7 @@ mod tests {
 
     impl Settings for UserSettings {
         const KEY: Option<&'static str> = Some("user");
-        type FileContent = UserSettingsJson;
+        type FileContent = UserSettingsContent;
 
         fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
             sources.json_merge()
@@ -2008,6 +1933,7 @@ mod tests {
     }
 
     #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
+    #[schemars(deny_unknown_fields)]
     struct MultiKeySettingsJson {
         key1: Option<String>,
         key2: Option<String>,
@@ -2046,6 +1972,7 @@ mod tests {
     }
 
     #[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema)]
+    #[schemars(deny_unknown_fields)]
     struct JournalSettingsJson {
         pub path: Option<String>,
         pub hour_format: Option<HourFormat>,
@@ -2069,6 +1996,70 @@ mod tests {
         }
     }
 
+    #[gpui::test]
+    fn test_global_settings(cx: &mut App) {
+        let mut store = SettingsStore::new(cx);
+        store.register_setting::<UserSettings>(cx);
+        store
+            .set_default_settings(
+                r#"{
+                    "user": {
+                        "name": "John Doe",
+                        "age": 30,
+                        "staff": false
+                    }
+                }"#,
+                cx,
+            )
+            .unwrap();
+
+        // Set global settings - these should override defaults but not user settings
+        store
+            .set_global_settings(
+                r#"{
+                    "user": {
+                        "name": "Global User",
+                        "age": 35,
+                        "staff": true
+                    }
+                }"#,
+                cx,
+            )
+            .unwrap();
+
+        // Before user settings, global settings should apply
+        assert_eq!(
+            store.get::<UserSettings>(None),
+            &UserSettings {
+                name: "Global User".to_string(),
+                age: 35,
+                staff: true,
+            }
+        );
+
+        // Set user settings - these should override both defaults and global
+        store
+            .set_user_settings(
+                r#"{
+                    "user": {
+                        "age": 40
+                    }
+                }"#,
+                cx,
+            )
+            .unwrap();
+
+        // User settings should override global settings
+        assert_eq!(
+            store.get::<UserSettings>(None),
+            &UserSettings {
+                name: "Global User".to_string(), // Name from global settings
+                age: 40,                         // Age from user settings
+                staff: true,                     // Staff from global settings
+            }
+        );
+    }
+
     #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
     struct LanguageSettings {
         #[serde(default)]
@@ -2076,6 +2067,7 @@ mod tests {
     }
 
     #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+    #[schemars(deny_unknown_fields)]
     struct LanguageSettingEntry {
         language_setting_1: Option<bool>,
         language_setting_2: Option<bool>,

crates/settings/src/vscode_import.rs 🔗

@@ -1,24 +1,79 @@
-use anyhow::Result;
+use anyhow::{Context as _, Result, anyhow};
 use fs::Fs;
+use paths::{cursor_settings_file_paths, vscode_settings_file_paths};
 use serde_json::{Map, Value};
+use std::{path::Path, rc::Rc, sync::Arc};
 
-use std::sync::Arc;
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum VsCodeSettingsSource {
+    VsCode,
+    Cursor,
+}
+
+impl std::fmt::Display for VsCodeSettingsSource {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            VsCodeSettingsSource::VsCode => write!(f, "VS Code"),
+            VsCodeSettingsSource::Cursor => write!(f, "Cursor"),
+        }
+    }
+}
 
 pub struct VsCodeSettings {
+    pub source: VsCodeSettingsSource,
+    pub path: Rc<Path>,
     content: Map<String, Value>,
 }
 
 impl VsCodeSettings {
-    pub fn from_str(content: &str) -> Result<Self> {
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn from_str(content: &str, source: VsCodeSettingsSource) -> Result<Self> {
         Ok(Self {
+            source,
+            path: Path::new("/example-path/Code/User/settings.json").into(),
             content: serde_json_lenient::from_str(content)?,
         })
     }
 
-    pub async fn load_user_settings(fs: Arc<dyn Fs>) -> Result<Self> {
-        let content = fs.load(paths::vscode_settings_file()).await?;
+    pub async fn load_user_settings(source: VsCodeSettingsSource, fs: Arc<dyn Fs>) -> Result<Self> {
+        let candidate_paths = match source {
+            VsCodeSettingsSource::VsCode => vscode_settings_file_paths(),
+            VsCodeSettingsSource::Cursor => cursor_settings_file_paths(),
+        };
+        let mut path = None;
+        for candidate_path in candidate_paths.iter() {
+            if fs.is_file(candidate_path).await {
+                path = Some(candidate_path.clone());
+            }
+        }
+        let Some(path) = path else {
+            return Err(anyhow!(
+                "No settings file found, expected to find it in one of the following paths:\n{}",
+                candidate_paths
+                    .into_iter()
+                    .map(|path| path.to_string_lossy().to_string())
+                    .collect::<Vec<_>>()
+                    .join("\n")
+            ));
+        };
+        let content = fs.load(&path).await.with_context(|| {
+            format!(
+                "Error loading {} settings file from {}",
+                source,
+                path.display()
+            )
+        })?;
+        let content = serde_json_lenient::from_str(&content).with_context(|| {
+            format!(
+                "Error parsing {} settings file from {}",
+                source,
+                path.display()
+            )
+        })?;
         Ok(Self {
-            content: serde_json_lenient::from_str(&content)?,
+            source,
+            path: path.into(),
+            content,
         })
     }
 

crates/settings_ui/Cargo.toml 🔗

@@ -18,11 +18,11 @@ feature_flags.workspace = true
 fs.workspace = true
 gpui.workspace = true
 log.workspace = true
-paths.workspace = true
+schemars.workspace = true
+serde.workspace = true
 settings.workspace = true
 theme.workspace = true
 ui.workspace = true
-workspace.workspace = true
+util.workspace = true
 workspace-hack.workspace = true
-serde.workspace = true
-schemars.workspace = true
+workspace.workspace = true

crates/settings_ui/src/settings_ui.rs 🔗

@@ -1,21 +1,22 @@
 mod appearance_settings_controls;
 
 use std::any::TypeId;
+use std::sync::Arc;
 
 use command_palette_hooks::CommandPaletteFilter;
 use editor::EditorSettingsControls;
 use feature_flags::{FeatureFlag, FeatureFlagViewExt};
 use fs::Fs;
 use gpui::{
-    App, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, Task, actions,
-    impl_actions,
+    Action, App, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, Task, actions,
 };
 use schemars::JsonSchema;
 use serde::Deserialize;
-use settings::SettingsStore;
+use settings::{SettingsStore, VsCodeSettingsSource};
 use ui::prelude::*;
-use workspace::Workspace;
+use util::truncate_and_remove_front;
 use workspace::item::{Item, ItemEvent};
+use workspace::{Workspace, with_active_or_new_workspace};
 
 use crate::appearance_settings_controls::AppearanceSettingsControls;
 
@@ -25,22 +26,24 @@ impl FeatureFlag for SettingsUiFeatureFlag {
     const NAME: &'static str = "settings-ui";
 }
 
-#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)]
+#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
+#[action(namespace = zed)]
 pub struct ImportVsCodeSettings {
     #[serde(default)]
     pub skip_prompt: bool,
 }
 
-impl_actions!(zed, [ImportVsCodeSettings]);
+#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
+#[action(namespace = zed)]
+pub struct ImportCursorSettings {
+    #[serde(default)]
+    pub skip_prompt: bool,
+}
 actions!(zed, [OpenSettingsEditor]);
 
 pub fn init(cx: &mut App) {
-    cx.observe_new(|workspace: &mut Workspace, window, cx| {
-        let Some(window) = window else {
-            return;
-        };
-
-        workspace.register_action(|workspace, _: &OpenSettingsEditor, window, cx| {
+    cx.on_action(|_: &OpenSettingsEditor, cx| {
+        with_active_or_new_workspace(cx, move |workspace, window, cx| {
             let existing = workspace
                 .active_pane()
                 .read(cx)
@@ -54,6 +57,12 @@ pub fn init(cx: &mut App) {
                 workspace.add_item_to_active_pane(Box::new(settings_page), None, true, window, cx)
             }
         });
+    });
+
+    cx.observe_new(|workspace: &mut Workspace, window, cx| {
+        let Some(window) = window else {
+            return;
+        };
 
         workspace.register_action(|_workspace, action: &ImportVsCodeSettings, window, cx| {
             let fs = <dyn Fs>::global(cx);
@@ -61,49 +70,30 @@ pub fn init(cx: &mut App) {
 
             window
                 .spawn(cx, async move |cx: &mut AsyncWindowContext| {
-                    let vscode =
-                        match settings::VsCodeSettings::load_user_settings(fs.clone()).await {
-                            Ok(vscode) => vscode,
-                            Err(err) => {
-                                println!(
-                                    "Failed to load VsCode settings: {}",
-                                    err.context(format!(
-                                        "Loading VsCode settings from path: {:?}",
-                                        paths::vscode_settings_file()
-                                    ))
-                                );
-
-                                let _ = cx.prompt(
-                                    gpui::PromptLevel::Info,
-                                    "Could not find or load a VsCode settings file",
-                                    None,
-                                    &["Ok"],
-                                );
-                                return;
-                            }
-                        };
-
-                    let prompt = if action.skip_prompt {
-                        Task::ready(Some(0))
-                    } else {
-                        let prompt = cx.prompt(
-                            gpui::PromptLevel::Warning,
-                            "Importing settings may overwrite your existing settings",
-                            None,
-                            &["Ok", "Cancel"],
-                        );
-                        cx.spawn(async move |_| prompt.await.ok())
-                    };
-                    if prompt.await != Some(0) {
-                        return;
-                    }
-
-                    cx.update(|_, cx| {
-                        cx.global::<SettingsStore>()
-                            .import_vscode_settings(fs, vscode);
-                        log::info!("Imported settings from VsCode");
-                    })
-                    .ok();
+                    handle_import_vscode_settings(
+                        VsCodeSettingsSource::VsCode,
+                        action.skip_prompt,
+                        fs,
+                        cx,
+                    )
+                    .await
+                })
+                .detach();
+        });
+
+        workspace.register_action(|_workspace, action: &ImportCursorSettings, window, cx| {
+            let fs = <dyn Fs>::global(cx);
+            let action = *action;
+
+            window
+                .spawn(cx, async move |cx: &mut AsyncWindowContext| {
+                    handle_import_vscode_settings(
+                        VsCodeSettingsSource::Cursor,
+                        action.skip_prompt,
+                        fs,
+                        cx,
+                    )
+                    .await
                 })
                 .detach();
         });
@@ -133,6 +123,57 @@ pub fn init(cx: &mut App) {
     .detach();
 }
 
+async fn handle_import_vscode_settings(
+    source: VsCodeSettingsSource,
+    skip_prompt: bool,
+    fs: Arc<dyn Fs>,
+    cx: &mut AsyncWindowContext,
+) {
+    let vscode_settings =
+        match settings::VsCodeSettings::load_user_settings(source, fs.clone()).await {
+            Ok(vscode_settings) => vscode_settings,
+            Err(err) => {
+                log::error!("{err}");
+                let _ = cx.prompt(
+                    gpui::PromptLevel::Info,
+                    &format!("Could not find or load a {source} settings file"),
+                    None,
+                    &["Ok"],
+                );
+                return;
+            }
+        };
+
+    let prompt = if skip_prompt {
+        Task::ready(Some(0))
+    } else {
+        let prompt = cx.prompt(
+            gpui::PromptLevel::Warning,
+            &format!(
+                "Importing {} settings may overwrite your existing settings. \
+                Will import settings from {}",
+                vscode_settings.source,
+                truncate_and_remove_front(&vscode_settings.path.to_string_lossy(), 128),
+            ),
+            None,
+            &["Ok", "Cancel"],
+        );
+        cx.spawn(async move |_| prompt.await.ok())
+    };
+    if prompt.await != Some(0) {
+        return;
+    }
+
+    cx.update(|_, cx| {
+        let source = vscode_settings.source;
+        let path = vscode_settings.path.clone();
+        cx.global::<SettingsStore>()
+            .import_vscode_settings(fs, vscode_settings);
+        log::info!("Imported {source} settings from {}", path.display());
+    })
+    .ok();
+}
+
 pub struct SettingsPage {
     focus_handle: FocusHandle,
 }

crates/snippet/src/snippet.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use smallvec::SmallVec;
 use std::{collections::BTreeMap, ops::Range};
 
@@ -114,7 +114,7 @@ fn parse_tabstop<'a>(
         if source.starts_with('}') {
             source = &source[1..];
         } else {
-            return Err(anyhow!("expected a closing brace"));
+            anyhow::bail!("expected a closing brace");
         }
     } else {
         let (index, rest) = parse_int(source)?;
@@ -137,9 +137,7 @@ fn parse_int(source: &str) -> Result<(usize, &str)> {
     let len = source
         .find(|c: char| !c.is_ascii_digit())
         .unwrap_or(source.len());
-    if len == 0 {
-        return Err(anyhow!("expected an integer"));
-    }
+    anyhow::ensure!(len > 0, "expected an integer");
     let (prefix, suffix) = source.split_at(len);
     Ok((prefix.parse()?, suffix))
 }
@@ -180,11 +178,10 @@ fn parse_choices<'a>(
             Some(_) => {
                 let chunk_end = source.find([',', '|', '\\']);
 
-                if chunk_end.is_none() {
-                    return Err(anyhow!(
-                        "Placeholder choice doesn't contain closing pipe-character '|'"
-                    ));
-                }
+                anyhow::ensure!(
+                    chunk_end.is_some(),
+                    "Placeholder choice doesn't contain closing pipe-character '|'"
+                );
 
                 let (chunk, rest) = source.split_at(chunk_end.unwrap());
 

crates/snippet_provider/src/lib.rs 🔗

@@ -34,10 +34,11 @@ fn file_stem_to_key(stem: &str) -> SnippetKind {
 
 fn file_to_snippets(file_contents: VsSnippetsFile) -> Vec<Arc<Snippet>> {
     let mut snippets = vec![];
-    for (prefix, snippet) in file_contents.snippets {
+    for (name, snippet) in file_contents.snippets {
+        let snippet_name = name.clone();
         let prefixes = snippet
             .prefix
-            .map_or_else(move || vec![prefix], |prefixes| prefixes.into());
+            .map_or_else(move || vec![snippet_name], |prefixes| prefixes.into());
         let description = snippet
             .description
             .map(|description| description.to_string());
@@ -49,6 +50,7 @@ fn file_to_snippets(file_contents: VsSnippetsFile) -> Vec<Arc<Snippet>> {
             body,
             prefix: prefixes,
             description,
+            name,
         }));
     }
     snippets
@@ -59,6 +61,7 @@ pub struct Snippet {
     pub prefix: Vec<String>,
     pub body: String,
     pub description: Option<String>,
+    pub name: String,
 }
 
 async fn process_updates(
@@ -66,7 +69,7 @@ async fn process_updates(
     entries: Vec<PathBuf>,
     mut cx: AsyncApp,
 ) -> Result<()> {
-    let fs = this.update(&mut cx, |this, _| this.fs.clone())?;
+    let fs = this.read_with(&mut cx, |this, _| this.fs.clone())?;
     for entry_path in entries {
         if !entry_path
             .extension()
@@ -117,7 +120,7 @@ async fn initial_scan(
     path: Arc<Path>,
     mut cx: AsyncApp,
 ) -> Result<()> {
-    let fs = this.update(&mut cx, |this, _| this.fs.clone())?;
+    let fs = this.read_with(&mut cx, |this, _| this.fs.clone())?;
     let entries = fs.read_dir(&path).await;
     if let Ok(entries) = entries {
         let entries = entries
@@ -141,15 +144,13 @@ struct GlobalSnippetWatcher(Entity<SnippetProvider>);
 
 impl GlobalSnippetWatcher {
     fn new(fs: Arc<dyn Fs>, cx: &mut App) -> Self {
-        let global_snippets_dir = paths::config_dir().join("snippets");
+        let global_snippets_dir = paths::snippets_dir();
         let provider = cx.new(|_cx| SnippetProvider {
             fs,
             snippets: Default::default(),
             watch_tasks: vec![],
         });
-        provider.update(cx, |this, cx| {
-            this.watch_directory(&global_snippets_dir, cx)
-        });
+        provider.update(cx, |this, cx| this.watch_directory(global_snippets_dir, cx));
         Self(provider)
     }
 }
@@ -182,7 +183,7 @@ impl SnippetProvider {
         let path: Arc<Path> = Arc::from(path);
 
         self.watch_tasks.push(cx.spawn(async move |this, cx| {
-            let fs = this.update(cx, |this, _| this.fs.clone())?;
+            let fs = this.read_with(cx, |this, _| this.fs.clone())?;
             let watched_path = path.clone();
             let watcher = fs.watch(&watched_path, Duration::from_secs(1));
             initial_scan(this.clone(), path, cx.clone()).await?;

crates/snippets_ui/Cargo.toml 🔗

@@ -12,12 +12,15 @@ workspace = true
 path = "src/snippets_ui.rs"
 
 [dependencies]
+file_finder.workspace = true
+file_icons.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
 language.workspace = true
 paths.workspace = true
 picker.workspace = true
+settings.workspace = true
 ui.workspace = true
 util.workspace = true
-workspace.workspace = true
 workspace-hack.workspace = true
+workspace.workspace = true

crates/snippets_ui/src/snippets_ui.rs 🔗

@@ -1,16 +1,59 @@
+use file_finder::file_finder_settings::FileFinderSettings;
+use file_icons::FileIcons;
 use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
 use gpui::{
     App, Context, DismissEvent, Entity, EventEmitter, Focusable, ParentElement, Render, Styled,
     WeakEntity, Window, actions,
 };
-use language::LanguageRegistry;
-use paths::config_dir;
+use language::{LanguageMatcher, LanguageName, LanguageRegistry};
+use paths::snippets_dir;
 use picker::{Picker, PickerDelegate};
-use std::{borrow::Borrow, fs, sync::Arc};
+use settings::Settings;
+use std::{
+    borrow::{Borrow, Cow},
+    collections::HashSet,
+    fs,
+    path::Path,
+    sync::Arc,
+};
 use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
 use util::ResultExt;
 use workspace::{ModalView, OpenOptions, OpenVisible, Workspace, notifications::NotifyResultExt};
 
+#[derive(Eq, Hash, PartialEq)]
+struct ScopeName(Cow<'static, str>);
+
+struct ScopeFileName(Cow<'static, str>);
+
+impl ScopeFileName {
+    fn with_extension(self) -> String {
+        format!("{}.json", self.0)
+    }
+}
+
+const GLOBAL_SCOPE_NAME: &str = "global";
+const GLOBAL_SCOPE_FILE_NAME: &str = "snippets";
+
+impl From<ScopeName> for ScopeFileName {
+    fn from(value: ScopeName) -> Self {
+        if value.0 == GLOBAL_SCOPE_NAME {
+            ScopeFileName(Cow::Borrowed(GLOBAL_SCOPE_FILE_NAME))
+        } else {
+            ScopeFileName(value.0)
+        }
+    }
+}
+
+impl From<ScopeFileName> for ScopeName {
+    fn from(value: ScopeFileName) -> Self {
+        if value.0 == GLOBAL_SCOPE_FILE_NAME {
+            ScopeName(Cow::Borrowed(GLOBAL_SCOPE_NAME))
+        } else {
+            ScopeName(value.0)
+        }
+    }
+}
+
 actions!(snippets, [ConfigureSnippets, OpenFolder]);
 
 pub fn init(cx: &mut App) {
@@ -42,8 +85,8 @@ fn open_folder(
     _: &mut Window,
     cx: &mut Context<Workspace>,
 ) {
-    fs::create_dir_all(config_dir().join("snippets")).notify_err(workspace, cx);
-    cx.open_with_system(config_dir().join("snippets").borrow());
+    fs::create_dir_all(snippets_dir()).notify_err(workspace, cx);
+    cx.open_with_system(snippets_dir().borrow());
 }
 
 pub struct ScopeSelector {
@@ -89,6 +132,7 @@ pub struct ScopeSelectorDelegate {
     candidates: Vec<StringMatchCandidate>,
     matches: Vec<StringMatch>,
     selected_index: usize,
+    existing_scopes: HashSet<ScopeName>,
 }
 
 impl ScopeSelectorDelegate {
@@ -97,7 +141,7 @@ impl ScopeSelectorDelegate {
         scope_selector: WeakEntity<ScopeSelector>,
         language_registry: Arc<LanguageRegistry>,
     ) -> Self {
-        let candidates = Vec::from(["Global".to_string()]).into_iter();
+        let candidates = Vec::from([GLOBAL_SCOPE_NAME.to_string()]).into_iter();
         let languages = language_registry.language_names().into_iter();
 
         let candidates = candidates
@@ -106,15 +150,44 @@ impl ScopeSelectorDelegate {
             .map(|(candidate_id, name)| StringMatchCandidate::new(candidate_id, &name))
             .collect::<Vec<_>>();
 
+        let mut existing_scopes = HashSet::new();
+
+        if let Some(read_dir) = fs::read_dir(snippets_dir()).log_err() {
+            for entry in read_dir {
+                if let Some(entry) = entry.log_err() {
+                    let path = entry.path();
+                    if let (Some(stem), Some(extension)) = (path.file_stem(), path.extension()) {
+                        if extension.to_os_string().to_str() == Some("json") {
+                            if let Ok(file_name) = stem.to_os_string().into_string() {
+                                existing_scopes
+                                    .insert(ScopeName::from(ScopeFileName(Cow::Owned(file_name))));
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
         Self {
             workspace,
             scope_selector,
             language_registry,
             candidates,
-            matches: vec![],
+            matches: Vec::new(),
             selected_index: 0,
+            existing_scopes,
         }
     }
+
+    fn scope_icon(&self, matcher: &LanguageMatcher, cx: &App) -> Option<Icon> {
+        matcher
+            .path_suffixes
+            .iter()
+            .find_map(|extension| FileIcons::get_icon(Path::new(extension), cx))
+            .or(FileIcons::get(cx).get_icon_for_type("default", cx))
+            .map(Icon::from_path)
+            .map(|icon| icon.color(Color::Muted))
+    }
 }
 
 impl PickerDelegate for ScopeSelectorDelegate {
@@ -135,15 +208,15 @@ impl PickerDelegate for ScopeSelectorDelegate {
 
             if let Some(workspace) = self.workspace.upgrade() {
                 cx.spawn_in(window, async move |_, cx| {
-                    let scope = match scope_name.as_str() {
-                        "Global" => "snippets".to_string(),
-                        _ => language.await?.lsp_id(),
-                    };
+                    let scope_file_name = ScopeFileName(match scope_name.to_lowercase().as_str() {
+                        GLOBAL_SCOPE_NAME => Cow::Borrowed(GLOBAL_SCOPE_FILE_NAME),
+                        _ => Cow::Owned(language.await?.lsp_id()),
+                    });
 
                     workspace.update_in(cx, |workspace, window, cx| {
                         workspace
                             .open_abs_path(
-                                config_dir().join("snippets").join(scope + ".json"),
+                                snippets_dir().join(scope_file_name.with_extension()),
                                 OpenOptions {
                                     visible: Some(OpenVisible::None),
                                     ..Default::default()
@@ -204,6 +277,7 @@ impl PickerDelegate for ScopeSelectorDelegate {
                     &candidates,
                     &query,
                     false,
+                    true,
                     100,
                     &Default::default(),
                     background,
@@ -228,17 +302,53 @@ impl PickerDelegate for ScopeSelectorDelegate {
         ix: usize,
         selected: bool,
         _window: &mut Window,
-        _: &mut Context<Picker<Self>>,
+        cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         let mat = &self.matches[ix];
-        let label = mat.string.clone();
+        let name_label = mat.string.clone();
+
+        let scope_name = ScopeName(Cow::Owned(
+            LanguageName::new(&self.candidates[mat.candidate_id].string).lsp_id(),
+        ));
+        let file_label = if self.existing_scopes.contains(&scope_name) {
+            Some(ScopeFileName::from(scope_name).with_extension())
+        } else {
+            None
+        };
+
+        let language_icon = if FileFinderSettings::get_global(cx).file_icons {
+            let language_name = LanguageName::new(mat.string.as_str());
+            self.language_registry
+                .available_language_for_name(language_name.as_ref())
+                .and_then(|available_language| self.scope_icon(available_language.matcher(), cx))
+                .or_else(|| {
+                    Some(
+                        Icon::from_path(IconName::Globe.path())
+                            .map(|icon| icon.color(Color::Muted)),
+                    )
+                })
+        } else {
+            None
+        };
 
         Some(
             ListItem::new(ix)
                 .inset(true)
                 .spacing(ListItemSpacing::Sparse)
                 .toggle_state(selected)
-                .child(HighlightedLabel::new(label, mat.positions.clone())),
+                .start_slot::<Icon>(language_icon)
+                .child(
+                    h_flex()
+                        .gap_x_2()
+                        .child(HighlightedLabel::new(name_label, mat.positions.clone()))
+                        .when_some(file_label, |item, path_label| {
+                            item.child(
+                                Label::new(path_label)
+                                    .color(Color::Muted)
+                                    .size(LabelSize::Small),
+                            )
+                        }),
+                ),
         )
     }
 }

crates/sqlez/src/connection.rs 🔗

@@ -6,7 +6,7 @@ use std::{
     ptr,
 };
 
-use anyhow::{Result, anyhow};
+use anyhow::Result;
 use libsqlite3_sys::*;
 
 pub struct Connection {
@@ -199,11 +199,7 @@ impl Connection {
                 )
             };
 
-            Err(anyhow!(
-                "Sqlite call failed with code {} and message: {:?}",
-                code as isize,
-                message
-            ))
+            anyhow::bail!("Sqlite call failed with code {code} and message: {message:?}")
         }
     }
 

crates/sqlez/src/migrations.rs 🔗

@@ -6,7 +6,7 @@
 
 use std::ffi::CString;
 
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
 use indoc::{formatdoc, indoc};
 use libsqlite3_sys::sqlite3_exec;
 
@@ -69,14 +69,14 @@ impl Connection {
                         // Migration already run. Continue
                         continue;
                     } else {
-                        return Err(anyhow!(formatdoc! {"
-                            Migration changed for {} at step {}
+                        anyhow::bail!(formatdoc! {"
+                            Migration changed for {domain} at step {index}
 
                             Stored migration:
-                            {}
+                            {completed_migration}
 
                             Proposed migration:
-                            {}", domain, index, completed_migration, migration}));
+                            {migration}"});
                     }
                 }
 

crates/sqlez/src/savepoint.rs 🔗

@@ -78,7 +78,7 @@ mod tests {
 
             assert!(
                 connection
-                    .with_savepoint("second", || -> Result<Option<()>, anyhow::Error> {
+                    .with_savepoint("second", || -> anyhow::Result<Option<()>> {
                         connection.exec_bound("INSERT INTO text(text, idx) VALUES (?, ?)")?((
                             save2_text, 2,
                         ))?;

crates/sqlez/src/statement.rs 🔗

@@ -2,7 +2,7 @@ use std::ffi::{CStr, CString, c_int};
 use std::marker::PhantomData;
 use std::{ptr, slice, str};
 
-use anyhow::{Context, Result, anyhow, bail};
+use anyhow::{Context as _, Result, bail};
 use libsqlite3_sys::*;
 
 use crate::bindable::{Bind, Column};
@@ -126,7 +126,7 @@ impl<'a> Statement<'a> {
         if any_succeed {
             Ok(())
         } else {
-            Err(anyhow!("Failed to bind parameters"))
+            anyhow::bail!("Failed to bind parameters")
         }
     }
 
@@ -261,7 +261,7 @@ impl<'a> Statement<'a> {
             SQLITE_TEXT => Ok(SqlType::Text),
             SQLITE_BLOB => Ok(SqlType::Blob),
             SQLITE_NULL => Ok(SqlType::Null),
-            _ => Err(anyhow!("Column type returned was incorrect ")),
+            _ => anyhow::bail!("Column type returned was incorrect"),
         }
     }
 
@@ -282,7 +282,7 @@ impl<'a> Statement<'a> {
                         self.step()
                     }
                 }
-                SQLITE_MISUSE => Err(anyhow!("Statement step returned SQLITE_MISUSE")),
+                SQLITE_MISUSE => anyhow::bail!("Statement step returned SQLITE_MISUSE"),
                 _other_error => {
                     self.connection.last_error()?;
                     unreachable!("Step returned error code and last error failed to catch it");
@@ -328,16 +328,16 @@ impl<'a> Statement<'a> {
             callback: impl FnOnce(&mut Statement) -> Result<R>,
         ) -> Result<R> {
             println!("{:?}", std::any::type_name::<R>());
-            if this.step()? != StepResult::Row {
-                return Err(anyhow!("single called with query that returns no rows."));
-            }
+            anyhow::ensure!(
+                this.step()? == StepResult::Row,
+                "single called with query that returns no rows."
+            );
             let result = callback(this)?;
 
-            if this.step()? != StepResult::Done {
-                return Err(anyhow!(
-                    "single called with a query that returns more than one row."
-                ));
-            }
+            anyhow::ensure!(
+                this.step()? == StepResult::Done,
+                "single called with a query that returns more than one row."
+            );
 
             Ok(result)
         }
@@ -366,11 +366,10 @@ impl<'a> Statement<'a> {
                 .map(|r| Some(r))
                 .context("Failed to parse row result")?;
 
-            if this.step().context("Second step call")? != StepResult::Done {
-                return Err(anyhow!(
-                    "maybe called with a query that returns more than one row."
-                ));
-            }
+            anyhow::ensure!(
+                this.step().context("Second step call")? == StepResult::Done,
+                "maybe called with a query that returns more than one row."
+            );
 
             Ok(result)
         }

crates/story/src/story.rs 🔗

@@ -1,6 +1,5 @@
 use gpui::{
-    AnyElement, App, DefaultColor, DefaultColors, Div, SharedString, Window, div, prelude::*, px,
-    rems,
+    AnyElement, App, Div, SharedString, Window, colors::DefaultColors, div, prelude::*, px, rems,
 };
 use itertools::Itertools;
 use smallvec::SmallVec;
@@ -8,9 +7,7 @@ use smallvec::SmallVec;
 pub struct Story {}
 
 impl Story {
-    pub fn container() -> gpui::Stateful<Div> {
-        let colors = DefaultColors::light();
-
+    pub fn container(cx: &App) -> gpui::Stateful<Div> {
         div()
             .id("story_container")
             .overflow_y_scroll()
@@ -18,84 +15,66 @@ impl Story {
             .min_h_full()
             .flex()
             .flex_col()
-            .text_color(DefaultColor::Text.hsla(&colors))
-            .bg(DefaultColor::Background.hsla(&colors))
+            .text_color(cx.default_colors().text)
+            .bg(cx.default_colors().background)
     }
 
-    pub fn title(title: impl Into<SharedString>) -> impl Element {
-        let colors = DefaultColors::light();
-
+    pub fn title(title: impl Into<SharedString>, cx: &App) -> impl Element {
         div()
             .text_xs()
-            .text_color(DefaultColor::Text.hsla(&colors))
+            .text_color(cx.default_colors().text)
             .child(title.into())
     }
 
-    pub fn title_for<T>() -> impl Element {
-        Self::title(std::any::type_name::<T>())
+    pub fn title_for<T>(cx: &App) -> impl Element {
+        Self::title(std::any::type_name::<T>(), cx)
     }
 
-    pub fn section() -> Div {
-        let colors = DefaultColors::light();
-
+    pub fn section(cx: &App) -> Div {
         div()
             .p_4()
             .m_4()
             .border_1()
-            .border_color(DefaultColor::Separator.hsla(&colors))
+            .border_color(cx.default_colors().separator)
     }
 
-    pub fn section_title() -> Div {
-        let colors = DefaultColors::light();
-
-        div().text_lg().text_color(DefaultColor::Text.hsla(&colors))
+    pub fn section_title(cx: &App) -> Div {
+        div().text_lg().text_color(cx.default_colors().text)
     }
 
-    pub fn group() -> Div {
-        let colors = DefaultColors::light();
-        div().my_2().bg(DefaultColor::Container.hsla(&colors))
+    pub fn group(cx: &App) -> Div {
+        div().my_2().bg(cx.default_colors().container)
     }
 
-    pub fn code_block(code: impl Into<SharedString>) -> Div {
-        let colors = DefaultColors::light();
-
+    pub fn code_block(code: impl Into<SharedString>, cx: &App) -> Div {
         div()
             .size_full()
             .p_2()
             .max_w(rems(36.))
-            .bg(DefaultColor::Container.hsla(&colors))
+            .bg(cx.default_colors().container)
             .rounded_sm()
             .text_sm()
-            .text_color(DefaultColor::Text.hsla(&colors))
+            .text_color(cx.default_colors().text)
             .overflow_hidden()
             .child(code.into())
     }
 
-    pub fn divider() -> Div {
-        let colors = DefaultColors::light();
-
-        div()
-            .my_2()
-            .h(px(1.))
-            .bg(DefaultColor::Separator.hsla(&colors))
+    pub fn divider(cx: &App) -> Div {
+        div().my_2().h(px(1.)).bg(cx.default_colors().separator)
     }
 
-    pub fn description(description: impl Into<SharedString>) -> impl Element {
-        let colors = DefaultColors::light();
-
+    pub fn description(description: impl Into<SharedString>, cx: &App) -> impl Element {
         div()
             .text_sm()
-            .text_color(DefaultColor::Text.hsla(&colors))
+            .text_color(cx.default_colors().text)
             .min_w_96()
             .child(description.into())
     }
 
-    pub fn label(label: impl Into<SharedString>) -> impl Element {
-        let colors = DefaultColors::light();
-
+    pub fn label(label: impl Into<SharedString>, cx: &App) -> impl Element {
         div()
             .text_xs()
-            .text_color(DefaultColor::Text.hsla(&colors))
+            .text_color(cx.default_colors().text)
             .child(label.into())
     }
 
@@ -135,8 +114,8 @@ impl StoryItem {
 }
 
 impl RenderOnce for StoryItem {
-    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
-        let colors = DefaultColors::light();
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let colors = cx.default_colors();
 
         div()
             .my_2()
@@ -148,20 +127,20 @@ impl RenderOnce for StoryItem {
                     .px_2()
                     .w_1_2()
                     .min_h_px()
-                    .child(Story::label(self.label))
+                    .child(Story::label(self.label, cx))
                     .child(
                         div()
                             .rounded_sm()
-                            .bg(DefaultColor::Background.hsla(&colors))
+                            .bg(colors.background)
                             .border_1()
-                            .border_color(DefaultColor::Border.hsla(&colors))
+                            .border_color(colors.border)
                             .py_1()
                             .px_2()
                             .overflow_hidden()
                             .child(self.item),
                     )
                     .when_some(self.description, |this, description| {
-                        this.child(Story::description(description))
+                        this.child(Story::description(description, cx))
                     }),
             )
             .child(
@@ -171,8 +150,8 @@ impl RenderOnce for StoryItem {
                     .w_1_2()
                     .min_h_px()
                     .when_some(self.usage, |this, usage| {
-                        this.child(Story::label("Example Usage"))
-                            .child(Story::code_block(usage))
+                        this.child(Story::label("Example Usage", cx))
+                            .child(Story::code_block(usage, cx))
                     }),
             )
     }
@@ -205,21 +184,21 @@ impl StorySection {
 }
 
 impl RenderOnce for StorySection {
-    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
         let children: SmallVec<[AnyElement; 2]> = SmallVec::from_iter(Itertools::intersperse_with(
             self.children.into_iter(),
-            || Story::divider().into_any_element(),
+            || Story::divider(cx).into_any_element(),
         ));
 
-        Story::section()
+        Story::section(cx)
             // Section title
             .py_2()
             // Section description
             .when_some(self.description.clone(), |section, description| {
-                section.child(Story::description(description))
+                section.child(Story::description(description, cx))
             })
             .child(div().flex().flex_col().gap_2().children(children))
-            .child(Story::divider())
+            .child(Story::divider(cx))
     }
 }
 

crates/storybook/src/assets.rs 🔗

@@ -1,6 +1,6 @@
 use std::borrow::Cow;
 
-use anyhow::{Result, anyhow};
+use anyhow::{Context as _, Result};
 use gpui::{AssetSource, SharedString};
 use rust_embed::RustEmbed;
 
@@ -19,7 +19,7 @@ impl AssetSource for Assets {
     fn load(&self, path: &str) -> Result<Option<Cow<'static, [u8]>>> {
         Self::get(path)
             .map(|f| f.data)
-            .ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path))
+            .with_context(|| format!("could not find asset at path {path:?}"))
             .map(Some)
     }
 

crates/storybook/src/stories.rs 🔗

@@ -1,6 +1,5 @@
 mod auto_height_editor;
 mod cursor;
-mod default_colors;
 mod focus;
 mod kitchen_sink;
 mod overflow_scroll;
@@ -12,7 +11,6 @@ mod with_rem_size;
 
 pub use auto_height_editor::*;
 pub use cursor::*;
-pub use default_colors::*;
 pub use focus::*;
 pub use kitchen_sink::*;
 pub use overflow_scroll::*;

crates/storybook/src/stories/auto_height_editor.rs 🔗

@@ -17,7 +17,7 @@ impl AutoHeightEditorStory {
         )]);
         cx.new(|cx| Self {
             editor: cx.new(|cx| {
-                let mut editor = Editor::auto_height(3, window, cx);
+                let mut editor = Editor::auto_height(1, 3, window, cx);
                 editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
                 editor
             }),

crates/storybook/src/stories/cursor.rs 🔗

@@ -5,7 +5,7 @@ use ui::prelude::*;
 pub struct CursorStory;
 
 impl Render for CursorStory {
-    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let all_cursors: [(&str, Box<dyn Fn(Stateful<Div>) -> Stateful<Div>>); 19] = [
             (
                 "cursor_default",
@@ -85,10 +85,10 @@ impl Render for CursorStory {
             ),
         ];
 
-        Story::container()
+        Story::container(cx)
             .flex()
             .gap_1()
-            .child(Story::title("cursor"))
+            .child(Story::title("cursor", cx))
             .children(all_cursors.map(|(name, apply_cursor)| {
                 div().gap_1().flex().text_color(gpui::white()).child(
                     div()
@@ -102,7 +102,7 @@ impl Render for CursorStory {
                         .bg(gpui::red())
                         .active(|style| style.bg(gpui::green()))
                         .text_sm()
-                        .child(Story::label(name)),
+                        .child(Story::label(name, cx)),
                 )
             }))
     }

crates/storybook/src/stories/default_colors.rs 🔗

@@ -1,89 +0,0 @@
-use gpui::{
-    App, Context, DefaultColor, DefaultThemeAppearance, Entity, Hsla, Render, Window, colors, div,
-    prelude::*,
-};
-use story::Story;
-use strum::IntoEnumIterator;
-use ui::{ActiveTheme, h_flex};
-
-pub struct DefaultColorsStory;
-
-impl DefaultColorsStory {
-    pub fn model(cx: &mut App) -> Entity<Self> {
-        cx.new(|_| Self)
-    }
-}
-
-impl Render for DefaultColorsStory {
-    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let appearances = [DefaultThemeAppearance::Light, DefaultThemeAppearance::Dark];
-
-        Story::container()
-            .child(Story::title("Default Colors"))
-            .children(appearances.iter().map(|&appearance| {
-                let colors = colors(appearance);
-                let color_types = DefaultColor::iter()
-                    .map(|color| {
-                        let name = format!("{:?}", color);
-                        let rgba = color.hsla(&colors);
-                        (name, rgba)
-                    })
-                    .collect::<Vec<_>>();
-
-                div()
-                    .flex()
-                    .flex_col()
-                    .gap_4()
-                    .p_4()
-                    .child(Story::label(format!("{:?} Appearance", appearance)))
-                    .children(color_types.iter().map(|(name, color)| {
-                        let color: Hsla = *color;
-
-                        div()
-                            .flex()
-                            .items_center()
-                            .gap_2()
-                            .child(
-                                div()
-                                    .w_12()
-                                    .h_12()
-                                    .bg(color)
-                                    .border_1()
-                                    .border_color(cx.theme().colors().border),
-                            )
-                            .child(Story::label(format!("{}: {:?}", name, color.clone())))
-                    }))
-                    .child(
-                        h_flex()
-                            .gap_1()
-                            .child(
-                                h_flex()
-                                    .bg(DefaultColor::Background.hsla(&colors))
-                                    .h_8()
-                                    .p_2()
-                                    .text_sm()
-                                    .text_color(DefaultColor::Text.hsla(&colors))
-                                    .child("Default Text"),
-                            )
-                            .child(
-                                h_flex()
-                                    .bg(DefaultColor::Container.hsla(&colors))
-                                    .h_8()
-                                    .p_2()
-                                    .text_sm()
-                                    .text_color(DefaultColor::Text.hsla(&colors))
-                                    .child("Text on Container"),
-                            )
-                            .child(
-                                h_flex()
-                                    .bg(DefaultColor::Selected.hsla(&colors))
-                                    .h_8()
-                                    .p_2()
-                                    .text_sm()
-                                    .text_color(DefaultColor::SelectedText.hsla(&colors))
-                                    .child("Selected Text"),
-                            ),
-                    )
-            }))
-    }
-}

crates/storybook/src/stories/indent_guides.rs 🔗

@@ -1,12 +1,12 @@
 use std::fmt::format;
 
 use gpui::{
-    colors, div, prelude::*, uniform_list, DefaultColor, DefaultThemeAppearance, Hsla, Render,
+    DefaultColor, DefaultThemeAppearance, Hsla, Render, colors, div, prelude::*, uniform_list,
 };
 use story::Story;
 use strum::IntoEnumIterator;
 use ui::{
-    h_flex, px, v_flex, AbsoluteLength, ActiveTheme, Color, DefiniteLength, Label, LabelCommon,
+    AbsoluteLength, ActiveTheme, Color, DefiniteLength, Label, LabelCommon, h_flex, px, v_flex,
 };
 
 const LENGTH: usize = 100;
@@ -34,7 +34,7 @@ impl IndentGuidesStory {
 
 impl Render for IndentGuidesStory {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        Story::container()
+        Story::container(cx)
             .child(Story::title("Indent guides"))
             .child(
                 v_flex().size_full().child(

crates/storybook/src/stories/kitchen_sink.rs 🔗

@@ -19,11 +19,11 @@ impl Render for KitchenSinkStory {
             .map(|selector| selector.story(window, cx))
             .collect::<Vec<_>>();
 
-        Story::container()
+        Story::container(cx)
             .id("kitchen-sink")
             .overflow_y_scroll()
-            .child(Story::title("Kitchen Sink"))
-            .child(Story::label("Components"))
+            .child(Story::title("Kitchen Sink", cx))
+            .child(Story::label("Components", cx))
             .child(div().flex().flex_col().children(component_stories))
             // Add a bit of space at the bottom of the kitchen sink so elements
             // don't end up squished right up against the bottom of the screen.

crates/storybook/src/stories/overflow_scroll.rs 🔗

@@ -6,10 +6,10 @@ use ui::prelude::*;
 pub struct OverflowScrollStory;
 
 impl Render for OverflowScrollStory {
-    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
-        Story::container()
-            .child(Story::title("Overflow Scroll"))
-            .child(Story::label("`overflow_x_scroll`"))
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        Story::container(cx)
+            .child(Story::title("Overflow Scroll", cx))
+            .child(Story::label("`overflow_x_scroll`", cx))
             .child(
                 h_flex()
                     .id("overflow_x_scroll")
@@ -22,7 +22,7 @@ impl Render for OverflowScrollStory {
                             .child(SharedString::from(format!("Child {}", i + 1)))
                     })),
             )
-            .child(Story::label("`overflow_y_scroll`"))
+            .child(Story::label("`overflow_y_scroll`", cx))
             .child(
                 v_flex()
                     .w_full()

crates/storybook/src/stories/text.rs 🔗

@@ -14,9 +14,9 @@ impl TextStory {
 }
 
 impl Render for TextStory {
-    fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
-        Story::container()
-            .child(Story::title("Text"))
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        Story::container(cx)
+            .child(Story::title("Text", cx))
             .children(vec![
                 StorySection::new()
                     .child(

crates/storybook/src/stories/viewport_units.rs 🔗

@@ -6,8 +6,8 @@ use ui::prelude::*;
 pub struct ViewportUnitsStory;
 
 impl Render for ViewportUnitsStory {
-    fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
-        Story::container().child(
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        Story::container(cx).child(
             div()
                 .flex()
                 .flex_row()

crates/storybook/src/stories/with_rem_size.rs 🔗

@@ -6,8 +6,8 @@ use ui::{prelude::*, utils::WithRemSize};
 pub struct WithRemSizeStory;
 
 impl Render for WithRemSizeStory {
-    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
-        Story::container().child(
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        Story::container(cx).child(
             Example::new(16., gpui::red())
                 .child(
                     Example::new(24., gpui::green())

crates/storybook/src/story_selector.rs 🔗

@@ -2,7 +2,6 @@ use std::str::FromStr;
 use std::sync::OnceLock;
 
 use crate::stories::*;
-use anyhow::anyhow;
 use clap::ValueEnum;
 use clap::builder::PossibleValue;
 use gpui::AnyView;
@@ -17,7 +16,6 @@ pub enum ComponentStory {
     CollabNotification,
     ContextMenu,
     Cursor,
-    DefaultColors,
     Focus,
     IconButton,
     Keybinding,
@@ -47,7 +45,6 @@ impl ComponentStory {
                 .into(),
             Self::ContextMenu => cx.new(|_| ui::ContextMenuStory).into(),
             Self::Cursor => cx.new(|_| crate::stories::CursorStory).into(),
-            Self::DefaultColors => DefaultColorsStory::model(cx).into(),
             Self::Focus => FocusStory::model(window, cx).into(),
             Self::IconButton => cx.new(|_| ui::IconButtonStory).into(),
             Self::Keybinding => cx.new(|_| ui::KeybindingStory).into(),
@@ -92,7 +89,7 @@ impl FromStr for StorySelector {
             return Ok(Self::Component(component_story));
         }
 
-        Err(anyhow!("story not found for '{raw_story_name}'"))
+        anyhow::bail!("story not found for '{raw_story_name}'")
     }
 }
 

crates/storybook/src/storybook.rs 🔗

@@ -129,7 +129,7 @@ impl Render for StoryWrapper {
     }
 }
 
-fn load_embedded_fonts(cx: &App) -> gpui::Result<()> {
+fn load_embedded_fonts(cx: &App) -> anyhow::Result<()> {
     let font_paths = cx.asset_source().list("fonts")?;
     let mut embedded_fonts = Vec::new();
     for font_path in font_paths {
@@ -146,7 +146,7 @@ fn load_embedded_fonts(cx: &App) -> gpui::Result<()> {
 }
 
 fn load_storybook_keymap(cx: &mut App) {
-    cx.bind_keys(KeymapFile::load_asset("keymaps/storybook.json", cx).unwrap());
+    cx.bind_keys(KeymapFile::load_asset("keymaps/storybook.json", None, cx).unwrap());
 }
 
 pub fn init(cx: &mut App) {

crates/sum_tree/Cargo.toml 🔗

@@ -20,5 +20,5 @@ workspace-hack.workspace = true
 
 [dev-dependencies]
 ctor.workspace = true
-env_logger.workspace = true
 rand.workspace = true
+zlog.workspace = true

crates/sum_tree/src/sum_tree.rs 🔗

@@ -380,7 +380,7 @@ impl<T: Item> SumTree<T> {
         items
     }
 
-    pub fn iter(&self) -> Iter<T> {
+    pub fn iter(&self) -> Iter<'_, T> {
         Iter::new(self)
     }
 
@@ -998,9 +998,7 @@ mod tests {
 
     #[ctor::ctor]
     fn init_logger() {
-        if std::env::var("RUST_LOG").is_ok() {
-            env_logger::init();
-        }
+        zlog::init_test();
     }
 
     #[test]

crates/supermaven_api/Cargo.toml 🔗

@@ -20,4 +20,5 @@ paths.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 smol.workspace = true
+util.workspace = true
 workspace-hack.workspace = true

crates/supermaven_api/src/supermaven_api.rs 🔗

@@ -5,10 +5,11 @@ use http_client::{AsyncBody, HttpClient, Request as HttpRequest};
 use paths::supermaven_dir;
 use serde::{Deserialize, Serialize};
 use smol::fs::{self, File};
-use smol::stream::StreamExt;
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
 
+use util::fs::{make_file_executable, remove_matching};
+
 #[derive(Serialize)]
 pub struct GetExternalUserRequest {
     pub id: String,
@@ -91,7 +92,7 @@ impl SupermavenAdminApi {
             if error.message == "User not found" {
                 return Ok(None);
             } else {
-                return Err(anyhow!("Supermaven API error: {}", error.message));
+                anyhow::bail!("Supermaven API error: {}", error.message);
             }
         } else if response.status().is_server_error() {
             let error: SupermavenApiError = serde_json::from_slice(&body)?;
@@ -155,7 +156,7 @@ impl SupermavenAdminApi {
             if error.message == "User not found" {
                 return Ok(());
             } else {
-                return Err(anyhow!("Supermaven API error: {}", error.message));
+                anyhow::bail!("Supermaven API error: {}", error.message);
             }
         } else if response.status().is_server_error() {
             let error: SupermavenApiError = serde_json::from_slice(&body)?;
@@ -204,7 +205,7 @@ pub async fn latest_release(
     if response.status().is_client_error() || response.status().is_server_error() {
         let body_str = std::str::from_utf8(&body)?;
         let error: SupermavenApiError = serde_json::from_str(body_str)?;
-        return Err(anyhow!("Supermaven API error: {}", error.message));
+        anyhow::bail!("Supermaven API error: {}", error.message);
     }
 
     serde_json::from_slice::<SupermavenDownloadResponse>(&body)
@@ -239,13 +240,13 @@ pub async fn get_supermaven_agent_path(client: Arc<dyn HttpClient>) -> Result<Pa
         "macos" => "darwin",
         "windows" => "windows",
         "linux" => "linux",
-        _ => return Err(anyhow!("unsupported platform")),
+        unsupported => anyhow::bail!("unsupported platform {unsupported}"),
     };
 
     let arch = match std::env::consts::ARCH {
         "x86_64" => "amd64",
         "aarch64" => "arm64",
-        _ => return Err(anyhow!("unsupported architecture")),
+        unsupported => anyhow::bail!("unsupported architecture {unsupported}"),
     };
 
     let download_info = latest_release(client.clone(), platform, arch).await?;
@@ -253,6 +254,11 @@ pub async fn get_supermaven_agent_path(client: Arc<dyn HttpClient>) -> Result<Pa
     let binary_path = version_path(download_info.version);
 
     if has_version(&binary_path).await {
+        // Due to an issue with the Supermaven binary not being made executable on
+        // earlier Zed versions and Supermaven releases not occurring that frequently,
+        // we ensure here that the found binary is actually executable.
+        make_file_executable(&binary_path).await?;
+
         return Ok(binary_path);
     }
 
@@ -271,21 +277,9 @@ pub async fn get_supermaven_agent_path(client: Arc<dyn HttpClient>) -> Result<Pa
         .await
         .with_context(|| format!("Unable to write binary to file at {:?}", binary_path))?;
 
-    #[cfg(not(windows))]
-    {
-        file.set_permissions(<fs::Permissions as fs::unix::PermissionsExt>::from_mode(
-            0o755,
-        ))
-        .await?;
-    }
+    make_file_executable(&binary_path).await?;
 
-    let mut old_binary_paths = fs::read_dir(supermaven_dir()).await?;
-    while let Some(old_binary_path) = old_binary_paths.next().await {
-        let old_binary_path = old_binary_path?;
-        if old_binary_path.path() != binary_path {
-            fs::remove_file(old_binary_path.path()).await?;
-        }
-    }
+    remove_matching(supermaven_dir(), |file| file != binary_path).await;
 
     Ok(binary_path)
 }

crates/svg_preview/Cargo.toml 🔗

@@ -0,0 +1,20 @@
+[package]
+name = "svg_preview"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/svg_preview.rs"
+
+[dependencies]
+editor.workspace = true
+file_icons.workspace = true
+gpui.workspace = true
+ui.workspace = true
+workspace.workspace = true
+workspace-hack.workspace = true

crates/svg_preview/src/svg_preview.rs 🔗

@@ -0,0 +1,19 @@
+use gpui::{App, actions};
+use workspace::Workspace;
+
+pub mod svg_preview_view;
+
+actions!(
+    svg,
+    [OpenPreview, OpenPreviewToTheSide, OpenFollowingPreview]
+);
+
+pub fn init(cx: &mut App) {
+    cx.observe_new(|workspace: &mut Workspace, window, cx| {
+        let Some(window) = window else {
+            return;
+        };
+        crate::svg_preview_view::SvgPreviewView::register(workspace, window, cx);
+    })
+    .detach();
+}

crates/svg_preview/src/svg_preview_view.rs 🔗

@@ -0,0 +1,323 @@
+use std::path::PathBuf;
+
+use editor::{Editor, EditorEvent};
+use file_icons::FileIcons;
+use gpui::{
+    App, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, IntoElement,
+    ParentElement, Render, Resource, RetainAllImageCache, Styled, Subscription, WeakEntity, Window,
+    div, img,
+};
+use ui::prelude::*;
+use workspace::item::Item;
+use workspace::{Pane, Workspace};
+
+use crate::{OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide};
+
+pub struct SvgPreviewView {
+    focus_handle: FocusHandle,
+    svg_path: Option<PathBuf>,
+    image_cache: Entity<RetainAllImageCache>,
+    _editor_subscription: Subscription,
+    _workspace_subscription: Option<Subscription>,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum SvgPreviewMode {
+    /// The preview will always show the contents of the provided editor.
+    Default,
+    /// The preview will "follow" the last active editor of an SVG file.
+    Follow,
+}
+
+impl SvgPreviewView {
+    pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context<Workspace>) {
+        workspace.register_action(move |workspace, _: &OpenPreview, window, cx| {
+            if let Some(editor) = Self::resolve_active_item_as_svg_editor(workspace, cx) {
+                if Self::is_svg_file(&editor, cx) {
+                    let view = Self::create_svg_view(
+                        SvgPreviewMode::Default,
+                        workspace,
+                        editor.clone(),
+                        window,
+                        cx,
+                    );
+                    workspace.active_pane().update(cx, |pane, cx| {
+                        if let Some(existing_view_idx) =
+                            Self::find_existing_preview_item_idx(pane, &editor, cx)
+                        {
+                            pane.activate_item(existing_view_idx, true, true, window, cx);
+                        } else {
+                            pane.add_item(Box::new(view), true, true, None, window, cx)
+                        }
+                    });
+                    cx.notify();
+                }
+            }
+        });
+
+        workspace.register_action(move |workspace, _: &OpenPreviewToTheSide, window, cx| {
+            if let Some(editor) = Self::resolve_active_item_as_svg_editor(workspace, cx) {
+                if Self::is_svg_file(&editor, cx) {
+                    let editor_clone = editor.clone();
+                    let view = Self::create_svg_view(
+                        SvgPreviewMode::Default,
+                        workspace,
+                        editor_clone,
+                        window,
+                        cx,
+                    );
+                    let pane = workspace
+                        .find_pane_in_direction(workspace::SplitDirection::Right, cx)
+                        .unwrap_or_else(|| {
+                            workspace.split_pane(
+                                workspace.active_pane().clone(),
+                                workspace::SplitDirection::Right,
+                                window,
+                                cx,
+                            )
+                        });
+                    pane.update(cx, |pane, cx| {
+                        if let Some(existing_view_idx) =
+                            Self::find_existing_preview_item_idx(pane, &editor, cx)
+                        {
+                            pane.activate_item(existing_view_idx, true, true, window, cx);
+                        } else {
+                            pane.add_item(Box::new(view), false, false, None, window, cx)
+                        }
+                    });
+                    cx.notify();
+                }
+            }
+        });
+
+        workspace.register_action(move |workspace, _: &OpenFollowingPreview, window, cx| {
+            if let Some(editor) = Self::resolve_active_item_as_svg_editor(workspace, cx) {
+                if Self::is_svg_file(&editor, cx) {
+                    let view = Self::create_svg_view(
+                        SvgPreviewMode::Follow,
+                        workspace,
+                        editor,
+                        window,
+                        cx,
+                    );
+                    workspace.active_pane().update(cx, |pane, cx| {
+                        pane.add_item(Box::new(view), true, true, None, window, cx)
+                    });
+                    cx.notify();
+                }
+            }
+        });
+    }
+
+    fn find_existing_preview_item_idx(
+        pane: &Pane,
+        editor: &Entity<Editor>,
+        cx: &App,
+    ) -> Option<usize> {
+        let editor_path = Self::get_svg_path(editor, cx);
+        pane.items_of_type::<SvgPreviewView>()
+            .find(|view| {
+                let view_read = view.read(cx);
+                view_read.svg_path.is_some() && view_read.svg_path == editor_path
+            })
+            .and_then(|view| pane.index_for_item(&view))
+    }
+
+    pub fn resolve_active_item_as_svg_editor(
+        workspace: &Workspace,
+        cx: &mut Context<Workspace>,
+    ) -> Option<Entity<Editor>> {
+        let editor = workspace.active_item(cx)?.act_as::<Editor>(cx)?;
+
+        if Self::is_svg_file(&editor, cx) {
+            Some(editor)
+        } else {
+            None
+        }
+    }
+
+    fn create_svg_view(
+        mode: SvgPreviewMode,
+        workspace: &mut Workspace,
+        editor: Entity<Editor>,
+        window: &mut Window,
+        cx: &mut Context<Workspace>,
+    ) -> Entity<SvgPreviewView> {
+        let workspace_handle = workspace.weak_handle();
+        SvgPreviewView::new(mode, editor, workspace_handle, window, cx)
+    }
+
+    pub fn new(
+        mode: SvgPreviewMode,
+        active_editor: Entity<Editor>,
+        workspace_handle: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut Context<Workspace>,
+    ) -> Entity<Self> {
+        cx.new(|cx| {
+            let svg_path = Self::get_svg_path(&active_editor, cx);
+            let image_cache = RetainAllImageCache::new(cx);
+
+            let subscription = cx.subscribe_in(
+                &active_editor,
+                window,
+                |this: &mut SvgPreviewView, _editor, event: &EditorEvent, window, cx| {
+                    match event {
+                        EditorEvent::Saved => {
+                            // Remove cached image to force reload
+                            if let Some(svg_path) = &this.svg_path {
+                                let resource = Resource::Path(svg_path.clone().into());
+                                this.image_cache.update(cx, |cache, cx| {
+                                    cache.remove(&resource, window, cx);
+                                });
+                            }
+                            cx.notify();
+                        }
+                        _ => {}
+                    }
+                },
+            );
+
+            // Subscribe to workspace active item changes to follow SVG files
+            let workspace_subscription = if mode == SvgPreviewMode::Follow {
+                workspace_handle.upgrade().map(|workspace_handle| {
+                    cx.subscribe_in(
+                        &workspace_handle,
+                        window,
+                        |this: &mut SvgPreviewView,
+                         workspace,
+                         event: &workspace::Event,
+                         _window,
+                         cx| {
+                            match event {
+                                workspace::Event::ActiveItemChanged => {
+                                    let workspace_read = workspace.read(cx);
+                                    if let Some(active_item) = workspace_read.active_item(cx) {
+                                        if let Some(editor_entity) =
+                                            active_item.downcast::<Editor>()
+                                        {
+                                            if Self::is_svg_file(&editor_entity, cx) {
+                                                let new_path =
+                                                    Self::get_svg_path(&editor_entity, cx);
+                                                if this.svg_path != new_path {
+                                                    this.svg_path = new_path;
+                                                    cx.notify();
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                                _ => {}
+                            }
+                        },
+                    )
+                })
+            } else {
+                None
+            };
+
+            Self {
+                focus_handle: cx.focus_handle(),
+                svg_path,
+                image_cache,
+                _editor_subscription: subscription,
+                _workspace_subscription: workspace_subscription,
+            }
+        })
+    }
+
+    pub fn is_svg_file<C>(editor: &Entity<Editor>, cx: &C) -> bool
+    where
+        C: std::borrow::Borrow<App>,
+    {
+        let app = cx.borrow();
+        let buffer = editor.read(app).buffer().read(app);
+        if let Some(buffer) = buffer.as_singleton() {
+            if let Some(file) = buffer.read(app).file() {
+                return file
+                    .path()
+                    .extension()
+                    .and_then(|ext| ext.to_str())
+                    .map(|ext| ext.eq_ignore_ascii_case("svg"))
+                    .unwrap_or(false);
+            }
+        }
+        false
+    }
+
+    fn get_svg_path<C>(editor: &Entity<Editor>, cx: &C) -> Option<PathBuf>
+    where
+        C: std::borrow::Borrow<App>,
+    {
+        let app = cx.borrow();
+        let buffer = editor.read(app).buffer().read(app).as_singleton()?;
+        let file = buffer.read(app).file()?;
+        let local_file = file.as_local()?;
+        Some(local_file.abs_path(app))
+    }
+}
+
+impl Render for SvgPreviewView {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex()
+            .id("SvgPreview")
+            .key_context("SvgPreview")
+            .track_focus(&self.focus_handle(cx))
+            .size_full()
+            .bg(cx.theme().colors().editor_background)
+            .flex()
+            .justify_center()
+            .items_center()
+            .child(if let Some(svg_path) = &self.svg_path {
+                img(ImageSource::from(svg_path.clone()))
+                    .image_cache(&self.image_cache)
+                    .max_w_full()
+                    .max_h_full()
+                    .with_fallback(|| {
+                        div()
+                            .p_4()
+                            .child("Failed to load SVG file")
+                            .into_any_element()
+                    })
+                    .into_any_element()
+            } else {
+                div().p_4().child("No SVG file selected").into_any_element()
+            })
+    }
+}
+
+impl Focusable for SvgPreviewView {
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl EventEmitter<()> for SvgPreviewView {}
+
+impl Item for SvgPreviewView {
+    type Event = ();
+
+    fn tab_icon(&self, _window: &Window, cx: &App) -> Option<Icon> {
+        // Use the same icon as SVG files in the file tree
+        self.svg_path
+            .as_ref()
+            .and_then(|svg_path| FileIcons::get_icon(svg_path, cx))
+            .map(Icon::from_path)
+            .or_else(|| Some(Icon::new(IconName::Image)))
+    }
+
+    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
+        self.svg_path
+            .as_ref()
+            .and_then(|svg_path| svg_path.file_name())
+            .map(|name| name.to_string_lossy())
+            .map(|name| format!("Preview {}", name).into())
+            .unwrap_or_else(|| "SVG Preview".into())
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        Some("svg preview: open")
+    }
+
+    fn to_item_events(_event: &Self::Event, _f: impl FnMut(workspace::item::ItemEvent)) {}
+}

crates/tab_switcher/Cargo.toml 🔗

@@ -32,9 +32,9 @@ workspace-hack.workspace = true
 [dev-dependencies]
 anyhow.workspace = true
 ctor.workspace = true
-env_logger.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
 serde_json.workspace = true
 theme = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
+zlog.workspace = true

crates/tab_switcher/src/tab_switcher.rs 🔗

@@ -7,7 +7,7 @@ use fuzzy::StringMatchCandidate;
 use gpui::{
     Action, AnyElement, App, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle,
     Focusable, Modifiers, ModifiersChangedEvent, MouseButton, MouseUpEvent, ParentElement, Render,
-    Styled, Task, WeakEntity, Window, actions, impl_actions, rems,
+    Styled, Task, WeakEntity, Window, actions, rems,
 };
 use picker::{Picker, PickerDelegate};
 use project::Project;
@@ -25,14 +25,13 @@ use workspace::{
 
 const PANEL_WIDTH_REMS: f32 = 28.;
 
-#[derive(PartialEq, Clone, Deserialize, JsonSchema, Default)]
+#[derive(PartialEq, Clone, Deserialize, JsonSchema, Default, Action)]
+#[action(namespace = tab_switcher)]
 #[serde(deny_unknown_fields)]
 pub struct Toggle {
     #[serde(default)]
     pub select_last: bool,
 }
-
-impl_actions!(tab_switcher, [Toggle]);
 actions!(tab_switcher, [CloseSelectedItem, ToggleAll]);
 
 pub struct TabSwitcher {
@@ -318,6 +317,7 @@ impl TabSwitcherDelegate {
                 &candidates,
                 &query,
                 true,
+                true,
                 10000,
                 &Default::default(),
                 cx.background_executor().clone(),
@@ -450,7 +450,7 @@ impl PickerDelegate for TabSwitcherDelegate {
     type ListItem = ListItem;
 
     fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
-        Arc::default()
+        "Search all tabs…".into()
     }
 
     fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {

crates/tab_switcher/src/tab_switcher_tests.rs 🔗

@@ -10,9 +10,7 @@ use workspace::{AppState, Workspace};
 
 #[ctor::ctor]
 fn init_logger() {
-    if std::env::var("RUST_LOG").is_ok() {
-        env_logger::init();
-    }
+    zlog::init_test();
 }
 
 #[gpui::test]
@@ -326,7 +324,7 @@ async fn open_buffer(
     workspace: &Entity<Workspace>,
     cx: &mut gpui::VisualTestContext,
 ) -> Box<dyn ItemHandle> {
-    let project = workspace.update(cx, |workspace, _| workspace.project().clone());
+    let project = workspace.read_with(cx, |workspace, _| workspace.project().clone());
     let worktree_id = project.update(cx, |project, cx| {
         let worktree = project.worktrees(cx).last().expect("worktree not found");
         worktree.read(cx).id()

crates/task/Cargo.toml 🔗

@@ -20,6 +20,7 @@ collections.workspace = true
 futures.workspace = true
 gpui.workspace = true
 hex.workspace = true
+log.workspace = true
 parking_lot.workspace = true
 proto.workspace = true
 schemars.workspace = true
@@ -29,8 +30,8 @@ serde_json_lenient.workspace = true
 sha2.workspace = true
 shellexpand.workspace = true
 util.workspace = true
-zed_actions.workspace = true
 workspace-hack.workspace = true
+zed_actions.workspace = true
 
 [dev-dependencies]
 gpui = { workspace = true, features = ["test-support"] }

crates/task/src/adapter_schema.rs 🔗

@@ -0,0 +1,62 @@
+use anyhow::Result;
+use gpui::SharedString;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+
+/// Represents a schema for a specific adapter
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+pub struct AdapterSchema {
+    /// The adapter name identifier
+    pub adapter: SharedString,
+    /// The JSON schema for this adapter's configuration
+    pub schema: serde_json::Value,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(transparent)]
+pub struct AdapterSchemas(pub Vec<AdapterSchema>);
+
+impl AdapterSchemas {
+    pub fn generate_json_schema(&self) -> Result<serde_json_lenient::Value> {
+        let adapter_conditions = self
+            .0
+            .iter()
+            .map(|adapter_schema| {
+                let adapter_name = adapter_schema.adapter.to_string();
+                json!({
+                    "if": {
+                        "properties": {
+                            "adapter": { "const": adapter_name }
+                        }
+                    },
+                    "then": adapter_schema.schema
+                })
+            })
+            .collect::<Vec<_>>();
+
+        let schema = serde_json_lenient::json!({
+            "$schema": "http://json-schema.org/draft-07/schema#",
+            "title": "Debug Adapter Configurations",
+            "description": "Configuration for debug adapters. Schema changes based on the selected adapter.",
+            "type": "array",
+            "items": {
+                "type": "object",
+                "required": ["adapter", "label"],
+                "properties": {
+                    "adapter": {
+                        "type": "string",
+                        "description": "The name of the debug adapter"
+                    },
+                    "label": {
+                        "type": "string",
+                        "description": "The name of the debug configuration"
+                    },
+                },
+                "allOf": adapter_conditions
+            }
+        });
+
+        Ok(serde_json_lenient::to_value(schema)?)
+    }
+}

crates/task/src/debug_format.rs 🔗

@@ -1,12 +1,14 @@
-use anyhow::Result;
+use anyhow::{Context as _, Result};
 use collections::FxHashMap;
 use gpui::SharedString;
-use schemars::{JsonSchema, r#gen::SchemaSettings};
+use log as _;
+use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
+use std::net::Ipv4Addr;
 use std::path::PathBuf;
-use std::{net::Ipv4Addr, path::Path};
+use util::debug_panic;
 
-use crate::TaskTemplate;
+use crate::{TaskTemplate, adapter_schema::AdapterSchemas};
 
 /// Represents the host information of the debug adapter
 #[derive(Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)]
@@ -93,9 +95,20 @@ pub struct LaunchRequest {
     pub env: FxHashMap<String, String>,
 }
 
+impl LaunchRequest {
+    pub fn env_json(&self) -> serde_json::Value {
+        serde_json::Value::Object(
+            self.env
+                .iter()
+                .map(|(k, v)| (k.clone(), v.to_owned().into()))
+                .collect::<serde_json::Map<String, serde_json::Value>>(),
+        )
+    }
+}
+
 /// Represents the type that will determine which request to call on the debug adapter
 #[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)]
-#[serde(rename_all = "lowercase", untagged)]
+#[serde(rename_all = "lowercase", tag = "request")]
 pub enum DebugRequest {
     /// Call the `launch` request on the debug adapter
     Launch(LaunchRequest),
@@ -136,9 +149,7 @@ impl DebugRequest {
     }
 
     pub fn from_proto(val: proto::DebugRequest) -> Result<DebugRequest> {
-        let request = val
-            .request
-            .ok_or_else(|| anyhow::anyhow!("Missing debug request"))?;
+        let request = val.request.context("Missing debug request")?;
         match request {
             proto::debug_request::Request::DebugLaunchRequest(proto::DebugLaunchRequest {
                 program,
@@ -173,9 +184,8 @@ impl From<AttachRequest> for DebugRequest {
     }
 }
 
-#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)]
+#[derive(Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)]
 #[serde(untagged)]
-#[allow(clippy::large_enum_variant)]
 pub enum BuildTaskDefinition {
     ByName(SharedString),
     Template {
@@ -185,8 +195,71 @@ pub enum BuildTaskDefinition {
         locator_name: Option<SharedString>,
     },
 }
+
+impl<'de> Deserialize<'de> for BuildTaskDefinition {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        #[derive(Deserialize)]
+        struct TemplateHelper {
+            #[serde(default)]
+            label: Option<String>,
+            #[serde(flatten)]
+            rest: serde_json::Value,
+        }
+
+        let value = serde_json::Value::deserialize(deserializer)?;
+
+        if let Ok(name) = serde_json::from_value::<SharedString>(value.clone()) {
+            return Ok(BuildTaskDefinition::ByName(name));
+        }
+
+        let helper: TemplateHelper =
+            serde_json::from_value(value).map_err(serde::de::Error::custom)?;
+
+        let mut template_value = helper.rest;
+        if let serde_json::Value::Object(ref mut map) = template_value {
+            map.insert(
+                "label".to_string(),
+                serde_json::to_value(helper.label.unwrap_or_else(|| "debug-build".to_owned()))
+                    .map_err(serde::de::Error::custom)?,
+            );
+        }
+
+        let task_template: TaskTemplate =
+            serde_json::from_value(template_value).map_err(serde::de::Error::custom)?;
+
+        Ok(BuildTaskDefinition::Template {
+            task_template,
+            locator_name: None,
+        })
+    }
+}
+
+#[derive(Deserialize, Serialize, PartialEq, Eq, Clone, Debug, JsonSchema)]
+pub enum Request {
+    Launch,
+    Attach,
+}
+
+/// This struct represent a user created debug task from the new session modal
+#[derive(Deserialize, Serialize, PartialEq, Eq, Clone, Debug, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub struct ZedDebugConfig {
+    /// Name of the debug task
+    pub label: SharedString,
+    /// The debug adapter to use
+    pub adapter: SharedString,
+    #[serde(flatten)]
+    pub request: DebugRequest,
+    /// Whether to tell the debug adapter to stop on entry
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub stop_on_entry: Option<bool>,
+}
+
 /// This struct represent a user created debug task
-#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)]
+#[derive(Deserialize, Serialize, PartialEq, Eq, Clone, Debug, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub struct DebugScenario {
     pub adapter: SharedString,
@@ -195,11 +268,9 @@ pub struct DebugScenario {
     /// A task to run prior to spawning the debuggee.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub build: Option<BuildTaskDefinition>,
-    #[serde(flatten)]
-    pub request: Option<DebugRequest>,
-    /// Additional initialization arguments to be sent on DAP initialization
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub initialize_args: Option<serde_json::Value>,
+    /// The main arguments to be sent to the debug adapter
+    #[serde(default, flatten)]
+    pub config: serde_json::Value,
     /// Optional TCP connection information
     ///
     /// If provided, this will be used to connect to the debug adapter instead of
@@ -207,19 +278,6 @@ pub struct DebugScenario {
     /// that is already running or is started by another process.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub tcp_connection: Option<TcpArgumentsTemplate>,
-    /// Whether to tell the debug adapter to stop on entry
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub stop_on_entry: Option<bool>,
-}
-
-impl DebugScenario {
-    pub fn cwd(&self) -> Option<&Path> {
-        if let Some(DebugRequest::Launch(config)) = &self.request {
-            config.cwd.as_ref().map(Path::new)
-        } else {
-            None
-        }
-    }
 }
 
 /// A group of Debug Tasks defined in a JSON file.
@@ -228,32 +286,128 @@ impl DebugScenario {
 pub struct DebugTaskFile(pub Vec<DebugScenario>);
 
 impl DebugTaskFile {
-    /// Generates JSON schema of Tasks JSON template format.
-    pub fn generate_json_schema() -> serde_json_lenient::Value {
-        let schema = SchemaSettings::draft07()
-            .with(|settings| settings.option_add_null_type = false)
-            .into_generator()
-            .into_root_schema_for::<Self>();
-
-        serde_json_lenient::to_value(schema).unwrap()
+    pub fn generate_json_schema(schemas: &AdapterSchemas) -> serde_json_lenient::Value {
+        let build_task_schema = schemars::schema_for!(BuildTaskDefinition);
+        let mut build_task_value =
+            serde_json_lenient::to_value(&build_task_schema).unwrap_or_default();
+
+        if let Some(template_object) = build_task_value
+            .get_mut("anyOf")
+            .and_then(|array| array.as_array_mut())
+            .and_then(|array| array.get_mut(1))
+        {
+            if let Some(properties) = template_object
+                .get_mut("properties")
+                .and_then(|value| value.as_object_mut())
+            {
+                properties.remove("label");
+            }
+
+            if let Some(arr) = template_object
+                .get_mut("required")
+                .and_then(|array| array.as_array_mut())
+            {
+                arr.retain(|v| v.as_str() != Some("label"));
+            }
+        } else {
+            debug_panic!("Task Template schema in debug scenario's needs to be updated");
+        }
+
+        let task_definitions = build_task_value
+            .get("definitions")
+            .cloned()
+            .unwrap_or_default();
+
+        let adapter_conditions = schemas
+            .0
+            .iter()
+            .map(|adapter_schema| {
+                let adapter_name = adapter_schema.adapter.to_string();
+                serde_json::json!({
+                    "if": {
+                        "properties": {
+                            "adapter": { "const": adapter_name }
+                        }
+                    },
+                    "then": adapter_schema.schema
+                })
+            })
+            .collect::<Vec<_>>();
+
+        serde_json_lenient::json!({
+            "$schema": "http://json-schema.org/draft-07/schema#",
+            "title": "Debug Configurations",
+            "description": "Configuration for debug scenarios",
+            "type": "array",
+            "items": {
+                "type": "object",
+                "required": ["adapter", "label"],
+                "properties": {
+                    "adapter": {
+                        "type": "string",
+                        "description": "The name of the debug adapter"
+                    },
+                    "label": {
+                        "type": "string",
+                        "description": "The name of the debug configuration"
+                    },
+                    "build": build_task_value,
+                    "tcp_connection": {
+                        "type": "object",
+                        "description": "Optional TCP connection information for connecting to an already running debug adapter",
+                        "properties": {
+                            "port": {
+                                "type": "integer",
+                                "description": "The port that the debug adapter is listening on (default: auto-find open port)"
+                            },
+                            "host": {
+                                "type": "string",
+                                "pattern": "^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}$",
+                                "description": "The host that the debug adapter is listening to (default: 127.0.0.1)"
+                            },
+                            "timeout": {
+                                "type": "integer",
+                                "description": "The max amount of time in milliseconds to connect to a tcp DAP before returning an error (default: 2000ms)"
+                            }
+                        }
+                    }
+                },
+                "allOf": adapter_conditions
+            },
+            "definitions": task_definitions
+        })
     }
 }
 
 #[cfg(test)]
 mod tests {
-    use crate::{DebugRequest, DebugScenario, LaunchRequest};
+    use crate::DebugScenario;
+    use serde_json::json;
 
     #[test]
-    fn test_can_deserialize_non_attach_task() {
-        let deserialized: DebugRequest =
-            serde_json::from_str(r#"{"program": "cafebabe"}"#).unwrap();
-        assert_eq!(
-            deserialized,
-            DebugRequest::Launch(LaunchRequest {
-                program: "cafebabe".to_owned(),
-                ..Default::default()
-            })
-        );
+    fn test_just_build_args() {
+        let json = r#"{
+            "label": "Build & debug rust",
+            "adapter": "CodeLLDB",
+            "build": {
+                "command": "rust",
+                "args": ["build"]
+            }
+        }"#;
+
+        let deserialized: DebugScenario = serde_json::from_str(json).unwrap();
+        assert!(deserialized.build.is_some());
+        match deserialized.build.as_ref().unwrap() {
+            crate::BuildTaskDefinition::Template { task_template, .. } => {
+                assert_eq!("debug-build", task_template.label);
+                assert_eq!("rust", task_template.command);
+                assert_eq!(vec!["build"], task_template.args);
+            }
+            _ => panic!("Expected Template variant"),
+        }
+        assert_eq!(json!({}), deserialized.config);
+        assert_eq!("CodeLLDB", deserialized.adapter.as_ref());
+        assert_eq!("Build & debug rust", deserialized.label.as_ref());
     }
 
     #[test]
@@ -265,7 +419,10 @@ mod tests {
         }"#;
 
         let deserialized: DebugScenario = serde_json::from_str(json).unwrap();
-        assert_eq!(deserialized.request, None);
+
+        assert_eq!(json!({}), deserialized.config);
+        assert_eq!("CodeLLDB", deserialized.adapter.as_ref());
+        assert_eq!("Build & debug rust", deserialized.label.as_ref());
     }
 
     #[test]
@@ -273,18 +430,19 @@ mod tests {
         let json = r#"{
             "label": "Launch program",
             "adapter": "CodeLLDB",
+            "request": "launch",
             "program": "target/debug/myapp",
             "args": ["--test"]
         }"#;
 
         let deserialized: DebugScenario = serde_json::from_str(json).unwrap();
-        match deserialized.request {
-            Some(DebugRequest::Launch(launch)) => {
-                assert_eq!(launch.program, "target/debug/myapp");
-                assert_eq!(launch.args, vec!["--test"]);
-            }
-            _ => panic!("Expected Launch request"),
-        }
+
+        assert_eq!(
+            json!({ "request": "launch", "program": "target/debug/myapp", "args": ["--test"] }),
+            deserialized.config
+        );
+        assert_eq!("CodeLLDB", deserialized.adapter.as_ref());
+        assert_eq!("Launch program", deserialized.label.as_ref());
     }
 
     #[test]
@@ -292,15 +450,58 @@ mod tests {
         let json = r#"{
             "label": "Attach to process",
             "adapter": "CodeLLDB",
-            "process_id": 1234
+            "process_id": 1234,
+            "request": "attach"
         }"#;
 
         let deserialized: DebugScenario = serde_json::from_str(json).unwrap();
-        match deserialized.request {
-            Some(DebugRequest::Attach(attach)) => {
-                assert_eq!(attach.process_id, Some(1234));
+
+        assert_eq!(
+            json!({ "request": "attach", "process_id": 1234 }),
+            deserialized.config
+        );
+        assert_eq!("CodeLLDB", deserialized.adapter.as_ref());
+        assert_eq!("Attach to process", deserialized.label.as_ref());
+    }
+
+    #[test]
+    fn test_build_task_definition_without_label() {
+        use crate::BuildTaskDefinition;
+
+        let json = r#""my_build_task""#;
+        let deserialized: BuildTaskDefinition = serde_json::from_str(json).unwrap();
+        match deserialized {
+            BuildTaskDefinition::ByName(name) => assert_eq!("my_build_task", name.as_ref()),
+            _ => panic!("Expected ByName variant"),
+        }
+
+        let json = r#"{
+            "command": "cargo",
+            "args": ["build", "--release"]
+        }"#;
+        let deserialized: BuildTaskDefinition = serde_json::from_str(json).unwrap();
+        match deserialized {
+            BuildTaskDefinition::Template { task_template, .. } => {
+                assert_eq!("debug-build", task_template.label);
+                assert_eq!("cargo", task_template.command);
+                assert_eq!(vec!["build", "--release"], task_template.args);
+            }
+            _ => panic!("Expected Template variant"),
+        }
+
+        let json = r#"{
+            "label": "Build Release",
+            "command": "cargo",
+            "args": ["build", "--release"]
+        }"#;
+        let deserialized: BuildTaskDefinition = serde_json::from_str(json).unwrap();
+        match deserialized {
+            BuildTaskDefinition::Template { task_template, .. } => {
+                assert_eq!("Build Release", task_template.label);
+                assert_eq!("cargo", task_template.command);
+                assert_eq!(vec!["build", "--release"], task_template.args);
             }
-            _ => panic!("Expected Attach request"),
+            _ => panic!("Expected Template variant"),
         }
     }
 }

crates/task/src/lib.rs 🔗

@@ -1,5 +1,6 @@
 //! Baseline interface of Tasks in Zed: all tasks in Zed are intended to use those for implementing their own logic.
 
+mod adapter_schema;
 mod debug_format;
 mod serde_helpers;
 pub mod static_source;
@@ -15,14 +16,14 @@ use std::borrow::Cow;
 use std::path::PathBuf;
 use std::str::FromStr;
 
+pub use adapter_schema::{AdapterSchema, AdapterSchemas};
 pub use debug_format::{
     AttachRequest, BuildTaskDefinition, DebugRequest, DebugScenario, DebugTaskFile, LaunchRequest,
-    TcpArgumentsTemplate,
+    Request, TcpArgumentsTemplate, ZedDebugConfig,
 };
 pub use task_template::{
     DebugArgsRequest, HideStrategy, RevealStrategy, TaskTemplate, TaskTemplates,
-    substitute_all_template_variables_in_str, substitute_variables_in_map,
-    substitute_variables_in_str,
+    substitute_variables_in_map, substitute_variables_in_str,
 };
 pub use vscode_debug_format::VsCodeDebugTaskFile;
 pub use vscode_format::VsCodeTaskFile;
@@ -150,6 +151,8 @@ pub enum VariableName {
     File,
     /// A path of the currently opened file (relative to worktree root).
     RelativeFile,
+    /// A path of the currently opened file's directory (relative to worktree root).
+    RelativeDir,
     /// The currently opened filename.
     Filename,
     /// The path to a parent directory of a currently opened file.
@@ -193,6 +196,7 @@ impl FromStr for VariableName {
             "FILE" => Self::File,
             "FILENAME" => Self::Filename,
             "RELATIVE_FILE" => Self::RelativeFile,
+            "RELATIVE_DIR" => Self::RelativeDir,
             "DIRNAME" => Self::Dirname,
             "STEM" => Self::Stem,
             "WORKTREE_ROOT" => Self::WorktreeRoot,
@@ -225,6 +229,7 @@ impl std::fmt::Display for VariableName {
             Self::File => write!(f, "{ZED_VARIABLE_NAME_PREFIX}FILE"),
             Self::Filename => write!(f, "{ZED_VARIABLE_NAME_PREFIX}FILENAME"),
             Self::RelativeFile => write!(f, "{ZED_VARIABLE_NAME_PREFIX}RELATIVE_FILE"),
+            Self::RelativeDir => write!(f, "{ZED_VARIABLE_NAME_PREFIX}RELATIVE_DIR"),
             Self::Dirname => write!(f, "{ZED_VARIABLE_NAME_PREFIX}DIRNAME"),
             Self::Stem => write!(f, "{ZED_VARIABLE_NAME_PREFIX}STEM"),
             Self::WorktreeRoot => write!(f, "{ZED_VARIABLE_NAME_PREFIX}WORKTREE_ROOT"),
@@ -525,6 +530,21 @@ impl EnvVariableReplacer {
     fn new(variables: HashMap<VsCodeEnvVariable, ZedEnvVariable>) -> Self {
         Self { variables }
     }
+
+    fn replace_value(&self, input: serde_json::Value) -> serde_json::Value {
+        match input {
+            serde_json::Value::String(s) => serde_json::Value::String(self.replace(&s)),
+            serde_json::Value::Array(arr) => {
+                serde_json::Value::Array(arr.into_iter().map(|v| self.replace_value(v)).collect())
+            }
+            serde_json::Value::Object(obj) => serde_json::Value::Object(
+                obj.into_iter()
+                    .map(|(k, v)| (self.replace(&k), self.replace_value(v)))
+                    .collect(),
+            ),
+            _ => input,
+        }
+    }
     // Replaces occurrences of VsCode-specific environment variables with Zed equivalents.
     fn replace(&self, input: &str) -> String {
         shellexpand::env_with_context_no_errors(&input, |var: &str| {

crates/task/src/task_template.rs 🔗

@@ -1,4 +1,4 @@
-use anyhow::{Context, bail};
+use anyhow::{Context as _, bail};
 use collections::{HashMap, HashSet};
 use schemars::{JsonSchema, r#gen::SchemaSettings};
 use serde::{Deserialize, Serialize};
@@ -315,7 +315,7 @@ pub fn substitute_variables_in_str(template_str: &str, context: &TaskContext) ->
         &mut substituted_variables,
     )
 }
-pub fn substitute_all_template_variables_in_str<A: AsRef<str>>(
+fn substitute_all_template_variables_in_str<A: AsRef<str>>(
     template_str: &str,
     task_variables: &HashMap<String, A>,
     variable_names: &HashMap<String, VariableName>,

crates/task/src/vscode_debug_format.rs 🔗

@@ -1,14 +1,9 @@
-use std::path::PathBuf;
-
-use anyhow::anyhow;
 use collections::HashMap;
-use gpui::SharedString;
 use serde::Deserialize;
 use util::ResultExt as _;
 
 use crate::{
-    AttachRequest, DebugRequest, DebugScenario, DebugTaskFile, EnvVariableReplacer, LaunchRequest,
-    TcpArgumentsTemplate, VariableName,
+    DebugScenario, DebugTaskFile, EnvVariableReplacer, TcpArgumentsTemplate, VariableName,
 };
 
 #[derive(Clone, Debug, Deserialize, PartialEq)]
@@ -24,69 +19,35 @@ enum Request {
 struct VsCodeDebugTaskDefinition {
     r#type: String,
     name: String,
-    request: Request,
-
-    #[serde(default)]
-    program: Option<String>,
-    #[serde(default)]
-    args: Vec<String>,
-    #[serde(default)]
-    env: HashMap<String, Option<String>>,
-    // TODO envFile?
-    #[serde(default)]
-    cwd: Option<String>,
     #[serde(default)]
     port: Option<u16>,
-    #[serde(default)]
-    stop_on_entry: Option<bool>,
     #[serde(flatten)]
-    other_attributes: HashMap<String, serde_json_lenient::Value>,
+    other_attributes: serde_json::Value,
 }
 
 impl VsCodeDebugTaskDefinition {
-    fn try_to_zed(self, replacer: &EnvVariableReplacer) -> anyhow::Result<DebugScenario> {
-        let label = replacer.replace(&self.name).into();
-        // TODO based on grep.app results it seems that vscode supports whitespace-splitting this field (ugh)
+    fn try_to_zed(mut self, replacer: &EnvVariableReplacer) -> anyhow::Result<DebugScenario> {
+        let label = replacer.replace(&self.name);
+        let mut config = replacer.replace_value(self.other_attributes);
+        let adapter = task_type_to_adapter_name(&self.r#type);
+        if let Some(config) = config.as_object_mut() {
+            if adapter == "JavaScript" {
+                config.insert("type".to_owned(), self.r#type.clone().into());
+                if let Some(port) = self.port.take() {
+                    config.insert("port".to_owned(), port.into());
+                }
+            }
+        }
         let definition = DebugScenario {
-            label,
+            label: label.into(),
             build: None,
-            request: match self.request {
-                Request::Launch => {
-                    let cwd = self.cwd.map(|cwd| PathBuf::from(replacer.replace(&cwd)));
-                    let program = self.program.ok_or_else(|| {
-                        anyhow!("vscode debug launch configuration does not define a program")
-                    })?;
-                    let program = replacer.replace(&program);
-                    let args = self
-                        .args
-                        .into_iter()
-                        .map(|arg| replacer.replace(&arg))
-                        .collect();
-                    let env = self
-                        .env
-                        .into_iter()
-                        .filter_map(|(k, v)| v.map(|v| (k, v)))
-                        .collect();
-                    DebugRequest::Launch(LaunchRequest {
-                        program,
-                        cwd,
-                        args,
-                        env,
-                    })
-                    .into()
-                }
-                Request::Attach => DebugRequest::Attach(AttachRequest { process_id: None }).into(),
-            },
-            adapter: task_type_to_adapter_name(&self.r#type),
-            // TODO host?
+            adapter: adapter.into(),
             tcp_connection: self.port.map(|port| TcpArgumentsTemplate {
                 port: Some(port),
                 host: None,
                 timeout: None,
             }),
-            stop_on_entry: self.stop_on_entry,
-            // TODO
-            initialize_args: None,
+            config,
         };
         Ok(definition)
     }
@@ -95,7 +56,8 @@ impl VsCodeDebugTaskDefinition {
 #[derive(Clone, Debug, Deserialize, PartialEq)]
 #[serde(rename_all = "camelCase")]
 pub struct VsCodeDebugTaskFile {
-    version: String,
+    #[serde(default)]
+    version: Option<String>,
     configurations: Vec<VsCodeDebugTaskDefinition>,
 }
 
@@ -108,7 +70,11 @@ impl TryFrom<VsCodeDebugTaskFile> for DebugTaskFile {
                 "workspaceFolder".to_owned(),
                 VariableName::WorktreeRoot.to_string(),
             ),
-            // TODO other interesting variables?
+            (
+                "relativeFile".to_owned(),
+                VariableName::RelativeFile.to_string(),
+            ),
+            ("file".to_owned(), VariableName::File.to_string()),
         ]));
         let templates = file
             .configurations
@@ -119,26 +85,25 @@ impl TryFrom<VsCodeDebugTaskFile> for DebugTaskFile {
     }
 }
 
-// todo(debugger) figure out how to make JsDebugAdapter::ADAPTER_NAME et al available here
-fn task_type_to_adapter_name(task_type: &str) -> SharedString {
+fn task_type_to_adapter_name(task_type: &str) -> String {
     match task_type {
-        "node" => "JavaScript",
+        "pwa-node" | "node" | "node-terminal" | "chrome" | "pwa-chrome" | "edge" | "pwa-edge"
+        | "msedge" | "pwa-msedge" => "JavaScript",
         "go" => "Delve",
         "php" => "PHP",
         "cppdbg" | "lldb" => "CodeLLDB",
         "debugpy" => "Debugpy",
+        "rdbg" => "Ruby",
         _ => task_type,
     }
     .to_owned()
-    .into()
 }
 
 #[cfg(test)]
 mod tests {
+    use serde_json::json;
 
-    use collections::FxHashMap;
-
-    use crate::{DebugRequest, DebugScenario, DebugTaskFile, LaunchRequest, TcpArgumentsTemplate};
+    use crate::{DebugScenario, DebugTaskFile};
 
     use super::VsCodeDebugTaskFile;
 
@@ -173,19 +138,23 @@ mod tests {
             DebugTaskFile(vec![DebugScenario {
                 label: "Debug my JS app".into(),
                 adapter: "JavaScript".into(),
-                stop_on_entry: Some(true),
-                initialize_args: None,
-                tcp_connection: Some(TcpArgumentsTemplate {
-                    port: Some(17),
-                    host: None,
-                    timeout: None,
+                config: json!({
+                    "request": "launch",
+                    "program": "${ZED_WORKTREE_ROOT}/xyz.js",
+                    "showDevDebugOutput": false,
+                    "stopOnEntry": true,
+                    "args": [
+                        "--foo",
+                        "${ZED_WORKTREE_ROOT}/thing",
+                    ],
+                    "cwd": "${ZED_WORKTREE_ROOT}/${FOO}/sub",
+                    "env": {
+                        "X": "Y",
+                    },
+                    "type": "node",
+                    "port": 17,
                 }),
-                request: Some(DebugRequest::Launch(LaunchRequest {
-                    program: "${ZED_WORKTREE_ROOT}/xyz.js".into(),
-                    args: vec!["--foo".into(), "${ZED_WORKTREE_ROOT}/thing".into()],
-                    cwd: Some("${ZED_WORKTREE_ROOT}/${FOO}/sub".into()),
-                    env: FxHashMap::from_iter([("X".into(), "Y".into())])
-                })),
+                tcp_connection: None,
                 build: None
             }])
         );

crates/task/src/vscode_format.rs 🔗

@@ -42,9 +42,13 @@ enum Command {
 }
 
 impl VsCodeTaskDefinition {
-    fn into_zed_format(self, replacer: &EnvVariableReplacer) -> anyhow::Result<TaskTemplate> {
+    fn into_zed_format(
+        self,
+        replacer: &EnvVariableReplacer,
+    ) -> anyhow::Result<Option<TaskTemplate>> {
         if self.other_attributes.contains_key("dependsOn") {
-            bail!("Encountered unsupported `dependsOn` key during deserialization");
+            log::warn!("Skipping deserializing of a task with the unsupported `dependsOn` key");
+            return Ok(None);
         }
         // `type` might not be set in e.g. tasks that use `dependsOn`; we still want to deserialize the whole object though (hence command is an Option),
         // as that way we can provide more specific description of why deserialization failed.
@@ -62,17 +66,17 @@ impl VsCodeTaskDefinition {
         // Per VSC docs, only `command`, `args` and `options` support variable substitution.
         let command = replacer.replace(&command);
         let args = args.into_iter().map(|arg| replacer.replace(&arg)).collect();
-        let mut ret = TaskTemplate {
+        let mut template = TaskTemplate {
             label: self.label,
             command,
             args,
-            ..Default::default()
+            ..TaskTemplate::default()
         };
         if let Some(options) = self.options {
-            ret.cwd = options.cwd.map(|cwd| replacer.replace(&cwd));
-            ret.env = options.env;
+            template.cwd = options.cwd.map(|cwd| replacer.replace(&cwd));
+            template.env = options.env;
         }
-        Ok(ret)
+        Ok(Some(template))
     }
 }
 
@@ -101,7 +105,12 @@ impl TryFrom<VsCodeTaskFile> for TaskTemplates {
         let templates = value
             .tasks
             .into_iter()
-            .filter_map(|vscode_definition| vscode_definition.into_zed_format(&replacer).log_err())
+            .filter_map(|vscode_definition| {
+                vscode_definition
+                    .into_zed_format(&replacer)
+                    .log_err()
+                    .flatten()
+            })
             .collect();
         Ok(Self(templates))
     }

crates/tasks_ui/src/modal.rs 🔗

@@ -1,6 +1,7 @@
 use std::sync::Arc;
 
 use crate::TaskContexts;
+use editor::Editor;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
     Action, AnyElement, App, AppContext as _, Context, DismissEvent, Entity, EventEmitter,
@@ -12,9 +13,9 @@ use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMa
 use project::{TaskSourceKind, task_store::TaskStore};
 use task::{DebugScenario, ResolvedTask, RevealTarget, TaskContext, TaskTemplate};
 use ui::{
-    ActiveTheme, Button, ButtonCommon, ButtonSize, Clickable, Color, FluentBuilder as _, Icon,
-    IconButton, IconButtonShape, IconName, IconSize, IntoElement, KeyBinding, Label, LabelSize,
-    ListItem, ListItemSpacing, RenderOnce, Toggleable, Tooltip, div, h_flex, v_flex,
+    ActiveTheme, Clickable, FluentBuilder as _, IconButtonShape, IconWithIndicator, Indicator,
+    IntoElement, KeyBinding, ListItem, ListItemSpacing, RenderOnce, Toggleable, Tooltip, div,
+    prelude::*,
 };
 
 use util::{ResultExt, truncate_and_trailoff};
@@ -22,7 +23,7 @@ use workspace::{ModalView, Workspace};
 pub use zed_actions::{Rerun, Spawn};
 
 /// A modal used to spawn new tasks.
-pub(crate) struct TasksModalDelegate {
+pub struct TasksModalDelegate {
     task_store: Entity<TaskStore>,
     candidates: Option<Vec<(TaskSourceKind, ResolvedTask)>>,
     task_overrides: Option<TaskOverrides>,
@@ -32,21 +33,21 @@ pub(crate) struct TasksModalDelegate {
     selected_index: usize,
     workspace: WeakEntity<Workspace>,
     prompt: String,
-    task_contexts: TaskContexts,
+    task_contexts: Arc<TaskContexts>,
     placeholder_text: Arc<str>,
 }
 
 /// Task template amendments to do before resolving the context.
 #[derive(Clone, Debug, Default, PartialEq, Eq)]
-pub(crate) struct TaskOverrides {
+pub struct TaskOverrides {
     /// See [`RevealTarget`].
-    pub(crate) reveal_target: Option<RevealTarget>,
+    pub reveal_target: Option<RevealTarget>,
 }
 
 impl TasksModalDelegate {
     fn new(
         task_store: Entity<TaskStore>,
-        task_contexts: TaskContexts,
+        task_contexts: Arc<TaskContexts>,
         task_overrides: Option<TaskOverrides>,
         workspace: WeakEntity<Workspace>,
     ) -> Self {
@@ -122,15 +123,16 @@ impl TasksModalDelegate {
 }
 
 pub struct TasksModal {
-    picker: Entity<Picker<TasksModalDelegate>>,
+    pub picker: Entity<Picker<TasksModalDelegate>>,
     _subscription: [Subscription; 2],
 }
 
 impl TasksModal {
-    pub(crate) fn new(
+    pub fn new(
         task_store: Entity<TaskStore>,
-        task_contexts: TaskContexts,
+        task_contexts: Arc<TaskContexts>,
         task_overrides: Option<TaskOverrides>,
+        is_modal: bool,
         workspace: WeakEntity<Workspace>,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -141,6 +143,7 @@ impl TasksModal {
                 window,
                 cx,
             )
+            .modal(is_modal)
         });
         let _subscription = [
             cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| {
@@ -157,6 +160,53 @@ impl TasksModal {
             _subscription,
         }
     }
+
+    pub fn tasks_loaded(
+        &mut self,
+        task_contexts: Arc<TaskContexts>,
+        lsp_tasks: Vec<(TaskSourceKind, task::ResolvedTask)>,
+        used_tasks: Vec<(TaskSourceKind, task::ResolvedTask)>,
+        current_resolved_tasks: Vec<(TaskSourceKind, task::ResolvedTask)>,
+        add_current_language_tasks: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let last_used_candidate_index = if used_tasks.is_empty() {
+            None
+        } else {
+            Some(used_tasks.len() - 1)
+        };
+        let mut new_candidates = used_tasks;
+        new_candidates.extend(lsp_tasks);
+        let hide_vscode = current_resolved_tasks.iter().any(|(kind, _)| match kind {
+            TaskSourceKind::Worktree {
+                id: _,
+                directory_in_worktree: dir,
+                id_base: _,
+            } => dir.ends_with(".zed"),
+            _ => false,
+        });
+        // todo(debugger): We're always adding lsp tasks here even if prefer_lsp is false
+        // We should move the filter to new_candidates instead of on current
+        // and add a test for this
+        new_candidates.extend(current_resolved_tasks.into_iter().filter(|(task_kind, _)| {
+            match task_kind {
+                TaskSourceKind::Worktree {
+                    directory_in_worktree: dir,
+                    ..
+                } => !(hide_vscode && dir.ends_with(".vscode")),
+                TaskSourceKind::Language { .. } => add_current_language_tasks,
+                _ => true,
+            }
+        }));
+        self.picker.update(cx, |picker, cx| {
+            picker.delegate.task_contexts = task_contexts;
+            picker.delegate.last_used_candidate_index = last_used_candidate_index;
+            picker.delegate.candidates = Some(new_candidates);
+            picker.refresh(window, cx);
+            cx.notify();
+        })
+    }
 }
 
 impl Render for TasksModal {
@@ -224,21 +274,35 @@ impl PickerDelegate for TasksModalDelegate {
             Some(candidates) => Task::ready(string_match_candidates(candidates)),
             None => {
                 if let Some(task_inventory) = self.task_store.read(cx).task_inventory().cloned() {
-                    let (used, current) = task_inventory
-                        .read(cx)
-                        .used_and_current_resolved_tasks(&self.task_contexts, cx);
+                    let task_list = task_inventory.update(cx, |this, cx| {
+                        this.used_and_current_resolved_tasks(self.task_contexts.clone(), cx)
+                    });
                     let workspace = self.workspace.clone();
                     let lsp_task_sources = self.task_contexts.lsp_task_sources.clone();
                     let task_position = self.task_contexts.latest_selection;
-
                     cx.spawn(async move |picker, cx| {
-                        let Ok(lsp_tasks) = workspace.update(cx, |workspace, cx| {
-                            editor::lsp_tasks(
+                        let (used, current) = task_list.await;
+                        let Ok((lsp_tasks, prefer_lsp)) = workspace.update(cx, |workspace, cx| {
+                            let lsp_tasks = editor::lsp_tasks(
                                 workspace.project().clone(),
                                 &lsp_task_sources,
                                 task_position,
                                 cx,
-                            )
+                            );
+                            let prefer_lsp = workspace
+                                .active_item(cx)
+                                .and_then(|item| item.downcast::<Editor>())
+                                .map(|editor| {
+                                    editor
+                                        .read(cx)
+                                        .buffer()
+                                        .read(cx)
+                                        .language_settings(cx)
+                                        .tasks
+                                        .prefer_lsp
+                                })
+                                .unwrap_or(false);
+                            (lsp_tasks, prefer_lsp)
                         }) else {
                             return Vec::new();
                         };
@@ -253,6 +317,8 @@ impl PickerDelegate for TasksModalDelegate {
                                 };
 
                                 let mut new_candidates = used;
+                                let add_current_language_tasks =
+                                    !prefer_lsp || lsp_tasks.is_empty();
                                 new_candidates.extend(lsp_tasks.into_iter().flat_map(
                                     |(kind, tasks_with_locations)| {
                                         tasks_with_locations
@@ -263,7 +329,15 @@ impl PickerDelegate for TasksModalDelegate {
                                             .map(move |(_, task)| (kind.clone(), task))
                                     },
                                 ));
-                                new_candidates.extend(current);
+                                // todo(debugger): We're always adding lsp tasks here even if prefer_lsp is false
+                                // We should move the filter to new_candidates instead of on current
+                                // and add a test for this
+                                new_candidates.extend(current.into_iter().filter(
+                                    |(task_kind, _)| {
+                                        add_current_language_tasks
+                                            || !matches!(task_kind, TaskSourceKind::Language { .. })
+                                    },
+                                ));
                                 let match_candidates = string_match_candidates(&new_candidates);
                                 let _ = picker.delegate.candidates.insert(new_candidates);
                                 match_candidates
@@ -283,6 +357,7 @@ impl PickerDelegate for TasksModalDelegate {
                 &candidates,
                 &query,
                 true,
+                true,
                 1000,
                 &Default::default(),
                 cx.background_executor().clone(),
@@ -411,15 +486,29 @@ impl PickerDelegate for TasksModalDelegate {
             color: Color::Default,
         };
         let icon = match source_kind {
-            TaskSourceKind::Lsp(..) => Some(Icon::new(IconName::Bolt)),
             TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
             TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
             TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
-            TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
+            TaskSourceKind::Lsp {
+                language_name: name,
+                ..
+            }
+            | TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
                 .get_icon_for_type(&name.to_lowercase(), cx)
                 .map(Icon::from_path),
         }
         .map(|icon| icon.color(Color::Muted).size(IconSize::Small));
+        let indicator = if matches!(source_kind, TaskSourceKind::Lsp { .. }) {
+            Some(Indicator::icon(
+                Icon::new(IconName::Bolt).size(IconSize::Small),
+            ))
+        } else {
+            None
+        };
+        let icon = icon.map(|icon| {
+            IconWithIndicator::new(icon, indicator)
+                .indicator_border_color(Some(cx.theme().colors().border_transparent))
+        });
         let history_run_icon = if Some(ix) <= self.divider_index {
             Some(
                 Icon::new(IconName::HistoryRerun)
@@ -439,7 +528,7 @@ impl PickerDelegate for TasksModalDelegate {
         Some(
             ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
                 .inset(true)
-                .start_slot::<Icon>(icon)
+                .start_slot::<IconWithIndicator>(icon)
                 .end_slot::<AnyElement>(
                     h_flex()
                         .gap_1()
@@ -547,6 +636,7 @@ impl PickerDelegate for TasksModalDelegate {
             Vec::new()
         }
     }
+
     fn render_footer(
         &self,
         window: &mut Window,
@@ -569,11 +659,8 @@ impl PickerDelegate for TasksModalDelegate {
         Some(
             h_flex()
                 .w_full()
-                .h_8()
-                .p_2()
+                .p_1p5()
                 .justify_between()
-                .rounded_b_sm()
-                .bg(cx.theme().colors().ghost_element_selected)
                 .border_t_1()
                 .border_color(cx.theme().colors().border_variant)
                 .child(
@@ -582,7 +669,6 @@ impl PickerDelegate for TasksModalDelegate {
                             let keybind = KeyBinding::for_action(&*action, window, cx);
 
                             Button::new("edit-current-task", label)
-                                .label_size(LabelSize::Small)
                                 .when_some(keybind, |this, keybind| this.key_binding(keybind))
                                 .on_click(move |_, window, cx| {
                                     window.dispatch_action(action.boxed_clone(), cx);
@@ -606,7 +692,6 @@ impl PickerDelegate for TasksModalDelegate {
                             };
 
                             Button::new("spawn-onehshot", spawn_oneshot_label)
-                                .label_size(LabelSize::Small)
                                 .key_binding(keybind)
                                 .on_click(move |_, window, cx| {
                                     window.dispatch_action(action.boxed_clone(), cx)
@@ -621,15 +706,14 @@ impl PickerDelegate for TasksModalDelegate {
                                     } else {
                                         "Spawn Without History"
                                     };
-                                    Button::new("spawn", label)
-                                        .label_size(LabelSize::Small)
-                                        .key_binding(keybind)
-                                        .on_click(move |_, window, cx| {
+                                    Button::new("spawn", label).key_binding(keybind).on_click(
+                                        move |_, window, cx| {
                                             window.dispatch_action(
                                                 menu::SecondaryConfirm.boxed_clone(),
                                                 cx,
                                             )
-                                        })
+                                        },
+                                    )
                                 },
                             ),
                         )
@@ -640,7 +724,6 @@ impl PickerDelegate for TasksModalDelegate {
                                     if is_recent_selected { "Rerun" } else { "Spawn" };
 
                                 Button::new("spawn", run_entry_label)
-                                    .label_size(LabelSize::Small)
                                     .key_binding(keybind)
                                     .on_click(|_, window, cx| {
                                         window.dispatch_action(menu::Confirm.boxed_clone(), cx);
@@ -668,7 +751,7 @@ fn string_match_candidates<'a>(
 mod tests {
     use std::{path::PathBuf, sync::Arc};
 
-    use editor::Editor;
+    use editor::{Editor, SelectionEffects};
     use gpui::{TestAppContext, VisualTestContext};
     use language::{Language, LanguageConfig, LanguageMatcher, Point};
     use project::{ContextProviderWithTasks, FakeFs, Project};
@@ -945,7 +1028,7 @@ mod tests {
             .update(|_window, cx| second_item.act_as::<Editor>(cx))
             .unwrap();
         editor.update_in(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |s| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                 s.select_ranges(Some(Point::new(1, 2)..Point::new(1, 5)))
             })
         });
@@ -1176,7 +1259,7 @@ mod tests {
         scheduled_task_label: &str,
         cx: &mut VisualTestContext,
     ) {
-        let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| {
+        let scheduled_task = tasks_picker.read_with(cx, |tasks_picker, _| {
             tasks_picker
                 .delegate
                 .candidates
@@ -1220,14 +1303,14 @@ mod tests {
         spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
         cx: &mut VisualTestContext,
     ) -> String {
-        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
+        spawn_tasks.read_with(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
     }
 
     fn task_names(
         spawn_tasks: &Entity<Picker<TasksModalDelegate>>,
         cx: &mut VisualTestContext,
     ) -> Vec<String> {
-        spawn_tasks.update(cx, |spawn_tasks, _| {
+        spawn_tasks.read_with(cx, |spawn_tasks, _| {
             spawn_tasks
                 .delegate
                 .matches

crates/tasks_ui/src/tasks_ui.rs 🔗

@@ -1,16 +1,15 @@
-use std::path::Path;
+use std::{path::Path, sync::Arc};
 
 use collections::HashMap;
 use editor::Editor;
 use gpui::{App, AppContext as _, Context, Entity, Task, Window};
-use modal::TaskOverrides;
 use project::{Location, TaskContexts, TaskSourceKind, Worktree};
 use task::{RevealTarget, TaskContext, TaskId, TaskTemplate, TaskVariables, VariableName};
 use workspace::Workspace;
 
 mod modal;
 
-pub use modal::{Rerun, ShowAttachModal, Spawn, TasksModal};
+pub use modal::{Rerun, ShowAttachModal, Spawn, TaskOverrides, TasksModal};
 
 pub fn init(cx: &mut App) {
     cx.observe_new(
@@ -81,7 +80,14 @@ pub fn init(cx: &mut App) {
                             );
                         }
                     } else {
-                        toggle_modal(workspace, None, window, cx).detach();
+                        spawn_task_or_modal(
+                            workspace,
+                            &Spawn::ViaModal {
+                                reveal_target: None,
+                            },
+                            window,
+                            cx,
+                        );
                     };
                 });
         },
@@ -95,6 +101,11 @@ fn spawn_task_or_modal(
     window: &mut Window,
     cx: &mut Context<Workspace>,
 ) {
+    if let Some(provider) = workspace.debugger_provider() {
+        provider.spawn_task_or_modal(workspace, action, window, cx);
+        return;
+    }
+
     match action {
         Spawn::ByName {
             task_name,
@@ -143,7 +154,7 @@ pub fn toggle_modal(
     if can_open_modal {
         let task_contexts = task_contexts(workspace, window, cx);
         cx.spawn_in(window, async move |workspace, cx| {
-            let task_contexts = task_contexts.await;
+            let task_contexts = Arc::new(task_contexts.await);
             workspace
                 .update_in(cx, |workspace, window, cx| {
                     workspace.toggle_modal(window, cx, |window, cx| {
@@ -153,6 +164,7 @@ pub fn toggle_modal(
                             reveal_target.map(|target| TaskOverrides {
                                 reveal_target: Some(target),
                             }),
+                            true,
                             workspace_handle,
                             window,
                             cx,
@@ -166,7 +178,7 @@ pub fn toggle_modal(
     }
 }
 
-fn spawn_tasks_filtered<F>(
+pub fn spawn_tasks_filtered<F>(
     mut predicate: F,
     overrides: Option<TaskOverrides>,
     window: &mut Window,
@@ -180,31 +192,33 @@ where
             task_contexts(workspace, window, cx)
         })?;
         let task_contexts = task_contexts.await;
-        let mut tasks = workspace.update(cx, |workspace, cx| {
-            let Some(task_inventory) = workspace
-                .project()
-                .read(cx)
-                .task_store()
-                .read(cx)
-                .task_inventory()
-                .cloned()
-            else {
-                return Vec::new();
-            };
-            let (file, language) = task_contexts
-                .location()
-                .map(|location| {
-                    let buffer = location.buffer.read(cx);
-                    (
-                        buffer.file().cloned(),
-                        buffer.language_at(location.range.start),
-                    )
-                })
-                .unwrap_or_default();
-            task_inventory
-                .read(cx)
-                .list_tasks(file, language, task_contexts.worktree(), cx)
-        })?;
+        let mut tasks = workspace
+            .update(cx, |workspace, cx| {
+                let Some(task_inventory) = workspace
+                    .project()
+                    .read(cx)
+                    .task_store()
+                    .read(cx)
+                    .task_inventory()
+                    .cloned()
+                else {
+                    return Task::ready(Vec::new());
+                };
+                let (file, language) = task_contexts
+                    .location()
+                    .map(|location| {
+                        let buffer = location.buffer.read(cx);
+                        (
+                            buffer.file().cloned(),
+                            buffer.language_at(location.range.start),
+                        )
+                    })
+                    .unwrap_or_default();
+                task_inventory
+                    .read(cx)
+                    .list_tasks(file, language, task_contexts.worktree(), cx)
+            })?
+            .await;
 
         let did_spawn = workspace
             .update_in(cx, |workspace, window, cx| {
@@ -270,6 +284,12 @@ pub fn task_contexts(
                 .read(cx)
                 .worktree_for_id(*worktree_id, cx)
                 .map_or(false, |worktree| is_visible_directory(&worktree, cx))
+        })
+        .or_else(|| {
+            workspace
+                .visible_worktrees(cx)
+                .next()
+                .map(|tree| tree.read(cx).id())
         });
 
     let active_editor = active_item.and_then(|item| item.act_as::<Editor>(cx));
@@ -300,9 +320,12 @@ pub fn task_contexts(
         .unwrap_or_default();
 
     let latest_selection = active_editor.as_ref().map(|active_editor| {
-        active_editor.update(cx, |editor, _| {
-            editor.selections.newest_anchor().head().text_anchor
-        })
+        active_editor
+            .read(cx)
+            .selections
+            .newest_anchor()
+            .head()
+            .text_anchor
     });
 
     let mut worktree_abs_paths = workspace
@@ -370,14 +393,14 @@ fn worktree_context(worktree_abs_path: &Path) -> TaskContext {
 mod tests {
     use std::{collections::HashMap, sync::Arc};
 
-    use editor::Editor;
+    use editor::{Editor, SelectionEffects};
     use gpui::TestAppContext;
     use language::{Language, LanguageConfig};
     use project::{BasicContextProvider, FakeFs, Project, task_store::TaskStore};
     use serde_json::json;
     use task::{TaskContext, TaskVariables, VariableName};
     use ui::VisualContext;
-    use util::{path, separator};
+    use util::path;
     use workspace::{AppState, Workspace};
 
     use crate::task_contexts;
@@ -412,7 +435,7 @@ mod tests {
         )
         .await;
         let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
-        let worktree_store = project.update(cx, |project, _| project.worktree_store().clone());
+        let worktree_store = project.read_with(cx, |project, _| project.worktree_store().clone());
         let rust_language = Arc::new(
             Language::new(
                 LanguageConfig::default(),
@@ -501,7 +524,8 @@ mod tests {
                 task_variables: TaskVariables::from_iter([
                     (VariableName::File, path!("/dir/rust/b.rs").into()),
                     (VariableName::Filename, "b.rs".into()),
-                    (VariableName::RelativeFile, separator!("rust/b.rs").into()),
+                    (VariableName::RelativeFile, path!("rust/b.rs").into()),
+                    (VariableName::RelativeDir, "rust".into()),
                     (VariableName::Dirname, path!("/dir/rust").into()),
                     (VariableName::Stem, "b".into()),
                     (VariableName::WorktreeRoot, path!("/dir").into()),
@@ -514,7 +538,7 @@ mod tests {
 
         // And now, let's select an identifier.
         editor2.update_in(cx, |editor, window, cx| {
-            editor.change_selections(None, window, cx, |selections| {
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
                 selections.select_ranges([14..18])
             })
         });
@@ -532,7 +556,8 @@ mod tests {
                 task_variables: TaskVariables::from_iter([
                     (VariableName::File, path!("/dir/rust/b.rs").into()),
                     (VariableName::Filename, "b.rs".into()),
-                    (VariableName::RelativeFile, separator!("rust/b.rs").into()),
+                    (VariableName::RelativeFile, path!("rust/b.rs").into()),
+                    (VariableName::RelativeDir, "rust".into()),
                     (VariableName::Dirname, path!("/dir/rust").into()),
                     (VariableName::Stem, "b".into()),
                     (VariableName::WorktreeRoot, path!("/dir").into()),
@@ -561,6 +586,7 @@ mod tests {
                     (VariableName::File, path!("/dir/a.ts").into()),
                     (VariableName::Filename, "a.ts".into()),
                     (VariableName::RelativeFile, "a.ts".into()),
+                    (VariableName::RelativeDir, ".".into()),
                     (VariableName::Dirname, path!("/dir").into()),
                     (VariableName::Stem, "a".into()),
                     (VariableName::WorktreeRoot, path!("/dir").into()),